@jobshimo/browser-link 0.25.2 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-instructions/content.js +53 -6
- package/dist/agent-instructions/content.js.map +1 -1
- package/dist/bridge/events.d.ts +1 -1
- package/dist/bridge/events.js +11 -1
- package/dist/bridge/events.js.map +1 -1
- package/dist/bridge/ws-bridge.d.ts +18 -0
- package/dist/bridge/ws-bridge.js +24 -0
- package/dist/bridge/ws-bridge.js.map +1 -1
- package/dist/cdp/detached-flows.d.ts +114 -0
- package/dist/cdp/detached-flows.js +242 -0
- package/dist/cdp/detached-flows.js.map +1 -0
- package/dist/cdp/flow.d.ts +76 -8
- package/dist/cdp/flow.js +91 -8
- package/dist/cdp/flow.js.map +1 -1
- package/dist/cdp/support.js +2 -0
- package/dist/cdp/support.js.map +1 -1
- package/dist/cdp/transport.js +132 -19
- package/dist/cdp/transport.js.map +1 -1
- package/dist/extension/background.js +535 -16
- package/dist/extension/background.js.map +1 -1
- package/dist/extension/flow-registry.d.ts +436 -0
- package/dist/extension/flow-registry.js +638 -0
- package/dist/extension/flow-registry.js.map +1 -0
- package/dist/extension/flow.d.ts +81 -5
- package/dist/extension/flow.js +96 -5
- package/dist/extension/flow.js.map +1 -1
- package/dist/extension/manifest.json +1 -1
- package/dist/extension/popup.html +138 -0
- package/dist/extension/popup.js +144 -0
- package/dist/extension/popup.js.map +1 -1
- package/dist/messages.d.ts +32 -1
- package/dist/permissions.js +12 -0
- package/dist/permissions.js.map +1 -1
- package/dist/server.js +10 -1
- package/dist/server.js.map +1 -1
- package/dist/tools/browser-definitions.js +65 -3
- package/dist/tools/browser-definitions.js.map +1 -1
- package/dist/tools/browser-dispatch.d.ts +13 -0
- package/dist/tools/browser-dispatch.js +190 -17
- package/dist/tools/browser-dispatch.js.map +1 -1
- package/package.json +1 -1
|
@@ -2,6 +2,7 @@ import { buildSnapshotJs, buildFindJs, buildClickResolveJs, buildTypeResolveJs,
|
|
|
2
2
|
import { buildKeyEventSequence, resolveKey, modifiersToBitmask, MODIFIER_BITS, } from './keymap.js';
|
|
3
3
|
import { resolveSettleParams, settleSafely } from './settle.js';
|
|
4
4
|
import { runFlow, } from './flow.js';
|
|
5
|
+
import { MAX_DETACHED_FLOW_MS, appendFlowHistory, createFlowRegistry, detachedFlowKey, detachedFlowRecordsToEvict, finishedDetachedFlowRecord, flowHistoryKey, flowIdFromDetachedFlowKey, minimalDetachedFlowRecord, parseDetachedFlowRecord, parseFlowHistory, startedDetachedFlowRecord, terminatedDetachedFlowRecord, terminatedFlowHistoryEntry, toFlowStatus, toHistoryEntry, } from './flow-registry.js';
|
|
5
6
|
import { DEFAULT_IDLE_TTL_MINUTES, IDLE_TTL_STORAGE_KEY, IDLE_TTL_UPDATED_AT_STORAGE_KEY, clampIdleTtlMinutes, parseIncomingSettings, shouldAcceptIncomingSettings, shouldDisconnectForIdle, shouldScheduleIdleSweep, } from './idle-policy.js';
|
|
6
7
|
import { DEFAULT_FLOW_RECORDING_ENABLED, FLOW_RECORDING_STORAGE_KEY, FLOW_RECORDING_UPDATED_AT_STORAGE_KEY, normalizeFlowRecordingEnabled, parseIncomingFlowRecordingSettings, } from './flow-recording-policy.js';
|
|
7
8
|
import { buildRecorderJs, buildStopRecorderJs } from './inpage/recorder.js';
|
|
@@ -145,6 +146,8 @@ function isRuntimeMessage(msg) {
|
|
|
145
146
|
if (m.action === 'saveRecording') {
|
|
146
147
|
return typeof m.tabId === 'number' && typeof m.name === 'string';
|
|
147
148
|
}
|
|
149
|
+
if (m.action === 'cancelFlow')
|
|
150
|
+
return typeof m.flowId === 'string';
|
|
148
151
|
if (typeof m.tabId !== 'number')
|
|
149
152
|
return false;
|
|
150
153
|
return (m.action === 'connect' ||
|
|
@@ -155,9 +158,53 @@ function isRuntimeMessage(msg) {
|
|
|
155
158
|
m.action === 'recordingStatus' ||
|
|
156
159
|
m.action === 'startRecording' ||
|
|
157
160
|
m.action === 'stopRecording' ||
|
|
158
|
-
m.action === 'discardRecording'
|
|
161
|
+
m.action === 'discardRecording' ||
|
|
162
|
+
m.action === 'flowsStatus');
|
|
159
163
|
}
|
|
160
164
|
const tabStates = new Map();
|
|
165
|
+
/**
|
|
166
|
+
* Every `browser.flow` currently running in this service worker, across
|
|
167
|
+
* every connected tab. This is the whole basis of the kill switch: the
|
|
168
|
+
* runner polls `cancelled` off an entry here before each step, so stopping
|
|
169
|
+
* a flow means flipping one boolean and letting the loop decline to
|
|
170
|
+
* dispatch step N+1 — never interrupting an in-flight CDP command.
|
|
171
|
+
*
|
|
172
|
+
* Lives in the service worker's memory ON PURPOSE. If Chrome terminates
|
|
173
|
+
* the worker mid-flow the flow dies with it, the entry goes with it, and
|
|
174
|
+
* the popup shows nothing running — which is the truth. A registry that
|
|
175
|
+
* survived the worker would have to describe flows that no longer exist.
|
|
176
|
+
*
|
|
177
|
+
* A DETACHED flow additionally leaves a `detached-flow:<id>` record in
|
|
178
|
+
* `chrome.storage.session` (see `flow-registry.ts`), which is a different
|
|
179
|
+
* thing entirely: not a live registry, but the paper trail that lets
|
|
180
|
+
* `recoverDetachedFlows` below notice a worker termination and report it
|
|
181
|
+
* as `failed` / `worker-terminated` instead of leaving a phantom.
|
|
182
|
+
*/
|
|
183
|
+
const flowRegistry = createFlowRegistry();
|
|
184
|
+
/** Fallback id for a `browser.flow` that arrived without one (a server
|
|
185
|
+
* older than this extension). `crypto.randomUUID` is available in the MV3
|
|
186
|
+
* service worker; the `flow_` prefix keeps a locally-minted id visually
|
|
187
|
+
* distinguishable from a server-minted one in the popup. */
|
|
188
|
+
function newFlowId() {
|
|
189
|
+
return `flow_${crypto.randomUUID()}`;
|
|
190
|
+
}
|
|
191
|
+
/** Answer the popup's Flows panel: everything running (across ALL tabs —
|
|
192
|
+
* the Stop button has to reach a flow on a tab the user is not looking at)
|
|
193
|
+
* plus the completed history of the ONE tab the popup is showing. */
|
|
194
|
+
async function getFlowsStatus(tabId) {
|
|
195
|
+
const running = flowRegistry.listRunning(Date.now());
|
|
196
|
+
let history = [];
|
|
197
|
+
try {
|
|
198
|
+
const key = flowHistoryKey(tabId);
|
|
199
|
+
const data = await chrome.storage.session.get(key);
|
|
200
|
+
history = parseFlowHistory(data[key]);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// Session storage unavailable — show the running half rather than
|
|
204
|
+
// failing the whole panel.
|
|
205
|
+
}
|
|
206
|
+
return { running, history };
|
|
207
|
+
}
|
|
161
208
|
/** Per-tab `chrome.tabs.onUpdated` listener installed while that tab is
|
|
162
209
|
* recording, so `handleRecordingNavigation` fires on every navigation and
|
|
163
210
|
* `stopRecording`/`cleanup` can remove EXACTLY this listener rather than
|
|
@@ -778,7 +825,15 @@ async function performPress(tabId, params) {
|
|
|
778
825
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
779
826
|
}
|
|
780
827
|
}
|
|
781
|
-
async function performWaitFor(tabId, state, params
|
|
828
|
+
async function performWaitFor(tabId, state, params,
|
|
829
|
+
/** Cancellation flag of the flow this wait belongs to, when it is a flow
|
|
830
|
+
* step (see `handleFlow`). Checked on EVERY poll tick, which is what
|
|
831
|
+
* makes the advertised worst-case cancellation latency of one step true
|
|
832
|
+
* for a step that can legitimately park for 30 seconds — without it,
|
|
833
|
+
* pressing Stop during a long `wait_for` would do nothing visible until
|
|
834
|
+
* the wait timed out on its own. Absent for the standalone
|
|
835
|
+
* `browser.wait_for` tool, which has no flow to be cancelled with. */
|
|
836
|
+
shouldCancel) {
|
|
782
837
|
try {
|
|
783
838
|
const waitSelector = params.selector;
|
|
784
839
|
const waitExpression = params.expression;
|
|
@@ -846,7 +901,12 @@ async function performWaitFor(tabId, state, params) {
|
|
|
846
901
|
const startedAt = Date.now();
|
|
847
902
|
let checks = 0;
|
|
848
903
|
let matched = false;
|
|
904
|
+
let cancelled = false;
|
|
849
905
|
while (check) {
|
|
906
|
+
if (shouldCancel?.() === true) {
|
|
907
|
+
cancelled = true;
|
|
908
|
+
break;
|
|
909
|
+
}
|
|
850
910
|
checks++;
|
|
851
911
|
if (await check()) {
|
|
852
912
|
matched = true;
|
|
@@ -857,9 +917,19 @@ async function performWaitFor(tabId, state, params) {
|
|
|
857
917
|
await sleep(pollIntervalMs);
|
|
858
918
|
}
|
|
859
919
|
const elapsedMs = Date.now() - startedAt;
|
|
920
|
+
// 'cancelled' is deliberately distinct from 'timeout': the step
|
|
921
|
+
// executor turns a timeout into a flow FAILURE (with a recovery
|
|
922
|
+
// snapshot) and a cancellation into a clean `stopped_by: 'cancelled'`.
|
|
923
|
+
// Reporting a stopped wait as a timeout would invent a diagnosis for a
|
|
924
|
+
// condition that was never given its budget.
|
|
860
925
|
const result = matched
|
|
861
926
|
? { matched: true, elapsed_ms: elapsedMs, checks }
|
|
862
|
-
: {
|
|
927
|
+
: {
|
|
928
|
+
matched: false,
|
|
929
|
+
elapsed_ms: elapsedMs,
|
|
930
|
+
checks,
|
|
931
|
+
reason: cancelled ? 'cancelled' : 'timeout',
|
|
932
|
+
};
|
|
863
933
|
return { ok: true, result };
|
|
864
934
|
}
|
|
865
935
|
catch (err) {
|
|
@@ -1229,25 +1299,410 @@ async function handleFlow(tabId, state, msg, p) {
|
|
|
1229
1299
|
const rawSteps = Array.isArray(p.steps)
|
|
1230
1300
|
? p.steps.filter((s) => typeof s === 'object' && s !== null)
|
|
1231
1301
|
: [];
|
|
1232
|
-
|
|
1302
|
+
// Strict `=== true`: any other wire value (missing, "true", 1) runs
|
|
1303
|
+
// the flow FOR REAL. A dry run that silently became a real run would
|
|
1304
|
+
// be the worst possible failure of this flag, so only the exact
|
|
1305
|
+
// boolean opts into it.
|
|
1306
|
+
const dryRun = p.dry_run === true;
|
|
1307
|
+
// Strict `=== true` for the same reason: a flow that silently detached
|
|
1308
|
+
// would keep acting after the caller believed it had returned.
|
|
1309
|
+
const detach = p.detach === true;
|
|
1310
|
+
// The server mints the id (see `browser-dispatch.ts`) so the agent gets
|
|
1311
|
+
// it back even when the extension never answers. The local fallback
|
|
1312
|
+
// covers a server too old to send one: the popup's Stop button must work
|
|
1313
|
+
// regardless of which half was upgraded first.
|
|
1314
|
+
const flowId = typeof p.flow_id === 'string' && p.flow_id.length > 0 ? p.flow_id : newFlowId();
|
|
1315
|
+
// A dry run dispatches nothing and finishes in milliseconds, so it is
|
|
1316
|
+
// deliberately NOT registered: there is nothing to stop, and listing it
|
|
1317
|
+
// would push real runs out of a 20-entry history with entries that never
|
|
1318
|
+
// touched the page. It also ignores `detach` — projecting a flow is
|
|
1319
|
+
// fast and synchronous whichever way the real run would go.
|
|
1320
|
+
if (dryRun) {
|
|
1321
|
+
const projection = await runFlow(rawSteps, buildFlowDeps(tabId, state), {
|
|
1322
|
+
dryRun: true,
|
|
1323
|
+
});
|
|
1324
|
+
return { kind: 'tool.response', id: msg.id, ok: true, result: projection };
|
|
1325
|
+
}
|
|
1326
|
+
if (detach) {
|
|
1327
|
+
// ONE detached flow per tab, enforced HERE because the registry is the
|
|
1328
|
+
// only place that knows. Two loops clicking through the same page have
|
|
1329
|
+
// no use case and every failure mode: they interleave on the same DOM,
|
|
1330
|
+
// race each other's `while_found` probes, and leave a human with two
|
|
1331
|
+
// Stop buttons and no way to tell which is which. The error names the
|
|
1332
|
+
// running id so the caller can inspect or stop it.
|
|
1333
|
+
const running = flowRegistry.detachedForTab(tabId);
|
|
1334
|
+
if (running) {
|
|
1335
|
+
return {
|
|
1336
|
+
kind: 'tool.response',
|
|
1337
|
+
id: msg.id,
|
|
1338
|
+
ok: false,
|
|
1339
|
+
error: `flow: a detached flow is already running on this tab (${running.flowId}). ` +
|
|
1340
|
+
`Wait for it, or stop it with browser.flow_cancel, before detaching another.`,
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
const entry = flowRegistry.register({
|
|
1345
|
+
flowId,
|
|
1346
|
+
tabId,
|
|
1347
|
+
steps: rawSteps.length,
|
|
1348
|
+
detached: detach,
|
|
1349
|
+
...(detach ? { expiresAt: Date.now() + MAX_DETACHED_FLOW_MS } : {}),
|
|
1350
|
+
});
|
|
1351
|
+
// Best effort and deliberately not awaited: the title is only there so
|
|
1352
|
+
// the popup can tell two concurrent flows apart, and no flow should pay
|
|
1353
|
+
// a round trip to chrome.tabs before its first step.
|
|
1354
|
+
void chrome.tabs
|
|
1355
|
+
.get(tabId)
|
|
1356
|
+
.then((tab) => {
|
|
1357
|
+
if (typeof tab.title === 'string')
|
|
1358
|
+
entry.title = tab.title;
|
|
1359
|
+
})
|
|
1360
|
+
.catch(() => {
|
|
1361
|
+
// Tab gone or title unavailable — the panel falls back to the id.
|
|
1362
|
+
});
|
|
1363
|
+
// Also covers the detached ceiling: `shouldStop` flips the entry to
|
|
1364
|
+
// `expired` the first time the deadline has passed, so the outcome
|
|
1365
|
+
// distinguishes "ran out of time" from "someone pressed Stop".
|
|
1366
|
+
const shouldCancel = () => flowRegistry.shouldStop(flowId, Date.now());
|
|
1367
|
+
const run = () => runFlow(rawSteps, {
|
|
1368
|
+
...buildFlowDeps(tabId, state, shouldCancel),
|
|
1369
|
+
shouldCancel,
|
|
1370
|
+
onProgress: (progress) => {
|
|
1371
|
+
entry.progress = progress;
|
|
1372
|
+
},
|
|
1373
|
+
});
|
|
1374
|
+
if (detach) {
|
|
1375
|
+
// The record lands BEFORE the first step is dispatched: it is the only
|
|
1376
|
+
// evidence a worker termination leaves behind, and a flow that acted
|
|
1377
|
+
// before it was recorded would be exactly the invisible run this whole
|
|
1378
|
+
// slice exists to make impossible. A record that will not store is
|
|
1379
|
+
// therefore a refusal, not a warning: nothing has run yet, and that is
|
|
1380
|
+
// the only moment refusing is still free.
|
|
1381
|
+
if (!(await persistLaunchRecord(entry))) {
|
|
1382
|
+
flowRegistry.finish(flowId);
|
|
1383
|
+
return {
|
|
1384
|
+
kind: 'tool.response',
|
|
1385
|
+
id: msg.id,
|
|
1386
|
+
ok: false,
|
|
1387
|
+
error: 'flow: could not persist the detached-flow launch record ' +
|
|
1388
|
+
'(chrome.storage.session write failed). NOTHING was executed: a ' +
|
|
1389
|
+
'detached flow with no record acts irreversibly with no evidence.',
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
// Deliberately floating. This is the entire point of `detach`: the
|
|
1393
|
+
// tool.response below goes out now, the loop keeps running, and the
|
|
1394
|
+
// agent's turn is free. `recordFinishedFlow` in the `finally` is what
|
|
1395
|
+
// eventually retires it.
|
|
1396
|
+
void (async () => {
|
|
1397
|
+
let flowResult = null;
|
|
1398
|
+
try {
|
|
1399
|
+
flowResult = await run();
|
|
1400
|
+
}
|
|
1401
|
+
catch {
|
|
1402
|
+
// A throw (detached debugger, closed tab) is a `failed` outcome —
|
|
1403
|
+
// recorded below from a null result. Nothing to rethrow to: there
|
|
1404
|
+
// is no caller left.
|
|
1405
|
+
}
|
|
1406
|
+
finally {
|
|
1407
|
+
recordFinishedFlow(flowId, flowResult);
|
|
1408
|
+
}
|
|
1409
|
+
})();
|
|
1410
|
+
return {
|
|
1411
|
+
kind: 'tool.response',
|
|
1412
|
+
id: msg.id,
|
|
1413
|
+
ok: true,
|
|
1414
|
+
result: {
|
|
1415
|
+
detached: true,
|
|
1416
|
+
started_at: entry.startedAt,
|
|
1417
|
+
steps: rawSteps.length,
|
|
1418
|
+
expires_at: entry.expiresAt,
|
|
1419
|
+
},
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
let flowResult = null;
|
|
1423
|
+
try {
|
|
1424
|
+
flowResult = await run();
|
|
1425
|
+
// The wire-level response is ok:true whenever the flow RAN (even a
|
|
1426
|
+
// failed step, and even a cancellation, is a legitimate business
|
|
1427
|
+
// outcome — same pattern as wait_for's matched:false) — flowResult
|
|
1428
|
+
// itself carries the ok:true/false and stopped_by the agent reads.
|
|
1429
|
+
return { kind: 'tool.response', id: msg.id, ok: true, result: flowResult };
|
|
1430
|
+
}
|
|
1431
|
+
finally {
|
|
1432
|
+
// In `finally` so a throw (detached debugger, closed tab) still retires
|
|
1433
|
+
// the registry entry — a phantom "running" flow in the popup would be
|
|
1434
|
+
// worse than no panel at all, since it offers a Stop button that can
|
|
1435
|
+
// never do anything.
|
|
1436
|
+
recordFinishedFlow(flowId, flowResult);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
/** Answer the `flow_status` wire tool (behind `browser.flow_status`).
|
|
1440
|
+
* Reads the live registry first and falls back to the persisted detached
|
|
1441
|
+
* record; an id neither knows comes back as `state: 'unknown'`, never as an
|
|
1442
|
+
* error — see `FlowState`. */
|
|
1443
|
+
async function handleFlowStatus(msg, p) {
|
|
1444
|
+
const flowId = typeof p.flow_id === 'string' ? p.flow_id : '';
|
|
1445
|
+
if (flowId.length === 0) {
|
|
1446
|
+
return { kind: 'tool.response', id: msg.id, ok: false, error: 'flow_status: flow_id required' };
|
|
1447
|
+
}
|
|
1448
|
+
const entry = flowRegistry.get(flowId);
|
|
1449
|
+
const record = entry ? undefined : await readDetachedFlowRecord(flowId);
|
|
1450
|
+
const status = toFlowStatus(flowId, entry, record);
|
|
1451
|
+
return { kind: 'tool.response', id: msg.id, ok: true, result: status };
|
|
1452
|
+
}
|
|
1453
|
+
/** The perform* bindings `runFlow` needs, in one place so the real run and
|
|
1454
|
+
* the dry-run projection cannot drift apart. `shouldCancel` is threaded
|
|
1455
|
+
* into `performWaitFor` alone: it is the only perform* with a loop of its
|
|
1456
|
+
* own to break out of. */
|
|
1457
|
+
function buildFlowDeps(tabId, state, shouldCancel) {
|
|
1458
|
+
return {
|
|
1233
1459
|
performFind: (params) => performFind(tabId, params),
|
|
1234
1460
|
performClick: (params) => performClick(tabId, params),
|
|
1235
1461
|
performType: (params) => performType(tabId, params),
|
|
1236
1462
|
performPress: (params) => performPress(tabId, params),
|
|
1237
|
-
performWaitFor: (params) => performWaitFor(tabId, state, params),
|
|
1463
|
+
performWaitFor: (params) => performWaitFor(tabId, state, params, shouldCancel),
|
|
1238
1464
|
performDrag: (params) => performDrag(tabId, params),
|
|
1239
1465
|
buildRecoverySnapshot: () => evaluateInTab(tabId, buildSnapshotJs({ only_interactive: true, max_interactive: 40 })),
|
|
1240
|
-
}
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1466
|
+
};
|
|
1467
|
+
}
|
|
1468
|
+
/** Serializes EVERY flow-related `chrome.storage.session` mutation —
|
|
1469
|
+
* history appends, detached-record writes, and the history drop when a tab
|
|
1470
|
+
* closes. Appending is a read-modify-write over an async store, so two
|
|
1471
|
+
* flows on the same tab finishing microseconds apart would otherwise both
|
|
1472
|
+
* read the same list and the second `set` would drop the first's entry;
|
|
1473
|
+
* and a tab-removal delete OUTSIDE the chain could be overtaken by a
|
|
1474
|
+
* still-running flow's late append, resurrecting the key it just dropped.
|
|
1475
|
+
* One global chain (rather than one per tab or one per concern) is enough:
|
|
1476
|
+
* these writes happen a handful of times per flow, never in a hot path. */
|
|
1477
|
+
let flowStorageWrites = Promise.resolve();
|
|
1478
|
+
/** Append one unit of work to the serialized chain. Swallows failures so a
|
|
1479
|
+
* single storage error cannot poison every later write — the session store
|
|
1480
|
+
* dies with the browser anyway, so a lost entry is not worth surfacing. */
|
|
1481
|
+
function queueFlowStorageWrite(work) {
|
|
1482
|
+
flowStorageWrites = flowStorageWrites.then(work).catch(() => {
|
|
1483
|
+
// Best effort, and the chain must survive.
|
|
1484
|
+
});
|
|
1485
|
+
return flowStorageWrites;
|
|
1486
|
+
}
|
|
1487
|
+
/** Same chain, but the caller learns whether the write landed. A lost
|
|
1488
|
+
* detached-flow record is not a lost entry: the launch record is a flow's
|
|
1489
|
+
* only evidence, and a lost terminal write leaves a stale `running` record
|
|
1490
|
+
* the next worker start reads as an orphan. The rejection is converted to
|
|
1491
|
+
* `false`, never rethrown, so the chain survives as it does above. */
|
|
1492
|
+
function queueTrackedFlowStorageWrite(work) {
|
|
1493
|
+
const tracked = flowStorageWrites.then(work).then(() => true, () => false);
|
|
1494
|
+
flowStorageWrites = tracked.then(() => undefined);
|
|
1495
|
+
return tracked;
|
|
1496
|
+
}
|
|
1497
|
+
/** Chrome tab ids whose flow history has been dropped because the tab
|
|
1498
|
+
* closed, and WHEN. Chrome reuses tab ids within a session, so the
|
|
1499
|
+
* timestamp matters: a flow that started before the removal belongs to the
|
|
1500
|
+
* tab that is gone and must not be written back (that is the resurrection
|
|
1501
|
+
* the serialized chain alone cannot prevent, since the late write is
|
|
1502
|
+
* queued after the delete); a flow that started after it belongs to
|
|
1503
|
+
* whatever tab holds the id now. Cleared when a tab connects under the id
|
|
1504
|
+
* again, so this never grows past live-plus-recently-closed tabs. */
|
|
1505
|
+
const flowHistoryDroppedAt = new Map();
|
|
1506
|
+
/** Retire a finished flow: drop it from the in-flight registry, prepend its
|
|
1507
|
+
* outcome to the tab's session-scoped history, and — for a detached flow —
|
|
1508
|
+
* finalize its record and emit the one-per-flow audit event. Never throws:
|
|
1509
|
+
* a storage failure must not turn a completed flow into a failed tool call,
|
|
1510
|
+
* and for a detached flow there is no caller left to fail anyway. */
|
|
1511
|
+
function recordFinishedFlow(flowId, result) {
|
|
1512
|
+
const entry = flowRegistry.finish(flowId);
|
|
1513
|
+
if (!entry)
|
|
1514
|
+
return;
|
|
1515
|
+
const endedAt = Date.now();
|
|
1516
|
+
const droppedAt = flowHistoryDroppedAt.get(entry.tabId);
|
|
1517
|
+
// Skip the append entirely for a tab that has already been removed — see
|
|
1518
|
+
// `flowHistoryDroppedAt`. Ordering on the chain is not enough on its own,
|
|
1519
|
+
// because this write is queued AFTER the delete was.
|
|
1520
|
+
if (droppedAt === undefined || droppedAt <= entry.startedAt) {
|
|
1521
|
+
appendFlowHistoryEntry(entry.tabId, toHistoryEntry(entry, result, endedAt));
|
|
1522
|
+
}
|
|
1523
|
+
if (!entry.detached)
|
|
1524
|
+
return;
|
|
1525
|
+
const record = finishedDetachedFlowRecord(entry, result, endedAt);
|
|
1526
|
+
// Exactly ONE summary event per detached flow, pushed here and nowhere
|
|
1527
|
+
// else — never per iteration. `BridgeEventLog` holds 200 entries, so a
|
|
1528
|
+
// single 200-iteration run emitting per-iteration events would silently
|
|
1529
|
+
// evict every other event in the log, which is the opposite of an audit
|
|
1530
|
+
// trail. If the socket is already gone the event is deferred onto the
|
|
1531
|
+
// record and flushed when the tab next registers.
|
|
1532
|
+
if (!publishFlowFinished(entry.tabId, record))
|
|
1533
|
+
record.eventPending = true;
|
|
1534
|
+
void persistTerminalRecord(record);
|
|
1535
|
+
}
|
|
1536
|
+
/** Prepend one entry to a tab's session-scoped flow history, on the
|
|
1537
|
+
* serialized chain (the append is a read-modify-write, so two flows
|
|
1538
|
+
* finishing microseconds apart would otherwise lose one). */
|
|
1539
|
+
function appendFlowHistoryEntry(tabId, historyEntry) {
|
|
1540
|
+
const key = flowHistoryKey(tabId);
|
|
1541
|
+
void queueFlowStorageWrite(async () => {
|
|
1542
|
+
const data = await chrome.storage.session.get(key);
|
|
1543
|
+
await chrome.storage.session.set({
|
|
1544
|
+
[key]: appendFlowHistory(parseFlowHistory(data[key]), historyEntry),
|
|
1545
|
+
});
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
1548
|
+
/** Push the one-per-flow summary `bridge.event`. Returns whether it
|
|
1549
|
+
* actually went out — the caller defers it onto the record when it did
|
|
1550
|
+
* not, so an audit trail is never quietly dropped just because the tab's
|
|
1551
|
+
* socket happened to be closed. Counts and enums only: the manifest itself
|
|
1552
|
+
* is addressable by `flow_id` through `browser.flow_status` and has no
|
|
1553
|
+
* business in a 200-entry ring buffer. */
|
|
1554
|
+
function publishFlowFinished(tabId, record) {
|
|
1555
|
+
const state = tabStates.get(tabId);
|
|
1556
|
+
if (!state?.ws || state.ws.readyState !== WebSocket.OPEN)
|
|
1557
|
+
return false;
|
|
1558
|
+
send(state.ws, {
|
|
1559
|
+
kind: 'bridge.event',
|
|
1560
|
+
eventKind: 'flow-finished',
|
|
1561
|
+
...(state.serverTabId === undefined ? {} : { tabId: state.serverTabId }),
|
|
1562
|
+
data: {
|
|
1563
|
+
flow_id: record.flowId,
|
|
1564
|
+
state: record.state,
|
|
1565
|
+
detached: true,
|
|
1566
|
+
steps: record.steps,
|
|
1567
|
+
steps_completed: record.stepsCompleted,
|
|
1568
|
+
...(record.iterationsCompleted === undefined
|
|
1569
|
+
? {}
|
|
1570
|
+
: { iterations_completed: record.iterationsCompleted }),
|
|
1571
|
+
duration_ms: Math.max(0, (record.endedAt ?? record.startedAt) - record.startedAt),
|
|
1572
|
+
...(record.stoppedBy === undefined ? {} : { stopped_by: record.stoppedBy }),
|
|
1573
|
+
manifest_available: record.manifest !== undefined,
|
|
1574
|
+
},
|
|
1575
|
+
});
|
|
1576
|
+
return true;
|
|
1577
|
+
}
|
|
1578
|
+
/** Persist one detached-flow record and enforce the record cap. Runs on the
|
|
1579
|
+
* serialized chain so a launch write and the matching terminal write can
|
|
1580
|
+
* never land out of order. Reports whether THE RECORD landed; the cap sweep
|
|
1581
|
+
* that follows is best effort and must not make a stored record look lost. */
|
|
1582
|
+
function writeDetachedFlowRecord(record) {
|
|
1583
|
+
return queueTrackedFlowStorageWrite(async () => {
|
|
1584
|
+
await chrome.storage.session.set({ [detachedFlowKey(record.flowId)]: record });
|
|
1585
|
+
try {
|
|
1586
|
+
const evict = detachedFlowRecordsToEvict(await readAllDetachedFlowRecords());
|
|
1587
|
+
if (evict.length === 0)
|
|
1588
|
+
return;
|
|
1589
|
+
await chrome.storage.session.remove(evict.map((r) => detachedFlowKey(r.flowId)));
|
|
1590
|
+
}
|
|
1591
|
+
catch {
|
|
1592
|
+
// Over the cap until the next write sweeps again.
|
|
1593
|
+
}
|
|
1594
|
+
});
|
|
1595
|
+
}
|
|
1596
|
+
/** Free session-storage quota by dropping every terminal record not still
|
|
1597
|
+
* owing its summary event. Only after a rejected write: at that point old
|
|
1598
|
+
* manifests are worth less than the launch record of a flow about to act. */
|
|
1599
|
+
function pruneDetachedFlowRecords() {
|
|
1600
|
+
return queueTrackedFlowStorageWrite(async () => {
|
|
1601
|
+
const stale = (await readAllDetachedFlowRecords()).filter((r) => r.state !== 'running' && r.eventPending !== true);
|
|
1602
|
+
if (stale.length === 0)
|
|
1603
|
+
return;
|
|
1604
|
+
await chrome.storage.session.remove(stale.map((r) => detachedFlowKey(r.flowId)));
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
/** Write the launch record and prove it landed, pruning old manifests and
|
|
1608
|
+
* retrying ONCE when storage refuses (the plausible refusal is a quota one:
|
|
1609
|
+
* ten records of up to 128 KB against pre-112 Chrome's 1 MB session cap).
|
|
1610
|
+
* False means genuinely unstored — the caller must not start the flow. */
|
|
1611
|
+
async function persistLaunchRecord(entry) {
|
|
1612
|
+
const record = startedDetachedFlowRecord(entry);
|
|
1613
|
+
if (await writeDetachedFlowRecord(record))
|
|
1614
|
+
return true;
|
|
1615
|
+
await pruneDetachedFlowRecords();
|
|
1616
|
+
return writeDetachedFlowRecord(record);
|
|
1617
|
+
}
|
|
1618
|
+
/** Write the terminal record and prove it landed. The retry STRIPS the
|
|
1619
|
+
* manifest instead of repeating the same write: a failed terminal write
|
|
1620
|
+
* does not lose an outcome, it invents a wrong one, because the stale
|
|
1621
|
+
* `running` record left behind is read as `worker-terminated` next start.
|
|
1622
|
+
* If the minimal record is refused too there is nothing left to try. */
|
|
1623
|
+
async function persistTerminalRecord(record) {
|
|
1624
|
+
if (await writeDetachedFlowRecord(record))
|
|
1625
|
+
return;
|
|
1626
|
+
if (await writeDetachedFlowRecord(minimalDetachedFlowRecord(record)))
|
|
1627
|
+
return;
|
|
1628
|
+
console.warn('browser-link: detached-flow terminal record not persisted', record.flowId);
|
|
1629
|
+
}
|
|
1630
|
+
/** Read one detached-flow record. Drains the write chain first so a status
|
|
1631
|
+
* poll landing microseconds after a flow ended never reports the stale
|
|
1632
|
+
* `running` record the launch write left behind. */
|
|
1633
|
+
async function readDetachedFlowRecord(flowId) {
|
|
1634
|
+
try {
|
|
1635
|
+
await flowStorageWrites;
|
|
1636
|
+
const key = detachedFlowKey(flowId);
|
|
1637
|
+
const data = await chrome.storage.session.get(key);
|
|
1638
|
+
return parseDetachedFlowRecord(data[key]) ?? undefined;
|
|
1639
|
+
}
|
|
1640
|
+
catch {
|
|
1641
|
+
return undefined;
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
/** Every detached-flow record currently in session storage. One
|
|
1645
|
+
* `get(null)` rather than N keyed reads — the same sweep
|
|
1646
|
+
* `resumePendingReconnects` does, and the record set is capped at
|
|
1647
|
+
* `MAX_DETACHED_FLOW_RECORDS`. */
|
|
1648
|
+
async function readAllDetachedFlowRecords() {
|
|
1649
|
+
let stored;
|
|
1650
|
+
try {
|
|
1651
|
+
stored = await chrome.storage.session.get(null);
|
|
1652
|
+
}
|
|
1653
|
+
catch {
|
|
1654
|
+
return [];
|
|
1655
|
+
}
|
|
1656
|
+
const records = [];
|
|
1657
|
+
for (const [key, value] of Object.entries(stored)) {
|
|
1658
|
+
if (flowIdFromDetachedFlowKey(key) === null)
|
|
1659
|
+
continue;
|
|
1660
|
+
const record = parseDetachedFlowRecord(value);
|
|
1661
|
+
if (record)
|
|
1662
|
+
records.push(record);
|
|
1663
|
+
}
|
|
1664
|
+
return records;
|
|
1665
|
+
}
|
|
1666
|
+
/**
|
|
1667
|
+
* Reconcile detached-flow records left behind by a service worker Chrome
|
|
1668
|
+
* terminated. Runs ONCE per worker start, before anything can register a
|
|
1669
|
+
* flow, so every `running` record it finds is by definition orphaned: the
|
|
1670
|
+
* loop that owned it lived in the dead worker's memory.
|
|
1671
|
+
*
|
|
1672
|
+
* The settled rule (see `terminatedDetachedFlowRecord`, and `DECISIONS.md`
|
|
1673
|
+
* §13): mark it `failed` / `worker-terminated`, write the history entry,
|
|
1674
|
+
* and NEVER resume. A detached flow does irreversible bulk work; resuming
|
|
1675
|
+
* one whose real progress died with the worker would double-act on some
|
|
1676
|
+
* unknowable prefix of it. Failing loudly is the only honest option, and a
|
|
1677
|
+
* flow that looks `running` while nothing executes it is worse than either.
|
|
1678
|
+
*/
|
|
1679
|
+
async function recoverDetachedFlows() {
|
|
1680
|
+
const now = Date.now();
|
|
1681
|
+
for (const record of await readAllDetachedFlowRecords()) {
|
|
1682
|
+
const terminated = terminatedDetachedFlowRecord(record, now);
|
|
1683
|
+
if (!terminated)
|
|
1684
|
+
continue;
|
|
1685
|
+
void writeDetachedFlowRecord(terminated);
|
|
1686
|
+
appendFlowHistoryEntry(terminated.tabId, terminatedFlowHistoryEntry(terminated));
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
/** Flush the summary events of any detached flow that ended while this
|
|
1690
|
+
* tab's socket was down — including every flow the worker was killed
|
|
1691
|
+
* under, whose event could not possibly have been sent at the time (no
|
|
1692
|
+
* worker, no socket). Called from the `tab.registered` handler, which is
|
|
1693
|
+
* the first moment there is somewhere to send them. */
|
|
1694
|
+
async function flushPendingFlowEvents(tabId) {
|
|
1695
|
+
// Drain the chain first, as `readDetachedFlowRecord` does: a terminal
|
|
1696
|
+
// record still queued reads as stale `running`, with no event to flush.
|
|
1697
|
+
await flowStorageWrites;
|
|
1698
|
+
for (const record of await readAllDetachedFlowRecords()) {
|
|
1699
|
+
if (record.tabId !== tabId || record.eventPending !== true)
|
|
1700
|
+
continue;
|
|
1701
|
+
if (!publishFlowFinished(tabId, record))
|
|
1702
|
+
return;
|
|
1703
|
+
const { eventPending: _sent, ...flushed } = record;
|
|
1704
|
+
void writeDetachedFlowRecord(flushed);
|
|
1705
|
+
}
|
|
1251
1706
|
}
|
|
1252
1707
|
async function handleDrag(tabId, msg, p, optStr) {
|
|
1253
1708
|
// Thin wire wrapper over performDrag — the same pattern the standalone
|
|
@@ -1423,6 +1878,13 @@ async function handleTool(state, msg) {
|
|
|
1423
1878
|
}
|
|
1424
1879
|
case 'flow':
|
|
1425
1880
|
return await handleFlow(tabId, state, msg, p);
|
|
1881
|
+
case 'flow_status':
|
|
1882
|
+
// Deliberately NOT scoped to `tabId`: the registry and the
|
|
1883
|
+
// detached records are worker-global, keyed by flow_id. The tab
|
|
1884
|
+
// this request arrived on is a transport route, not a filter — so
|
|
1885
|
+
// an agent can still read a finished flow's manifest through any
|
|
1886
|
+
// connected tab after the flow's own tab has closed.
|
|
1887
|
+
return await handleFlowStatus(msg, p);
|
|
1426
1888
|
case 'drag':
|
|
1427
1889
|
return await handleDrag(tabId, msg, p, optStr);
|
|
1428
1890
|
case 'evaluate': {
|
|
@@ -1475,6 +1937,12 @@ async function cleanup(tabId) {
|
|
|
1475
1937
|
// `state` and raw CDP calls, never the map entry — and deleting first
|
|
1476
1938
|
// also makes a re-entrant cleanup a no-op instead of a double detach.
|
|
1477
1939
|
tabStates.delete(tabId);
|
|
1940
|
+
// A flow cannot outlive its bridge: the debugger is about to detach, so
|
|
1941
|
+
// every remaining step would fail one by one anyway. Cancelling instead
|
|
1942
|
+
// makes the flow stop AT ONCE and report what it completed — and, just
|
|
1943
|
+
// as importantly, keeps the popup from offering a Stop button for a flow
|
|
1944
|
+
// whose tab is already being torn down.
|
|
1945
|
+
flowRegistry.cancelForTab(tabId);
|
|
1478
1946
|
// Recording never survives a disconnect — there is no server connection
|
|
1479
1947
|
// left to save to, and leaving captured-but-unsaved steps around after
|
|
1480
1948
|
// the tab drops its bridge would be a surprising place for them to
|
|
@@ -1651,6 +2119,10 @@ async function resumePendingReconnects() {
|
|
|
1651
2119
|
}
|
|
1652
2120
|
}
|
|
1653
2121
|
void resumePendingReconnects();
|
|
2122
|
+
// Same worker-start slot, same rationale: `chrome.storage.session` outlives
|
|
2123
|
+
// the service worker but not the browser, which is exactly the lifetime a
|
|
2124
|
+
// detached flow's record should have. See `recoverDetachedFlows`.
|
|
2125
|
+
void recoverDetachedFlows();
|
|
1654
2126
|
/** Tabs with a `connectTab` currently in flight. The `tabStates.has` check
|
|
1655
2127
|
* inside `doConnectTab` alone cannot prevent two concurrent connects — the
|
|
1656
2128
|
* state lands only after three awaited chrome.* calls — so this SYNCHRONOUS
|
|
@@ -1679,6 +2151,11 @@ async function doConnectTab(tabId) {
|
|
|
1679
2151
|
if (tabStates.has(tabId)) {
|
|
1680
2152
|
return { ok: false, error: 'This tab is already connected' };
|
|
1681
2153
|
}
|
|
2154
|
+
// Whatever tab holds this Chrome id now, it is not the closed one whose
|
|
2155
|
+
// history was dropped — so its own flows are free to write history
|
|
2156
|
+
// again. Also what keeps `flowHistoryDroppedAt` from growing past
|
|
2157
|
+
// live-plus-recently-closed tabs.
|
|
2158
|
+
flowHistoryDroppedAt.delete(tabId);
|
|
1682
2159
|
const tab = await chrome.tabs.get(tabId);
|
|
1683
2160
|
if (!tab.url || !tab.title) {
|
|
1684
2161
|
return { ok: false, error: 'Tab has no URL or title' };
|
|
@@ -1766,6 +2243,11 @@ async function doConnectTab(tabId) {
|
|
|
1766
2243
|
// asks the new primary to honour it. The primary emits
|
|
1767
2244
|
// `tab-renamed` in the bridge event log if it can't.
|
|
1768
2245
|
void storeTabId(tabId, msg.payload.tabId);
|
|
2246
|
+
// First moment there is a socket to send them on: deliver the
|
|
2247
|
+
// summary events of any detached flow that ended while this
|
|
2248
|
+
// tab was disconnected, including everything the worker was
|
|
2249
|
+
// killed under.
|
|
2250
|
+
void flushPendingFlowEvents(tabId);
|
|
1769
2251
|
settle({ ok: true, serverTabId: msg.payload.tabId });
|
|
1770
2252
|
return;
|
|
1771
2253
|
}
|
|
@@ -1808,6 +2290,20 @@ async function doConnectTab(tabId) {
|
|
|
1808
2290
|
send(ws, response);
|
|
1809
2291
|
return;
|
|
1810
2292
|
}
|
|
2293
|
+
case 'tool.cancel': {
|
|
2294
|
+
// Deliberately answered with SILENCE: the cancelled flow is
|
|
2295
|
+
// still parked in its own tool.request and replies there,
|
|
2296
|
+
// cleanly, with stopped_by:'cancelled' and its partial
|
|
2297
|
+
// results. An unknown or already-finished flow_id is a no-op —
|
|
2298
|
+
// the caller wanted the flow not running, and it is not.
|
|
2299
|
+
//
|
|
2300
|
+
// Counts as activity: an agent reaching for the kill switch is
|
|
2301
|
+
// very much using this tab, and letting the idle sweeper close
|
|
2302
|
+
// the bridge underneath it would be perverse.
|
|
2303
|
+
state.lastActivityAt = Date.now();
|
|
2304
|
+
flowRegistry.cancel(msg.flow_id);
|
|
2305
|
+
return;
|
|
2306
|
+
}
|
|
1811
2307
|
default: {
|
|
1812
2308
|
// Compile-time exhaustiveness check; at runtime an unknown kind
|
|
1813
2309
|
// (newer server, older extension) is ignored defensively.
|
|
@@ -2287,6 +2783,18 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
|
|
2287
2783
|
void saveRecording(msg.tabId, msg.name, msg.description).then(sendResponse);
|
|
2288
2784
|
return true;
|
|
2289
2785
|
}
|
|
2786
|
+
if (msg.action === 'flowsStatus') {
|
|
2787
|
+
void getFlowsStatus(msg.tabId).then(sendResponse);
|
|
2788
|
+
return true;
|
|
2789
|
+
}
|
|
2790
|
+
if (msg.action === 'cancelFlow') {
|
|
2791
|
+
// Synchronous and unconditional: one boolean flip, then the runner
|
|
2792
|
+
// stops at its next check. `cancelled` reports whether THIS call is
|
|
2793
|
+
// what stopped it, so the popup can distinguish "stopping…" from an
|
|
2794
|
+
// id that had already finished — never an error either way.
|
|
2795
|
+
sendResponse({ cancelled: flowRegistry.cancel(msg.flowId) });
|
|
2796
|
+
return false;
|
|
2797
|
+
}
|
|
2290
2798
|
// 'status'
|
|
2291
2799
|
sendResponse(getTabStatus(msg.tabId));
|
|
2292
2800
|
return false;
|
|
@@ -2299,6 +2807,17 @@ chrome.tabs.onRemoved.addListener((tabId) => {
|
|
|
2299
2807
|
});
|
|
2300
2808
|
// The Chrome tab is gone — drop any cached browser-link id for it.
|
|
2301
2809
|
void forgetTabId(tabId);
|
|
2810
|
+
// …and its flow history. Chrome reuses tab ids within a session, so a
|
|
2811
|
+
// stale key would eventually show one tab's flows under another's.
|
|
2812
|
+
//
|
|
2813
|
+
// Two guards, because the delete alone is not enough. Routing it through
|
|
2814
|
+
// the SAME serialized chain as the appends keeps it ordered against
|
|
2815
|
+
// every write already queued; the `flowHistoryDroppedAt` stamp handles
|
|
2816
|
+
// the other half — `cleanup` above only ASKS the tab's flows to stop, so
|
|
2817
|
+
// one of them can still finish (and try to append) after this delete has
|
|
2818
|
+
// run, resurrecting the key for a tab that no longer exists.
|
|
2819
|
+
flowHistoryDroppedAt.set(tabId, Date.now());
|
|
2820
|
+
void queueFlowStorageWrite(() => chrome.storage.session.remove(flowHistoryKey(tabId)));
|
|
2302
2821
|
});
|
|
2303
2822
|
/* Auto-connect tabs spawned by a connected tab.
|
|
2304
2823
|
*
|