refactor: extract TabSession for per-tab state isolation (v0.15.16.0) (#873)

* plan: batch command endpoint + multi-tab parallel execution for GStack Browser

* refactor: extract TabSession from BrowserManager for per-tab state

Move per-tab state (refMap, lastSnapshot, frame) into a new TabSession
class. BrowserManager delegates to the active TabSession via
getActiveSession(). Zero behavior change — all existing tests pass.

This is the foundation for the /batch endpoint: both /command and /batch
will use the same handler functions with TabSession, eliminating shared
state races during parallel tab execution.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: update handler signatures to use TabSession

Change handleReadCommand and handleSnapshot to take TabSession instead of
BrowserManager. Change handleWriteCommand to take both TabSession (per-tab
ops) and BrowserManager (global ops like viewport, headers, dialog).
handleMetaCommand keeps BrowserManager for tab management.

Tests use thin wrapper functions that bridge the old 3-arg call pattern to
the new signatures via bm.getActiveSession().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add POST /batch endpoint for parallel multi-tab execution

Execute multiple commands across tabs in a single HTTP request.
Commands targeting different tabs run concurrently via Promise.allSettled.
Commands targeting the same tab run sequentially within that group.

Features:
- Batch-safe command subset (text, goto, click, snapshot, screenshot, etc.)
- newtab/closetab as special commands within batch
- SSE streaming mode (stream: true) for partial results
- Per-command error isolation (one tab failing doesn't abort the batch)
- Max 50 commands per batch, soft batch-level timeout

A 143-page crawl drops from ~45 min (serial HTTP) to ~5 min (20 tabs
in parallel, batched commands).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add batch endpoint integration tests

10 tests covering:
- Multi-tab parallel execution (goto + text on different tabs)
- Same-tab sequential ordering
- Per-command error isolation (one tab fails, others succeed)
- Page-scoped refs (snapshot refs are per-session, not global)
- Per-tab lastSnapshot (snapshot -D with independent baselines)
- getSession/getActiveSession API
- Batch-safe command subset validation
- closeTab via page.close preserves at-least-one-page invariant
- Parallel goto on 3 tabs simultaneously

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: harden codex-review E2E — extract SKILL.md section, bump maxTurns to 25

The test was copying the full 55KB/1075-line codex SKILL.md into the fixture,
requiring 8 Read calls just to consume it and exhausting the 15-turn budget
before reaching the actual codex review command. Now extracts only the
review-relevant section (~6KB/148 lines), reducing Read calls from 8 to 1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: move batch endpoint plan into BROWSER.md as feature documentation

The batch endpoint is implemented — document it as an actual feature in
BROWSER.md (architecture, API shape, design decisions, usage pattern)
and remove the standalone plan file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.15.16.0)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: gstack <ship@gstack.dev>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-04-07 00:23:36 -07:00
committed by GitHub
parent 6cc094cd41
commit 1868636f49
17 changed files with 617 additions and 152 deletions

View File

@@ -18,7 +18,7 @@
*/
import type { Page, Frame, Locator } from 'playwright';
import type { BrowserManager, RefEntry } from './browser-manager';
import type { TabSession, RefEntry } from './tab-session';
import * as Diff from 'diff';
import { TEMP_DIR, isPathWithin } from './platform';
@@ -132,14 +132,14 @@ function parseLine(line: string): ParsedNode | null {
*/
export async function handleSnapshot(
args: string[],
bm: BrowserManager,
session: TabSession,
securityOpts?: { splitForScoped?: boolean },
): Promise<string> {
const opts = parseSnapshotArgs(args);
const page = bm.getPage();
const page = session.getPage();
// Frame-aware target for accessibility tree
const target = bm.getActiveFrameOrPage();
const inFrame = bm.getFrame() !== null;
const target = session.getActiveFrameOrPage();
const inFrame = session.getFrame() !== null;
// Get accessibility tree via ariaSnapshot
let rootLocator: Locator;
@@ -153,7 +153,7 @@ export async function handleSnapshot(
const ariaText = await rootLocator.ariaSnapshot();
if (!ariaText || ariaText.trim().length === 0) {
bm.setRefMap(new Map());
session.setRefMap(new Map());
return '(no accessible elements found)';
}
@@ -338,7 +338,7 @@ export async function handleSnapshot(
}
// Store ref map on BrowserManager
bm.setRefMap(refMap);
session.setRefMap(refMap);
if (output.length === 0) {
return '(no interactive elements found)';
@@ -430,9 +430,9 @@ export async function handleSnapshot(
// ─── Diff mode (-D) ───────────────────────────────────────
if (opts.diff) {
const lastSnapshot = bm.getLastSnapshot();
const lastSnapshot = session.getLastSnapshot();
if (!lastSnapshot) {
bm.setLastSnapshot(snapshotText);
session.setLastSnapshot(snapshotText);
return snapshotText + '\n\n(no previous snapshot to diff against — this snapshot stored as baseline)';
}
@@ -447,16 +447,16 @@ export async function handleSnapshot(
}
}
bm.setLastSnapshot(snapshotText);
session.setLastSnapshot(snapshotText);
return diffOutput.join('\n');
}
// Store for future diffs
bm.setLastSnapshot(snapshotText);
session.setLastSnapshot(snapshotText);
// Add frame context header when operating inside an iframe
if (inFrame) {
const frameUrl = bm.getFrame()?.url() ?? 'unknown';
const frameUrl = session.getFrame()?.url() ?? 'unknown';
output.unshift(`[Context: iframe src="${frameUrl}"]`);
}