@jobshimo/browser-link 0.26.0 → 0.27.1
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 +127 -0
- package/dist/cdp/detached-flows.js +272 -0
- package/dist/cdp/detached-flows.js.map +1 -0
- package/dist/cdp/support.js +2 -0
- package/dist/cdp/support.js.map +1 -1
- package/dist/cdp/transport.js +105 -20
- package/dist/cdp/transport.js.map +1 -1
- package/dist/extension/background.js +396 -37
- package/dist/extension/background.js.map +1 -1
- package/dist/extension/flow-registry.d.ts +298 -13
- package/dist/extension/flow-registry.js +443 -37
- package/dist/extension/flow-registry.js.map +1 -1
- package/dist/extension/manifest.json +1 -1
- package/dist/extension/popup.html +15 -0
- package/dist/extension/popup.js +6 -0
- package/dist/extension/popup.js.map +1 -1
- package/dist/messages.d.ts +9 -5
- 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 +63 -2
- package/dist/tools/browser-definitions.js.map +1 -1
- package/dist/tools/browser-dispatch.d.ts +13 -0
- package/dist/tools/browser-dispatch.js +149 -22
- package/dist/tools/browser-dispatch.js.map +1 -1
- package/package.json +1 -1
|
@@ -2,7 +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 { appendFlowHistory, createFlowRegistry, flowHistoryKey, parseFlowHistory, toHistoryEntry, } from './flow-registry.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';
|
|
6
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';
|
|
7
7
|
import { DEFAULT_FLOW_RECORDING_ENABLED, FLOW_RECORDING_STORAGE_KEY, FLOW_RECORDING_UPDATED_AT_STORAGE_KEY, normalizeFlowRecordingEnabled, parseIncomingFlowRecordingSettings, } from './flow-recording-policy.js';
|
|
8
8
|
import { buildRecorderJs, buildStopRecorderJs } from './inpage/recorder.js';
|
|
@@ -173,6 +173,12 @@ const tabStates = new Map();
|
|
|
173
173
|
* the worker mid-flow the flow dies with it, the entry goes with it, and
|
|
174
174
|
* the popup shows nothing running — which is the truth. A registry that
|
|
175
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.
|
|
176
182
|
*/
|
|
177
183
|
const flowRegistry = createFlowRegistry();
|
|
178
184
|
/** Fallback id for a `browser.flow` that arrived without one (a server
|
|
@@ -1298,6 +1304,9 @@ async function handleFlow(tabId, state, msg, p) {
|
|
|
1298
1304
|
// be the worst possible failure of this flag, so only the exact
|
|
1299
1305
|
// boolean opts into it.
|
|
1300
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;
|
|
1301
1310
|
// The server mints the id (see `browser-dispatch.ts`) so the agent gets
|
|
1302
1311
|
// it back even when the extension never answers. The local fallback
|
|
1303
1312
|
// covers a server too old to send one: the popup's Stop button must work
|
|
@@ -1306,39 +1315,113 @@ async function handleFlow(tabId, state, msg, p) {
|
|
|
1306
1315
|
// A dry run dispatches nothing and finishes in milliseconds, so it is
|
|
1307
1316
|
// deliberately NOT registered: there is nothing to stop, and listing it
|
|
1308
1317
|
// would push real runs out of a 20-entry history with entries that never
|
|
1309
|
-
// touched the page.
|
|
1318
|
+
// touched the page. It also ignores `detach` — projecting a flow is
|
|
1319
|
+
// fast and synchronous whichever way the real run would go.
|
|
1310
1320
|
if (dryRun) {
|
|
1311
1321
|
const projection = await runFlow(rawSteps, buildFlowDeps(tabId, state), {
|
|
1312
1322
|
dryRun: true,
|
|
1313
1323
|
});
|
|
1314
1324
|
return { kind: 'tool.response', id: msg.id, ok: true, result: projection };
|
|
1315
1325
|
}
|
|
1316
|
-
|
|
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
|
+
});
|
|
1317
1351
|
// Best effort and deliberately not awaited: the title is only there so
|
|
1318
1352
|
// the popup can tell two concurrent flows apart, and no flow should pay
|
|
1319
1353
|
// a round trip to chrome.tabs before its first step.
|
|
1320
1354
|
void chrome.tabs
|
|
1321
1355
|
.get(tabId)
|
|
1322
1356
|
.then((tab) => {
|
|
1323
|
-
|
|
1324
|
-
if (entry && typeof tab.title === 'string')
|
|
1357
|
+
if (typeof tab.title === 'string')
|
|
1325
1358
|
entry.title = tab.title;
|
|
1326
1359
|
})
|
|
1327
1360
|
.catch(() => {
|
|
1328
1361
|
// Tab gone or title unavailable — the panel falls back to the id.
|
|
1329
1362
|
});
|
|
1330
|
-
|
|
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
|
+
}
|
|
1331
1422
|
let flowResult = null;
|
|
1332
1423
|
try {
|
|
1333
|
-
flowResult = await
|
|
1334
|
-
...buildFlowDeps(tabId, state, shouldCancel),
|
|
1335
|
-
shouldCancel,
|
|
1336
|
-
onProgress: (progress) => {
|
|
1337
|
-
const entry = flowRegistry.get(flowId);
|
|
1338
|
-
if (entry)
|
|
1339
|
-
entry.progress = progress;
|
|
1340
|
-
},
|
|
1341
|
-
});
|
|
1424
|
+
flowResult = await run();
|
|
1342
1425
|
// The wire-level response is ok:true whenever the flow RAN (even a
|
|
1343
1426
|
// failed step, and even a cancellation, is a legitimate business
|
|
1344
1427
|
// outcome — same pattern as wait_for's matched:false) — flowResult
|
|
@@ -1353,6 +1436,20 @@ async function handleFlow(tabId, state, msg, p) {
|
|
|
1353
1436
|
recordFinishedFlow(flowId, flowResult);
|
|
1354
1437
|
}
|
|
1355
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
|
+
}
|
|
1356
1453
|
/** The perform* bindings `runFlow` needs, in one place so the real run and
|
|
1357
1454
|
* the dry-run projection cannot drift apart. `shouldCancel` is threaded
|
|
1358
1455
|
* into `performWaitFor` alone: it is the only perform* with a loop of its
|
|
@@ -1368,35 +1465,270 @@ function buildFlowDeps(tabId, state, shouldCancel) {
|
|
|
1368
1465
|
buildRecoverySnapshot: () => evaluateInTab(tabId, buildSnapshotJs({ only_interactive: true, max_interactive: 40 })),
|
|
1369
1466
|
};
|
|
1370
1467
|
}
|
|
1371
|
-
/** Serializes
|
|
1372
|
-
*
|
|
1373
|
-
*
|
|
1374
|
-
*
|
|
1375
|
-
*
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
*
|
|
1379
|
-
*
|
|
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. */
|
|
1380
1511
|
function recordFinishedFlow(flowId, result) {
|
|
1381
1512
|
const entry = flowRegistry.finish(flowId);
|
|
1382
1513
|
if (!entry)
|
|
1383
1514
|
return;
|
|
1384
|
-
const
|
|
1385
|
-
const
|
|
1386
|
-
|
|
1387
|
-
|
|
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 () => {
|
|
1388
1542
|
const data = await chrome.storage.session.get(key);
|
|
1389
1543
|
await chrome.storage.session.set({
|
|
1390
1544
|
[key]: appendFlowHistory(parseFlowHistory(data[key]), historyEntry),
|
|
1391
1545
|
});
|
|
1392
|
-
})
|
|
1393
|
-
.catch(() => {
|
|
1394
|
-
// Best effort, and the chain must survive: the history is an
|
|
1395
|
-
// operator convenience that dies with the browser session anyway, so
|
|
1396
|
-
// one lost entry is not worth surfacing — but swallowing here is
|
|
1397
|
-
// also what keeps a single failure from poisoning every later write.
|
|
1398
1546
|
});
|
|
1399
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
|
+
*
|
|
1600
|
+
* The sweep is deliberately GLOBAL, not scoped to the launching tab: it can
|
|
1601
|
+
* discard a finished flow's manifest that another tab's agent had not read
|
|
1602
|
+
* yet. That is the accepted trade. The alternative is refusing the launch,
|
|
1603
|
+
* which leaves a flow about to do irreversible work with no record proving
|
|
1604
|
+
* it started — and an unread manifest is recoverable by looking at the
|
|
1605
|
+
* page, while a missing launch record makes a killed worker undetectable. */
|
|
1606
|
+
function pruneDetachedFlowRecords() {
|
|
1607
|
+
return queueTrackedFlowStorageWrite(async () => {
|
|
1608
|
+
const stale = (await readAllDetachedFlowRecords()).filter((r) => r.state !== 'running' && r.eventPending !== true);
|
|
1609
|
+
if (stale.length === 0)
|
|
1610
|
+
return;
|
|
1611
|
+
await chrome.storage.session.remove(stale.map((r) => detachedFlowKey(r.flowId)));
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1614
|
+
/** Write the launch record and prove it landed, pruning old manifests and
|
|
1615
|
+
* retrying ONCE when storage refuses (the plausible refusal is a quota one:
|
|
1616
|
+
* ten records of up to 128 KB against pre-112 Chrome's 1 MB session cap).
|
|
1617
|
+
* False means genuinely unstored — the caller must not start the flow. */
|
|
1618
|
+
async function persistLaunchRecord(entry) {
|
|
1619
|
+
const record = startedDetachedFlowRecord(entry);
|
|
1620
|
+
if (await writeDetachedFlowRecord(record))
|
|
1621
|
+
return true;
|
|
1622
|
+
await pruneDetachedFlowRecords();
|
|
1623
|
+
return writeDetachedFlowRecord(record);
|
|
1624
|
+
}
|
|
1625
|
+
/** Write the terminal record and prove it landed. The retry STRIPS the
|
|
1626
|
+
* manifest instead of repeating the same write: a failed terminal write
|
|
1627
|
+
* does not lose an outcome, it invents a wrong one, because the stale
|
|
1628
|
+
* `running` record left behind is read as `worker-terminated` next start.
|
|
1629
|
+
* If the minimal record is refused too there is nothing left to try. */
|
|
1630
|
+
async function persistTerminalRecord(record) {
|
|
1631
|
+
if (await writeDetachedFlowRecord(record))
|
|
1632
|
+
return;
|
|
1633
|
+
if (await writeDetachedFlowRecord(minimalDetachedFlowRecord(record)))
|
|
1634
|
+
return;
|
|
1635
|
+
console.warn('browser-link: detached-flow terminal record not persisted', record.flowId);
|
|
1636
|
+
}
|
|
1637
|
+
/** Read one detached-flow record. Drains the write chain first so a status
|
|
1638
|
+
* poll landing microseconds after a flow ended never reports the stale
|
|
1639
|
+
* `running` record the launch write left behind. */
|
|
1640
|
+
async function readDetachedFlowRecord(flowId) {
|
|
1641
|
+
try {
|
|
1642
|
+
await flowStorageWrites;
|
|
1643
|
+
const key = detachedFlowKey(flowId);
|
|
1644
|
+
const data = await chrome.storage.session.get(key);
|
|
1645
|
+
return parseDetachedFlowRecord(data[key]) ?? undefined;
|
|
1646
|
+
}
|
|
1647
|
+
catch {
|
|
1648
|
+
return undefined;
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
/** Every detached-flow record currently in session storage. One
|
|
1652
|
+
* `get(null)` rather than N keyed reads — the same sweep
|
|
1653
|
+
* `resumePendingReconnects` does, and the record set is capped at
|
|
1654
|
+
* `MAX_DETACHED_FLOW_RECORDS`. */
|
|
1655
|
+
async function readAllDetachedFlowRecords() {
|
|
1656
|
+
let stored;
|
|
1657
|
+
try {
|
|
1658
|
+
stored = await chrome.storage.session.get(null);
|
|
1659
|
+
}
|
|
1660
|
+
catch {
|
|
1661
|
+
return [];
|
|
1662
|
+
}
|
|
1663
|
+
const records = [];
|
|
1664
|
+
for (const [key, value] of Object.entries(stored)) {
|
|
1665
|
+
if (flowIdFromDetachedFlowKey(key) === null)
|
|
1666
|
+
continue;
|
|
1667
|
+
const record = parseDetachedFlowRecord(value);
|
|
1668
|
+
if (record)
|
|
1669
|
+
records.push(record);
|
|
1670
|
+
}
|
|
1671
|
+
return records;
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* Reconcile detached-flow records left behind by a service worker Chrome
|
|
1675
|
+
* terminated. Runs ONCE per worker start, before anything can register a
|
|
1676
|
+
* flow, so every `running` record it finds is by definition orphaned: the
|
|
1677
|
+
* loop that owned it lived in the dead worker's memory.
|
|
1678
|
+
*
|
|
1679
|
+
* The settled rule (see `terminatedDetachedFlowRecord`, and `DECISIONS.md`
|
|
1680
|
+
* §13): mark it `failed` / `worker-terminated`, write the history entry,
|
|
1681
|
+
* and NEVER resume. A detached flow does irreversible bulk work; resuming
|
|
1682
|
+
* one whose real progress died with the worker would double-act on some
|
|
1683
|
+
* unknowable prefix of it. Failing loudly is the only honest option, and a
|
|
1684
|
+
* flow that looks `running` while nothing executes it is worse than either.
|
|
1685
|
+
*/
|
|
1686
|
+
async function recoverDetachedFlows() {
|
|
1687
|
+
const now = Date.now();
|
|
1688
|
+
for (const record of await readAllDetachedFlowRecords()) {
|
|
1689
|
+
const terminated = terminatedDetachedFlowRecord(record, now);
|
|
1690
|
+
if (!terminated)
|
|
1691
|
+
continue;
|
|
1692
|
+
void writeDetachedFlowRecord(terminated);
|
|
1693
|
+
appendFlowHistoryEntry(terminated.tabId, terminatedFlowHistoryEntry(terminated));
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
/** Flows whose summary event is being flushed right now. `eventPending` is
|
|
1697
|
+
* only cleared by a write queued AFTER the event goes out, so two fast
|
|
1698
|
+
* reconnects of the same tab can both read the same still-pending record
|
|
1699
|
+
* and publish its event twice — an audit trail that double-counts a flow is
|
|
1700
|
+
* as wrong as one that drops it. Membership holds from the send until the
|
|
1701
|
+
* clearing write settles; a write that failed leaves the flag set, and the
|
|
1702
|
+
* next flush legitimately retries. */
|
|
1703
|
+
const flushingFlowEvents = new Set();
|
|
1704
|
+
/** Flush the summary events of any detached flow that ended while this
|
|
1705
|
+
* tab's socket was down — including every flow the worker was killed
|
|
1706
|
+
* under, whose event could not possibly have been sent at the time (no
|
|
1707
|
+
* worker, no socket). Called from the `tab.registered` handler, which is
|
|
1708
|
+
* the first moment there is somewhere to send them. */
|
|
1709
|
+
async function flushPendingFlowEvents(tabId) {
|
|
1710
|
+
// Drain the chain first, as `readDetachedFlowRecord` does: a terminal
|
|
1711
|
+
// record still queued reads as stale `running`, with no event to flush.
|
|
1712
|
+
await flowStorageWrites;
|
|
1713
|
+
for (const record of await readAllDetachedFlowRecords()) {
|
|
1714
|
+
if (record.tabId !== tabId || record.eventPending !== true)
|
|
1715
|
+
continue;
|
|
1716
|
+
if (flushingFlowEvents.has(record.flowId))
|
|
1717
|
+
continue;
|
|
1718
|
+
flushingFlowEvents.add(record.flowId);
|
|
1719
|
+
if (!publishFlowFinished(tabId, record)) {
|
|
1720
|
+
flushingFlowEvents.delete(record.flowId);
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
const { eventPending: _sent, ...flushed } = record;
|
|
1724
|
+
void writeDetachedFlowRecord(flushed).then((stored) => {
|
|
1725
|
+
// A refused clearing write must keep the flag: storage still says
|
|
1726
|
+
// eventPending, so releasing it here would republish a sent event.
|
|
1727
|
+
if (stored)
|
|
1728
|
+
flushingFlowEvents.delete(record.flowId);
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1400
1732
|
async function handleDrag(tabId, msg, p, optStr) {
|
|
1401
1733
|
// Thin wire wrapper over performDrag — the same pattern the standalone
|
|
1402
1734
|
// click/type/press/wait_for cases use over their perform* functions.
|
|
@@ -1571,6 +1903,13 @@ async function handleTool(state, msg) {
|
|
|
1571
1903
|
}
|
|
1572
1904
|
case 'flow':
|
|
1573
1905
|
return await handleFlow(tabId, state, msg, p);
|
|
1906
|
+
case 'flow_status':
|
|
1907
|
+
// Deliberately NOT scoped to `tabId`: the registry and the
|
|
1908
|
+
// detached records are worker-global, keyed by flow_id. The tab
|
|
1909
|
+
// this request arrived on is a transport route, not a filter — so
|
|
1910
|
+
// an agent can still read a finished flow's manifest through any
|
|
1911
|
+
// connected tab after the flow's own tab has closed.
|
|
1912
|
+
return await handleFlowStatus(msg, p);
|
|
1574
1913
|
case 'drag':
|
|
1575
1914
|
return await handleDrag(tabId, msg, p, optStr);
|
|
1576
1915
|
case 'evaluate': {
|
|
@@ -1805,6 +2144,10 @@ async function resumePendingReconnects() {
|
|
|
1805
2144
|
}
|
|
1806
2145
|
}
|
|
1807
2146
|
void resumePendingReconnects();
|
|
2147
|
+
// Same worker-start slot, same rationale: `chrome.storage.session` outlives
|
|
2148
|
+
// the service worker but not the browser, which is exactly the lifetime a
|
|
2149
|
+
// detached flow's record should have. See `recoverDetachedFlows`.
|
|
2150
|
+
void recoverDetachedFlows();
|
|
1808
2151
|
/** Tabs with a `connectTab` currently in flight. The `tabStates.has` check
|
|
1809
2152
|
* inside `doConnectTab` alone cannot prevent two concurrent connects — the
|
|
1810
2153
|
* state lands only after three awaited chrome.* calls — so this SYNCHRONOUS
|
|
@@ -1833,6 +2176,11 @@ async function doConnectTab(tabId) {
|
|
|
1833
2176
|
if (tabStates.has(tabId)) {
|
|
1834
2177
|
return { ok: false, error: 'This tab is already connected' };
|
|
1835
2178
|
}
|
|
2179
|
+
// Whatever tab holds this Chrome id now, it is not the closed one whose
|
|
2180
|
+
// history was dropped — so its own flows are free to write history
|
|
2181
|
+
// again. Also what keeps `flowHistoryDroppedAt` from growing past
|
|
2182
|
+
// live-plus-recently-closed tabs.
|
|
2183
|
+
flowHistoryDroppedAt.delete(tabId);
|
|
1836
2184
|
const tab = await chrome.tabs.get(tabId);
|
|
1837
2185
|
if (!tab.url || !tab.title) {
|
|
1838
2186
|
return { ok: false, error: 'Tab has no URL or title' };
|
|
@@ -1920,6 +2268,11 @@ async function doConnectTab(tabId) {
|
|
|
1920
2268
|
// asks the new primary to honour it. The primary emits
|
|
1921
2269
|
// `tab-renamed` in the bridge event log if it can't.
|
|
1922
2270
|
void storeTabId(tabId, msg.payload.tabId);
|
|
2271
|
+
// First moment there is a socket to send them on: deliver the
|
|
2272
|
+
// summary events of any detached flow that ended while this
|
|
2273
|
+
// tab was disconnected, including everything the worker was
|
|
2274
|
+
// killed under.
|
|
2275
|
+
void flushPendingFlowEvents(tabId);
|
|
1923
2276
|
settle({ ok: true, serverTabId: msg.payload.tabId });
|
|
1924
2277
|
return;
|
|
1925
2278
|
}
|
|
@@ -2481,9 +2834,15 @@ chrome.tabs.onRemoved.addListener((tabId) => {
|
|
|
2481
2834
|
void forgetTabId(tabId);
|
|
2482
2835
|
// …and its flow history. Chrome reuses tab ids within a session, so a
|
|
2483
2836
|
// stale key would eventually show one tab's flows under another's.
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2837
|
+
//
|
|
2838
|
+
// Two guards, because the delete alone is not enough. Routing it through
|
|
2839
|
+
// the SAME serialized chain as the appends keeps it ordered against
|
|
2840
|
+
// every write already queued; the `flowHistoryDroppedAt` stamp handles
|
|
2841
|
+
// the other half — `cleanup` above only ASKS the tab's flows to stop, so
|
|
2842
|
+
// one of them can still finish (and try to append) after this delete has
|
|
2843
|
+
// run, resurrecting the key for a tab that no longer exists.
|
|
2844
|
+
flowHistoryDroppedAt.set(tabId, Date.now());
|
|
2845
|
+
void queueFlowStorageWrite(() => chrome.storage.session.remove(flowHistoryKey(tabId)));
|
|
2487
2846
|
});
|
|
2488
2847
|
/* Auto-connect tabs spawned by a connected tab.
|
|
2489
2848
|
*
|