@crouton-kit/capture 1.3.17 → 1.3.18
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/bin/capture +81 -35
- package/package.json +1 -1
- package/src/cdp/commands/cdp.ts +5 -5
- package/src/cdp/record.ts +37 -5
- package/src/session/commands.ts +64 -27
package/bin/capture
CHANGED
|
@@ -7093,6 +7093,9 @@ var init_artifacts = __esm({
|
|
|
7093
7093
|
function activeScopeKey() {
|
|
7094
7094
|
return process.env.CRTR_NODE_ID ?? "default";
|
|
7095
7095
|
}
|
|
7096
|
+
function activeSessionScopeKey() {
|
|
7097
|
+
return activeScopeKey();
|
|
7098
|
+
}
|
|
7096
7099
|
function getActivePath() {
|
|
7097
7100
|
return path2.join(CAPTURE_ROOT, `.active-${activeScopeKey()}`);
|
|
7098
7101
|
}
|
|
@@ -8379,6 +8382,32 @@ var init_a11y = __esm({
|
|
|
8379
8382
|
});
|
|
8380
8383
|
|
|
8381
8384
|
// src/cdp/record.ts
|
|
8385
|
+
function isMissingDefaultExecutionContext(err) {
|
|
8386
|
+
return err instanceof Error && err.message === "Cannot find default execution context";
|
|
8387
|
+
}
|
|
8388
|
+
async function readReadyStateWithRetry(client, timeout) {
|
|
8389
|
+
const deadline = Date.now() + timeout;
|
|
8390
|
+
let lastRetryableError;
|
|
8391
|
+
while (true) {
|
|
8392
|
+
const remaining = deadline - Date.now();
|
|
8393
|
+
if (remaining <= 0) {
|
|
8394
|
+
if (lastRetryableError) throw lastRetryableError;
|
|
8395
|
+
throw new Error("Runtime.evaluate deadline exceeded");
|
|
8396
|
+
}
|
|
8397
|
+
try {
|
|
8398
|
+
const result = await client.send("Runtime.evaluate", {
|
|
8399
|
+
expression: "document.readyState",
|
|
8400
|
+
returnByValue: true
|
|
8401
|
+
}, remaining);
|
|
8402
|
+
return result?.result?.value;
|
|
8403
|
+
} catch (err) {
|
|
8404
|
+
const remainingAfterAttempt = deadline - Date.now();
|
|
8405
|
+
if (!isMissingDefaultExecutionContext(err) || remainingAfterAttempt <= 0) throw err;
|
|
8406
|
+
lastRetryableError = err;
|
|
8407
|
+
await new Promise((resolve11) => setTimeout(resolve11, Math.min(50, remainingAfterAttempt)));
|
|
8408
|
+
}
|
|
8409
|
+
}
|
|
8410
|
+
}
|
|
8382
8411
|
async function navigateAndWait(port, url, options = {}) {
|
|
8383
8412
|
const timeout = options.timeout ?? 1e4;
|
|
8384
8413
|
if (!options.forceNew) {
|
|
@@ -8412,11 +8441,7 @@ async function navigateAndWait(port, url, options = {}) {
|
|
|
8412
8441
|
}, timeout);
|
|
8413
8442
|
});
|
|
8414
8443
|
try {
|
|
8415
|
-
const
|
|
8416
|
-
expression: "document.readyState",
|
|
8417
|
-
returnByValue: true
|
|
8418
|
-
});
|
|
8419
|
-
const readyState = result?.result?.value;
|
|
8444
|
+
const readyState = await readReadyStateWithRetry(client, timeout);
|
|
8420
8445
|
if (readyState !== "complete") {
|
|
8421
8446
|
await loadPromise;
|
|
8422
8447
|
if (loadTimeoutError) throw loadTimeoutError;
|
|
@@ -9842,7 +9867,7 @@ function validateHarAppendBatch(value, source) {
|
|
|
9842
9867
|
return { entries, incompleteLifecycles };
|
|
9843
9868
|
}
|
|
9844
9869
|
function captureVersion() {
|
|
9845
|
-
const version = "1.3.
|
|
9870
|
+
const version = "1.3.18";
|
|
9846
9871
|
return typeof version === "string" && version.length > 0 ? version : "development";
|
|
9847
9872
|
}
|
|
9848
9873
|
function token() {
|
|
@@ -11601,7 +11626,7 @@ function wallTime(value) {
|
|
|
11601
11626
|
}
|
|
11602
11627
|
}
|
|
11603
11628
|
function captureVersion2() {
|
|
11604
|
-
const version = "1.3.
|
|
11629
|
+
const version = "1.3.18";
|
|
11605
11630
|
return typeof version === "string" && version.length > 0 ? version : "development";
|
|
11606
11631
|
}
|
|
11607
11632
|
function deepFreeze(value) {
|
|
@@ -13825,25 +13850,30 @@ function collectRecs(dir) {
|
|
|
13825
13850
|
function sessionMetaPath2(id) {
|
|
13826
13851
|
return path12.join(sessionDir(id), ".session.json");
|
|
13827
13852
|
}
|
|
13853
|
+
function tryReadSession(id) {
|
|
13854
|
+
const metaPath2 = sessionMetaPath2(id);
|
|
13855
|
+
const bad = (reason) => ({ ok: false, id, metaPath: metaPath2, reason });
|
|
13856
|
+
let parsed;
|
|
13857
|
+
try {
|
|
13858
|
+
parsed = JSON.parse(readPrivateFile(metaPath2).toString("utf-8"));
|
|
13859
|
+
} catch (error) {
|
|
13860
|
+
if (error.code === "ENOENT") throw error;
|
|
13861
|
+
return bad("not valid JSON; the record is corrupt");
|
|
13862
|
+
}
|
|
13863
|
+
if (!isActiveStateCandidate(parsed)) return bad("does not match the session record schema; written by an incompatible version or corrupted");
|
|
13864
|
+
return { ok: true, session: parsed };
|
|
13865
|
+
}
|
|
13828
13866
|
function readSession(id) {
|
|
13829
13867
|
const metaPath2 = sessionMetaPath2(id);
|
|
13830
|
-
let
|
|
13868
|
+
let read;
|
|
13831
13869
|
try {
|
|
13832
|
-
|
|
13870
|
+
read = tryReadSession(id);
|
|
13833
13871
|
} catch (error) {
|
|
13834
13872
|
if (error.code === "ENOENT") throw captureError("precondition", "unknown_session", `No capture session found: ${id}`);
|
|
13835
13873
|
throw error;
|
|
13836
13874
|
}
|
|
13837
|
-
|
|
13838
|
-
|
|
13839
|
-
parsed = JSON.parse(raw);
|
|
13840
|
-
} catch (cause) {
|
|
13841
|
-
throw captureError("artifact", "invalid_session_record", `session metadata at ${metaPath2} is not valid JSON; the record is corrupt and cannot be read as a capture session.`, cause);
|
|
13842
|
-
}
|
|
13843
|
-
if (!isActiveStateCandidate(parsed)) {
|
|
13844
|
-
throw captureError("artifact", "invalid_session_record", `session metadata at ${metaPath2} does not match the capture session record schema; it was written by an incompatible version or corrupted.`);
|
|
13845
|
-
}
|
|
13846
|
-
return parsed;
|
|
13875
|
+
if (!read.ok) throw captureError("artifact", "invalid_session_record", `session metadata at ${metaPath2} ${read.reason}.`);
|
|
13876
|
+
return read.session;
|
|
13847
13877
|
}
|
|
13848
13878
|
function generateId() {
|
|
13849
13879
|
return `cap-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
@@ -13907,9 +13937,9 @@ async function start(parsed) {
|
|
|
13907
13937
|
if (active && !isSessionStopped(active)) {
|
|
13908
13938
|
emitResult({
|
|
13909
13939
|
tag: "error",
|
|
13910
|
-
attrs: { command: "session start", code: "
|
|
13911
|
-
summary: fact`
|
|
13912
|
-
followUp:
|
|
13940
|
+
attrs: { command: "session start", code: "session_already_active" },
|
|
13941
|
+
summary: fact`Session ${active.sessionId} is already live in this capture scope (${activeSessionScopeKey()}); one live session per scope.`,
|
|
13942
|
+
followUp: fact`Scopes are per-agent (CRTR_NODE_ID), so this session is yours — a concurrent agent's session never blocks yours. Keep using it (every command auto-targets its tab), or run \`capture session stop ${active.sessionId}\` to start fresh. To drive a different tab without ending it, pass \`--target <target-id>\` from \`capture tab list\`.`
|
|
13913
13943
|
}, { json: parsed.json });
|
|
13914
13944
|
process.exitCode = 1;
|
|
13915
13945
|
return;
|
|
@@ -14433,12 +14463,21 @@ function list(parsed) {
|
|
|
14433
14463
|
return;
|
|
14434
14464
|
}
|
|
14435
14465
|
if (rejectSurplusPositionals(parsed, "list", 0, "capture session list")) return;
|
|
14436
|
-
const
|
|
14437
|
-
|
|
14438
|
-
|
|
14439
|
-
|
|
14466
|
+
const reads = fs9.existsSync(CAPTURE_ROOT) ? fs9.readdirSync(CAPTURE_ROOT).filter((d) => fs9.existsSync(sessionMetaPath2(d))).flatMap((d) => {
|
|
14467
|
+
try {
|
|
14468
|
+
return [tryReadSession(d)];
|
|
14469
|
+
} catch {
|
|
14470
|
+
return [];
|
|
14471
|
+
}
|
|
14440
14472
|
}) : [];
|
|
14441
|
-
|
|
14473
|
+
const sessions = reads.flatMap((read) => {
|
|
14474
|
+
if (!read.ok) return [];
|
|
14475
|
+
const session = read.session;
|
|
14476
|
+
const hasBundled = fs9.existsSync(path12.join(session.dir, "bundle.json"));
|
|
14477
|
+
return [{ id: session.sessionId, startedAt: session.startedAt ?? "", url: session.url ?? null, status: hasBundled ? "stopped" : "active" }];
|
|
14478
|
+
});
|
|
14479
|
+
const unreadable = reads.flatMap((read) => read.ok ? [] : [read]);
|
|
14480
|
+
if (reads.length === 0) {
|
|
14442
14481
|
emitResult({
|
|
14443
14482
|
tag: "sessions",
|
|
14444
14483
|
attrs: { count: 0 },
|
|
@@ -14452,10 +14491,14 @@ function list(parsed) {
|
|
|
14452
14491
|
s.url ? fact` — ${s.url}` : text``
|
|
14453
14492
|
)
|
|
14454
14493
|
);
|
|
14494
|
+
const unreadableRows = unreadable.map(
|
|
14495
|
+
(u) => line(fact`${u.id} — unreadable — ${u.metaPath} — ${u.reason}`)
|
|
14496
|
+
);
|
|
14455
14497
|
emitResult({
|
|
14456
14498
|
tag: "sessions",
|
|
14457
|
-
attrs: { count: sessions.length },
|
|
14458
|
-
sections: [lineList(rows)]
|
|
14499
|
+
attrs: { count: sessions.length, unreadable: unreadable.length },
|
|
14500
|
+
sections: [lineList([...rows, ...unreadableRows])],
|
|
14501
|
+
...unreadable.length > 0 ? { followUp: text`Unreadable records are corrupt or written by an incompatible version; they are inert. Delete the named .session.json (or its session dir) to drop the row.` } : {}
|
|
14459
14502
|
}, { json: parsed.json });
|
|
14460
14503
|
}
|
|
14461
14504
|
function sectionRows(manifest, key) {
|
|
@@ -14665,8 +14708,11 @@ input:
|
|
|
14665
14708
|
(none)
|
|
14666
14709
|
|
|
14667
14710
|
output:
|
|
14668
|
-
One <sessions count=\u2026> block, one row per session: id, status
|
|
14711
|
+
One <sessions count=\u2026 unreadable=\u2026> block, one row per session: id, status
|
|
14669
14712
|
(active|stopped), start time, and URL when set. --json mirrors the same rows.
|
|
14713
|
+
A record that is corrupt or written by an incompatible version lists as its
|
|
14714
|
+
own "<id> \u2014 unreadable \u2014 <path> \u2014 <reason>" row and is excluded from count;
|
|
14715
|
+
one bad record never hides the others.
|
|
14670
14716
|
|
|
14671
14717
|
effects:
|
|
14672
14718
|
None \u2014 reads session metadata only.`;
|
|
@@ -228435,11 +228481,11 @@ async function runBrowserScope(method, params, parsed, timeoutMs, deps7 = DEFAUL
|
|
|
228435
228481
|
const attached = await client.send("Target.attachToTarget", {
|
|
228436
228482
|
targetId: tab.id,
|
|
228437
228483
|
flatten: true
|
|
228438
|
-
});
|
|
228484
|
+
}, timeoutMs);
|
|
228439
228485
|
sessionId = attached.sessionId;
|
|
228440
228486
|
}
|
|
228441
228487
|
const eventPromise = parsed.waitEvent ? waitForEventOnce(client, parsed.waitEvent, timeoutMs) : void 0;
|
|
228442
|
-
const result = method ? await client.send(method, params ?? {},
|
|
228488
|
+
const result = method ? await client.send(method, params ?? {}, timeoutMs, sessionId) : void 0;
|
|
228443
228489
|
const event = eventPromise ? await eventPromise : void 0;
|
|
228444
228490
|
emitCdpResult({
|
|
228445
228491
|
method,
|
|
@@ -228476,7 +228522,7 @@ async function runPageScope(method, params, parsed, timeoutMs, connect = connect
|
|
|
228476
228522
|
}
|
|
228477
228523
|
} else {
|
|
228478
228524
|
const eventPromise = parsed.waitEvent ? waitForEventOnce(client, parsed.waitEvent, timeoutMs) : void 0;
|
|
228479
|
-
result = method ? await client.send(method, params ?? {}) : void 0;
|
|
228525
|
+
result = method ? await client.send(method, params ?? {}, timeoutMs) : void 0;
|
|
228480
228526
|
event = eventPromise ? await eventPromise : void 0;
|
|
228481
228527
|
}
|
|
228482
228528
|
emitCdpResult({
|
|
@@ -228532,7 +228578,7 @@ Input:
|
|
|
228532
228578
|
--browser route through the held connection (session start --hold) instead of a one-shot page websocket. Connection-scoped state (permission grants, domain enables) reverts the instant its connection closes \u2014 it survives across commands only inside a held session.
|
|
228533
228579
|
--port <port> CDP endpoint. An explicit flag selects that endpoint even when an active session holds another browser connection.
|
|
228534
228580
|
--target <id> with --browser: attach a flattened CDP session on the held connection to this target (for target-scoped domains), including an already-open tab adopted by \`session start --hold\` without --url; without --browser: the page target to run against. 8-char id prefix accepted.
|
|
228535
|
-
--timeout <ms> event-wait timeout (default ${DEFAULT_TIMEOUT_MS}ms).
|
|
228581
|
+
--timeout <ms> one-shot method-response and event-wait timeout (default ${DEFAULT_TIMEOUT_MS}ms). WebSocket connection setup is separately bounded to 5000ms.
|
|
228536
228582
|
|
|
228537
228583
|
Output:
|
|
228538
228584
|
<cdp-result method=\u2026 wait-event=\u2026 scope=\u2026> \u2014 the protocol result and/or awaited event as an escaped, length-capped JSON payload. --json mirrors the same block with the payload at full fidelity.
|
|
@@ -241172,7 +241218,7 @@ CDP_PORT / CDP_TARGET pin the browser + tab for orchestrators \u2014 precedence:
|
|
|
241172
241218
|
explicit flag > active session > env.`;
|
|
241173
241219
|
}
|
|
241174
241220
|
function printVersion() {
|
|
241175
|
-
const declared = "1.3.
|
|
241221
|
+
const declared = "1.3.18";
|
|
241176
241222
|
if (declared) {
|
|
241177
241223
|
console.log(declared);
|
|
241178
241224
|
return;
|
package/package.json
CHANGED
package/src/cdp/commands/cdp.ts
CHANGED
|
@@ -58,7 +58,7 @@ Input:
|
|
|
58
58
|
--browser route through the held connection (session start --hold) instead of a one-shot page websocket. Connection-scoped state (permission grants, domain enables) reverts the instant its connection closes — it survives across commands only inside a held session.
|
|
59
59
|
--port <port> CDP endpoint. An explicit flag selects that endpoint even when an active session holds another browser connection.
|
|
60
60
|
--target <id> with --browser: attach a flattened CDP session on the held connection to this target (for target-scoped domains), including an already-open tab adopted by \`session start --hold\` without --url; without --browser: the page target to run against. 8-char id prefix accepted.
|
|
61
|
-
--timeout <ms> event-wait timeout (default ${DEFAULT_TIMEOUT_MS}ms).
|
|
61
|
+
--timeout <ms> one-shot method-response and event-wait timeout (default ${DEFAULT_TIMEOUT_MS}ms). WebSocket connection setup is separately bounded to 5000ms.
|
|
62
62
|
|
|
63
63
|
Output:
|
|
64
64
|
<cdp-result method=… wait-event=… scope=…> — the protocol result and/or awaited event as an escaped, length-capped JSON payload. --json mirrors the same block with the payload at full fidelity.
|
|
@@ -73,7 +73,7 @@ Effects:
|
|
|
73
73
|
* command wiring against a stub without a live browser.
|
|
74
74
|
*/
|
|
75
75
|
export interface CdpScopeClient {
|
|
76
|
-
send(method: string, params?: Record<string, unknown
|
|
76
|
+
send(method: string, params?: Record<string, unknown>, timeout?: number): Promise<unknown>;
|
|
77
77
|
on(event: string, handler: (params: unknown) => void): void;
|
|
78
78
|
close(): void;
|
|
79
79
|
}
|
|
@@ -279,11 +279,11 @@ export async function runBrowserScope(
|
|
|
279
279
|
const attached = (await client.send('Target.attachToTarget', {
|
|
280
280
|
targetId: tab.id,
|
|
281
281
|
flatten: true,
|
|
282
|
-
})) as { sessionId: string };
|
|
282
|
+
}, timeoutMs)) as { sessionId: string };
|
|
283
283
|
sessionId = attached.sessionId;
|
|
284
284
|
}
|
|
285
285
|
const eventPromise = parsed.waitEvent ? waitForEventOnce(client, parsed.waitEvent, timeoutMs) : undefined;
|
|
286
|
-
const result = method ? await client.send(method, params ?? {},
|
|
286
|
+
const result = method ? await client.send(method, params ?? {}, timeoutMs, sessionId) : undefined;
|
|
287
287
|
const event = eventPromise ? await eventPromise : undefined;
|
|
288
288
|
emitCdpResult({
|
|
289
289
|
method,
|
|
@@ -349,7 +349,7 @@ export async function runPageScope(
|
|
|
349
349
|
}
|
|
350
350
|
} else {
|
|
351
351
|
const eventPromise = parsed.waitEvent ? waitForEventOnce(client, parsed.waitEvent, timeoutMs) : undefined;
|
|
352
|
-
result = method ? await client.send(method, params ?? {}) : undefined;
|
|
352
|
+
result = method ? await client.send(method, params ?? {}, timeoutMs) : undefined;
|
|
353
353
|
event = eventPromise ? await eventPromise : undefined;
|
|
354
354
|
}
|
|
355
355
|
emitCdpResult({
|
package/src/cdp/record.ts
CHANGED
|
@@ -11,6 +11,42 @@ import { type CDPTarget } from './types.js';
|
|
|
11
11
|
* load-wait helper. Navigation of an existing tab lives in
|
|
12
12
|
* `./commands/page/navigate.ts` via ordinary page targeting.
|
|
13
13
|
*/
|
|
14
|
+
function isMissingDefaultExecutionContext(err: unknown): boolean {
|
|
15
|
+
return err instanceof Error && err.message === 'Cannot find default execution context';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Arc can report the page load before it has installed the default execution
|
|
20
|
+
* context (notably for hash-routed Gmail URLs). Retry only that known transient
|
|
21
|
+
* CDP error; every other Runtime.evaluate failure remains fatal.
|
|
22
|
+
*/
|
|
23
|
+
export async function readReadyStateWithRetry(
|
|
24
|
+
client: Pick<CDPClient, 'send'>,
|
|
25
|
+
timeout: number,
|
|
26
|
+
): Promise<unknown> {
|
|
27
|
+
const deadline = Date.now() + timeout;
|
|
28
|
+
let lastRetryableError: unknown;
|
|
29
|
+
while (true) {
|
|
30
|
+
const remaining = deadline - Date.now();
|
|
31
|
+
if (remaining <= 0) {
|
|
32
|
+
if (lastRetryableError) throw lastRetryableError;
|
|
33
|
+
throw new Error('Runtime.evaluate deadline exceeded');
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const result = await client.send('Runtime.evaluate', {
|
|
37
|
+
expression: 'document.readyState',
|
|
38
|
+
returnByValue: true,
|
|
39
|
+
}, remaining);
|
|
40
|
+
return (result as { result?: { value?: unknown } } | undefined)?.result?.value;
|
|
41
|
+
} catch (err) {
|
|
42
|
+
const remainingAfterAttempt = deadline - Date.now();
|
|
43
|
+
if (!isMissingDefaultExecutionContext(err) || remainingAfterAttempt <= 0) throw err;
|
|
44
|
+
lastRetryableError = err;
|
|
45
|
+
await new Promise<void>((resolve) => setTimeout(resolve, Math.min(50, remainingAfterAttempt)));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
14
50
|
export async function navigateAndWait(
|
|
15
51
|
port: number,
|
|
16
52
|
url: string,
|
|
@@ -59,11 +95,7 @@ export async function navigateAndWait(
|
|
|
59
95
|
});
|
|
60
96
|
|
|
61
97
|
try {
|
|
62
|
-
const
|
|
63
|
-
expression: 'document.readyState',
|
|
64
|
-
returnByValue: true,
|
|
65
|
-
});
|
|
66
|
-
const readyState = (result as { result?: { value?: unknown } } | undefined)?.result?.value;
|
|
98
|
+
const readyState = await readReadyStateWithRetry(client, timeout);
|
|
67
99
|
if (readyState !== 'complete') {
|
|
68
100
|
await loadPromise;
|
|
69
101
|
if (loadTimeoutError) throw loadTimeoutError;
|
package/src/session/commands.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from '../har-manager.js';
|
|
12
12
|
import {
|
|
13
13
|
getActiveSession,
|
|
14
|
+
activeSessionScopeKey,
|
|
14
15
|
setActiveSession,
|
|
15
16
|
clearActiveSessionIf,
|
|
16
17
|
updateSessionState,
|
|
@@ -235,10 +236,38 @@ function sessionMetaPath(id: string): string {
|
|
|
235
236
|
return path.join(sessionDir(id), '.session.json');
|
|
236
237
|
}
|
|
237
238
|
|
|
239
|
+
/** Outcome of reading one persisted record without failing the caller: either
|
|
240
|
+
* the validated session or the short reason its `.session.json` is unreadable. */
|
|
241
|
+
type SessionRead =
|
|
242
|
+
| { ok: true; session: Session }
|
|
243
|
+
| { ok: false; id: string; metaPath: string; reason: string };
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Reads one record without throwing. A listing is what an agent reaches for
|
|
247
|
+
* precisely when it is working out what state it is in, so one corrupt or
|
|
248
|
+
* legacy-schema record on a shared capture root must not make every other
|
|
249
|
+
* session invisible — `list` renders the reason as its own unreadable row.
|
|
250
|
+
* A missing file is not a read outcome here; only `readSession` (targeted
|
|
251
|
+
* commands) turns that into `unknown_session`.
|
|
252
|
+
*/
|
|
253
|
+
function tryReadSession(id: string): SessionRead {
|
|
254
|
+
const metaPath = sessionMetaPath(id);
|
|
255
|
+
const bad = (reason: string): SessionRead => ({ ok: false, id, metaPath, reason });
|
|
256
|
+
let parsed: unknown;
|
|
257
|
+
try {
|
|
258
|
+
parsed = JSON.parse(readPrivateFile(metaPath).toString('utf-8'));
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw error;
|
|
261
|
+
return bad('not valid JSON; the record is corrupt');
|
|
262
|
+
}
|
|
263
|
+
if (!isActiveStateCandidate(parsed)) return bad('does not match the session record schema; written by an incompatible version or corrupted');
|
|
264
|
+
return { ok: true, session: parsed };
|
|
265
|
+
}
|
|
266
|
+
|
|
238
267
|
/**
|
|
239
|
-
* Reads and VALIDATES one persisted `.session.json
|
|
240
|
-
*
|
|
241
|
-
*
|
|
268
|
+
* Reads and VALIDATES one persisted `.session.json` for a command that named
|
|
269
|
+
* this session. A missing file throws a typed `unknown_session` precondition
|
|
270
|
+
* error; unparsable bytes or a record failing the shared
|
|
242
271
|
* `isActiveStateCandidate` schema throw a typed `invalid_session_record`
|
|
243
272
|
* artifact error instead of letting a structurally-trusted cast reach the
|
|
244
273
|
* renderer (where a legacy-schema record used to crash `session list`
|
|
@@ -246,23 +275,15 @@ function sessionMetaPath(id: string): string {
|
|
|
246
275
|
*/
|
|
247
276
|
function readSession(id: string): Session {
|
|
248
277
|
const metaPath = sessionMetaPath(id);
|
|
249
|
-
let
|
|
278
|
+
let read: SessionRead;
|
|
250
279
|
try {
|
|
251
|
-
|
|
280
|
+
read = tryReadSession(id);
|
|
252
281
|
} catch (error) {
|
|
253
282
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw captureError('precondition', 'unknown_session', `No capture session found: ${id}`);
|
|
254
283
|
throw error;
|
|
255
284
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
parsed = JSON.parse(raw);
|
|
259
|
-
} catch (cause) {
|
|
260
|
-
throw captureError('artifact', 'invalid_session_record', `session metadata at ${metaPath} is not valid JSON; the record is corrupt and cannot be read as a capture session.`, cause);
|
|
261
|
-
}
|
|
262
|
-
if (!isActiveStateCandidate(parsed)) {
|
|
263
|
-
throw captureError('artifact', 'invalid_session_record', `session metadata at ${metaPath} does not match the capture session record schema; it was written by an incompatible version or corrupted.`);
|
|
264
|
-
}
|
|
265
|
-
return parsed;
|
|
285
|
+
if (!read.ok) throw captureError('artifact', 'invalid_session_record', `session metadata at ${metaPath} ${read.reason}.`);
|
|
286
|
+
return read.session;
|
|
266
287
|
}
|
|
267
288
|
|
|
268
289
|
function generateId(): string {
|
|
@@ -354,8 +375,11 @@ input:
|
|
|
354
375
|
(none)
|
|
355
376
|
|
|
356
377
|
output:
|
|
357
|
-
One <sessions count=…> block, one row per session: id, status
|
|
378
|
+
One <sessions count=… unreadable=…> block, one row per session: id, status
|
|
358
379
|
(active|stopped), start time, and URL when set. --json mirrors the same rows.
|
|
380
|
+
A record that is corrupt or written by an incompatible version lists as its
|
|
381
|
+
own "<id> — unreadable — <path> — <reason>" row and is excluded from count;
|
|
382
|
+
one bad record never hides the others.
|
|
359
383
|
|
|
360
384
|
effects:
|
|
361
385
|
None — reads session metadata only.`;
|
|
@@ -557,9 +581,9 @@ async function start(parsed: ParsedArgs): Promise<void> {
|
|
|
557
581
|
if (active && !isSessionStopped(active)) {
|
|
558
582
|
emitResult({
|
|
559
583
|
tag: 'error',
|
|
560
|
-
attrs: { command: 'session start', code: '
|
|
561
|
-
summary: fact`
|
|
562
|
-
followUp:
|
|
584
|
+
attrs: { command: 'session start', code: 'session_already_active' },
|
|
585
|
+
summary: fact`Session ${active.sessionId} is already live in this capture scope (${activeSessionScopeKey()}); one live session per scope.`,
|
|
586
|
+
followUp: fact`Scopes are per-agent (CRTR_NODE_ID), so this session is yours — a concurrent agent's session never blocks yours. Keep using it (every command auto-targets its tab), or run \`capture session stop ${active.sessionId}\` to start fresh. To drive a different tab without ending it, pass \`--target <target-id>\` from \`capture tab list\`.`,
|
|
563
587
|
}, { json: parsed.json });
|
|
564
588
|
process.exitCode = 1;
|
|
565
589
|
return;
|
|
@@ -1212,17 +1236,24 @@ function list(parsed: ParsedArgs): void {
|
|
|
1212
1236
|
|
|
1213
1237
|
if (rejectSurplusPositionals(parsed, 'list', 0, 'capture session list')) return;
|
|
1214
1238
|
|
|
1215
|
-
const
|
|
1239
|
+
const reads = fs.existsSync(CAPTURE_ROOT)
|
|
1216
1240
|
? fs.readdirSync(CAPTURE_ROOT)
|
|
1217
1241
|
.filter((d) => fs.existsSync(sessionMetaPath(d)))
|
|
1218
|
-
.
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
return { id: session.sessionId, startedAt: session.startedAt ?? '', url: session.url ?? null, status: hasBundled ? 'stopped' : 'active' };
|
|
1242
|
+
.flatMap((d) => {
|
|
1243
|
+
// A record deleted between readdir and read is simply gone, not unreadable.
|
|
1244
|
+
try { return [tryReadSession(d)]; } catch { return []; }
|
|
1222
1245
|
})
|
|
1223
1246
|
: [];
|
|
1224
1247
|
|
|
1225
|
-
|
|
1248
|
+
const sessions = reads.flatMap((read) => {
|
|
1249
|
+
if (!read.ok) return [];
|
|
1250
|
+
const session = read.session;
|
|
1251
|
+
const hasBundled = fs.existsSync(path.join(session.dir, 'bundle.json'));
|
|
1252
|
+
return [{ id: session.sessionId, startedAt: session.startedAt ?? '', url: session.url ?? null, status: hasBundled ? 'stopped' : 'active' }];
|
|
1253
|
+
});
|
|
1254
|
+
const unreadable = reads.flatMap((read) => (read.ok ? [] : [read]));
|
|
1255
|
+
|
|
1256
|
+
if (reads.length === 0) {
|
|
1226
1257
|
emitResult({
|
|
1227
1258
|
tag: 'sessions',
|
|
1228
1259
|
attrs: { count: 0 },
|
|
@@ -1237,11 +1268,17 @@ function list(parsed: ParsedArgs): void {
|
|
|
1237
1268
|
s.url ? fact` — ${s.url}` : text``,
|
|
1238
1269
|
),
|
|
1239
1270
|
);
|
|
1271
|
+
const unreadableRows = unreadable.map((u) =>
|
|
1272
|
+
line(fact`${u.id} — unreadable — ${u.metaPath} — ${u.reason}`),
|
|
1273
|
+
);
|
|
1240
1274
|
|
|
1241
1275
|
emitResult({
|
|
1242
1276
|
tag: 'sessions',
|
|
1243
|
-
attrs: { count: sessions.length },
|
|
1244
|
-
sections: [lineList(rows)],
|
|
1277
|
+
attrs: { count: sessions.length, unreadable: unreadable.length },
|
|
1278
|
+
sections: [lineList([...rows, ...unreadableRows])],
|
|
1279
|
+
...(unreadable.length > 0
|
|
1280
|
+
? { followUp: text`Unreadable records are corrupt or written by an incompatible version; they are inert. Delete the named .session.json (or its session dir) to drop the row.` }
|
|
1281
|
+
: {}),
|
|
1245
1282
|
}, { json: parsed.json });
|
|
1246
1283
|
}
|
|
1247
1284
|
|