@crouton-kit/capture 1.3.8 → 1.3.9

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.
Files changed (136) hide show
  1. package/bin/capture +29841 -8539
  2. package/package.json +6 -3
  3. package/src/capture.ts +86 -173
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -205
  6. package/src/cdp/bridge/protocol.ts +93 -6
  7. package/src/cdp/bridge/server.ts +118 -53
  8. package/src/cdp/bridge/spawn.ts +53 -6
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +20 -14
  11. package/src/cdp/commands/cdp.ts +259 -90
  12. package/src/cdp/commands/gate-guard.ts +43 -0
  13. package/src/cdp/commands/lib.ts +339 -213
  14. package/src/cdp/commands/measure/census.ts +76 -20
  15. package/src/cdp/commands/measure/check.ts +100 -26
  16. package/src/cdp/commands/measure/diff.ts +109 -21
  17. package/src/cdp/commands/measure/explain.ts +121 -19
  18. package/src/cdp/commands/measure/index.ts +31 -39
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +41 -13
  21. package/src/cdp/commands/measure/map-layers.ts +49 -15
  22. package/src/cdp/commands/measure/map-scroll.ts +85 -15
  23. package/src/cdp/commands/measure/snap.ts +405 -25
  24. package/src/cdp/commands/measure/sweep.ts +217 -20
  25. package/src/cdp/commands/motion/index.ts +18 -17
  26. package/src/cdp/commands/motion/jank.ts +119 -9
  27. package/src/cdp/commands/motion/mask.ts +81 -12
  28. package/src/cdp/commands/motion/rec.ts +520 -19
  29. package/src/cdp/commands/motion/response.ts +65 -15
  30. package/src/cdp/commands/motion/timeline.ts +139 -14
  31. package/src/cdp/commands/page/click.ts +194 -0
  32. package/src/cdp/commands/page/elements.ts +154 -0
  33. package/src/cdp/commands/page/exec.ts +317 -0
  34. package/src/cdp/commands/page/index.ts +72 -0
  35. package/src/cdp/commands/page/navigate.ts +330 -0
  36. package/src/cdp/commands/page/scroll.ts +107 -0
  37. package/src/cdp/commands/page/shot.ts +208 -0
  38. package/src/cdp/commands/page/type.ts +104 -0
  39. package/src/cdp/commands/tab/index.ts +59 -0
  40. package/src/cdp/commands/tab/list.ts +167 -0
  41. package/src/cdp/commands/tab/network.ts +162 -0
  42. package/src/cdp/commands/tab/open.ts +90 -0
  43. package/src/cdp/commands/tab/reset.ts +160 -0
  44. package/src/cdp/connection.ts +368 -37
  45. package/src/cdp/detect.ts +42 -13
  46. package/src/cdp/dispatch.ts +74 -28
  47. package/src/cdp/har-recorder.ts +648 -115
  48. package/src/cdp/leaf-grammar.ts +99 -0
  49. package/src/cdp/measure/census.ts +205 -0
  50. package/src/cdp/measure/check.ts +318 -0
  51. package/src/cdp/measure/collectors/animation.ts +385 -0
  52. package/src/cdp/measure/collectors/ax.ts +278 -0
  53. package/src/cdp/measure/collectors/focus.ts +1041 -0
  54. package/src/cdp/measure/collectors/forms.ts +905 -0
  55. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  56. package/src/cdp/measure/collectors/hittest.ts +835 -0
  57. package/src/cdp/measure/collectors/layers.ts +697 -0
  58. package/src/cdp/measure/collectors/media.ts +522 -0
  59. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  60. package/src/cdp/measure/collectors/queries.ts +464 -0
  61. package/src/cdp/measure/collectors/scroll.ts +599 -0
  62. package/src/cdp/measure/collectors/states.ts +992 -0
  63. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  64. package/src/cdp/measure/collectors/styles.ts +355 -0
  65. package/src/cdp/measure/collectors/text.ts +713 -0
  66. package/src/cdp/measure/diff.ts +470 -0
  67. package/src/cdp/measure/explain.ts +498 -0
  68. package/src/cdp/measure/map-ax.ts +633 -0
  69. package/src/cdp/measure/map-focus.ts +188 -0
  70. package/src/cdp/measure/map-layers.ts +177 -0
  71. package/src/cdp/measure/map-scroll.ts +317 -0
  72. package/src/cdp/measure/redaction.ts +51 -0
  73. package/src/cdp/measure/settle.ts +1051 -0
  74. package/src/cdp/measure/snapshot.ts +390 -0
  75. package/src/cdp/measure/sweep.ts +330 -0
  76. package/src/cdp/measure/types.ts +220 -0
  77. package/src/cdp/measure/v2/collectors.ts +19 -0
  78. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  79. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  80. package/src/cdp/measure/v2/target-lock.ts +15 -0
  81. package/src/cdp/measure/v2/target.ts +16 -0
  82. package/src/cdp/motion/jank.ts +433 -0
  83. package/src/cdp/motion/mask.ts +421 -0
  84. package/src/cdp/motion/recorder.ts +764 -0
  85. package/src/cdp/motion/response.ts +356 -0
  86. package/src/cdp/motion/timeline.ts +215 -0
  87. package/src/cdp/record.ts +5 -212
  88. package/src/cdp/recorder-bridge.ts +1478 -105
  89. package/src/cdp/recorder-client.ts +326 -0
  90. package/src/cdp/scope-lock.ts +73 -0
  91. package/src/cdp/screenshot.ts +199 -66
  92. package/src/cdp/targets.ts +21 -1
  93. package/src/cdp/timing.ts +15 -10
  94. package/src/cdp/types.ts +22 -7
  95. package/src/cdp/viewport.ts +21 -0
  96. package/src/cdp.ts +12 -58
  97. package/src/command-help.ts +29 -0
  98. package/src/command-resolution.ts +22 -0
  99. package/src/command-walker.ts +35 -0
  100. package/src/contracts/a11y.ts +33 -0
  101. package/src/contracts/cursor.ts +35 -0
  102. package/src/contracts/index.ts +15 -0
  103. package/src/contracts/motion.ts +19 -0
  104. package/src/contracts/neutral.ts +56 -0
  105. package/src/contracts/primitives.ts +205 -0
  106. package/src/contracts/release.ts +17 -0
  107. package/src/contracts/results.ts +208 -0
  108. package/src/contracts/routes.ts +140 -0
  109. package/src/contracts/snapshot.ts +92 -0
  110. package/src/cursor/index.ts +13 -0
  111. package/src/cursor/service.ts +310 -0
  112. package/src/errors.ts +71 -0
  113. package/src/har-manager.ts +992 -41
  114. package/src/interact.ts +410 -98
  115. package/src/output/artifact-lifecycle.ts +371 -0
  116. package/src/output/artifact.ts +6 -0
  117. package/src/output/exact-raw.ts +114 -0
  118. package/src/output/render.ts +396 -2
  119. package/src/output/structured.ts +211 -0
  120. package/src/registry.ts +208 -0
  121. package/src/session/artifacts.ts +452 -200
  122. package/src/session/commands.ts +1162 -397
  123. package/src/session/coordinator.ts +251 -0
  124. package/src/session/har-filter.ts +57 -0
  125. package/src/session/log-tailer.ts +653 -0
  126. package/src/session-context.ts +279 -51
  127. package/src/vault/bundle.ts +32 -17
  128. package/src/cdp/commands/exec.ts +0 -105
  129. package/src/cdp/commands/har.ts +0 -128
  130. package/src/cdp/commands/tabs.ts +0 -183
  131. package/src/cdp/commands/traffic.ts +0 -121
  132. package/src/cdp/commands/ui.ts +0 -172
  133. package/src/cdp/execute.ts +0 -113
  134. package/src/cdp/har-output.ts +0 -47
  135. package/src/cdp/usage.ts +0 -79
  136. package/src/tab-session.ts +0 -91
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Private hard-cut artifact ownership and publication substrate.
3
+ *
4
+ * This is deliberately not imported by legacy handlers. New acquisition and
5
+ * read leaves use it at cutover; legacy active-session resolution remains
6
+ * isolated in output/artifact.ts until then.
7
+ */
8
+ import * as crypto from 'node:crypto';
9
+ import * as fs from 'node:fs';
10
+ import * as path from 'node:path';
11
+ import { execFileSync } from 'node:child_process';
12
+
13
+ import { CAPTURE_ROOT, DIR_MODE, ensurePrivateDir, writeJsonPrivate } from '../session/artifacts.js';
14
+ import { SnapshotMetaV2, SourceArtifactManifest, validateSnapshotMetaV2 } from '../contracts/snapshot.js';
15
+
16
+ export const SNAPSHOT_ID_PATTERN = /^snap_[0-9a-z]{26}$/;
17
+ export const SESSION_ID_PATTERN = /^ses_[0-9a-z]{26}$/;
18
+ export const PUBLICATION_OPERATION_ID_PATTERN = /^pub_[0-9a-z]{26}$/;
19
+ export const MAX_ARTIFACT_LOCATOR_SOURCE_BYTES = 512;
20
+ export const MAX_ARTIFACT_LOCATOR_ENCODED_BYTES = 768;
21
+
22
+ /** The only artifact owner classes. A leaf cannot borrow another class's tree. */
23
+ export type ArtifactOwner = 'snapshot-source' | 'derived-read' | 'direct-read' | 'sweep-observation';
24
+ /** Exact U1 canonical measurement leaf suffixes; aliases cannot acquire an owner. */
25
+ export type MeasurementLeafPath = 'snap' | 'check' | 'diff' | 'census' | 'explain' | 'resolve' | 'sweep' | 'map scroll' | 'map layers' | 'map focus';
26
+ export const MEASUREMENT_ARTIFACT_OWNERS = {
27
+ snap: 'snapshot-source',
28
+ check: 'derived-read',
29
+ diff: 'derived-read',
30
+ census: 'derived-read',
31
+ explain: 'direct-read',
32
+ resolve: 'direct-read',
33
+ sweep: 'sweep-observation',
34
+ 'map scroll': 'derived-read',
35
+ 'map layers': 'derived-read',
36
+ 'map focus': 'derived-read',
37
+ } as const satisfies Record<MeasurementLeafPath, ArtifactOwner>;
38
+
39
+ export type SnapshotAssociation = 'one-shot' | { readonly sessionId: string };
40
+
41
+ export interface SnapshotIndexEntry {
42
+ readonly snapshotId: string;
43
+ readonly absoluteDirectory: string;
44
+ readonly association: SnapshotAssociation;
45
+ /** Repeats the final meta marker to prove this index published that tree. */
46
+ readonly publicationOperationId: string;
47
+ }
48
+
49
+ export interface SourceArtifactInventoryEntry {
50
+ readonly path: string;
51
+ readonly bytes: number;
52
+ readonly sha256: string;
53
+ }
54
+
55
+ /** A stable inventory for embedding in the immutable `meta.json` source manifest. */
56
+ export interface SourceArtifactInventory {
57
+ readonly schemaVersion: 1;
58
+ readonly artifacts: readonly SourceArtifactInventoryEntry[];
59
+ }
60
+
61
+ export interface PublicationMarker {
62
+ readonly state: 'unpublished-final';
63
+ readonly snapshotId: string;
64
+ readonly publicationOperationId: string;
65
+ readonly ownerPid: number;
66
+ readonly ownerProcessStartIdentity: string | null;
67
+ readonly finalDirectory: string;
68
+ }
69
+
70
+ export interface SnapshotStaging {
71
+ readonly snapshotId: string;
72
+ readonly publicationOperationId: string;
73
+ readonly directory: string;
74
+ readonly finalDirectory: string;
75
+ }
76
+
77
+ export class ArtifactLifecycleError extends Error {
78
+ constructor(readonly code: 'artifact_path_too_long' | 'snapshot_not_found' | 'snapshot_publication_owner_live' | 'snapshot_index_recovery_failed' | 'snapshot_publication_invalid' | 'artifact_lifecycle_lock_unavailable', message: string) {
79
+ super(message);
80
+ this.name = 'ArtifactLifecycleError';
81
+ }
82
+ }
83
+
84
+ function rootFor(root = CAPTURE_ROOT): string {
85
+ const resolved = path.resolve(root);
86
+ const captureRoot = path.resolve(CAPTURE_ROOT);
87
+ if (resolved === captureRoot) {
88
+ try {
89
+ const stat = fs.lstatSync(resolved);
90
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`capture root is not a private directory: ${resolved}`);
91
+ } catch (err) {
92
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
93
+ fs.mkdirSync(resolved, { recursive: true, mode: DIR_MODE });
94
+ }
95
+ fs.chmodSync(resolved, DIR_MODE);
96
+ return resolved;
97
+ }
98
+ return ensurePrivateDir(resolved);
99
+ }
100
+ function readRootFor(root = CAPTURE_ROOT): string {
101
+ const resolved = path.resolve(root);
102
+ const captureRoot = path.resolve(CAPTURE_ROOT);
103
+ if (resolved !== captureRoot && !isStrictlyUnder(captureRoot, resolved)) {
104
+ throw new ArtifactLifecycleError('snapshot_not_found', `artifact root escapes Capture root: ${resolved}`);
105
+ }
106
+ return resolved;
107
+ }
108
+ function snapshotsDir(root: string): string { return path.join(root, 'snapshots'); }
109
+ function stagingDir(root: string): string { return path.join(root, 'snapshot-staging'); }
110
+ function allocationsDir(root: string): string { return path.join(root, 'snapshot-allocations'); }
111
+ function indexDir(root: string): string { return path.join(root, 'snapshot-index'); }
112
+ function quarantineDir(root: string): string { return path.join(root, 'snapshot-recovery-quarantine'); }
113
+ function indexPath(root: string, snapshotId: string): string { return path.join(indexDir(root), `${snapshotId}.json`); }
114
+
115
+ function bytes(value: string): number { return Buffer.byteLength(value, 'utf8'); }
116
+ function randomId(prefix: 'snap' | 'pub'): string {
117
+ // base36 digits only; rejection sampling avoids an accidental shorter ID.
118
+ let out = '';
119
+ while (out.length < 26) out += BigInt(`0x${crypto.randomBytes(16).toString('hex')}`).toString(36);
120
+ return `${prefix}_${out.slice(0, 26)}`;
121
+ }
122
+ function requireSnapshotId(id: string): void {
123
+ if (!SNAPSHOT_ID_PATTERN.test(id)) throw new ArtifactLifecycleError('snapshot_not_found', `invalid snapshot id ${JSON.stringify(id)}`);
124
+ }
125
+ function requireOperationId(id: string): void {
126
+ if (!PUBLICATION_OPERATION_ID_PATTERN.test(id)) throw new ArtifactLifecycleError('snapshot_publication_invalid', `invalid publication operation id ${JSON.stringify(id)}`);
127
+ }
128
+ function isSnapshotAssociation(value: unknown): value is SnapshotAssociation {
129
+ return value === 'one-shot' || (typeof value === 'object' && value !== null && Object.keys(value).length === 1 && typeof (value as { sessionId?: unknown }).sessionId === 'string' && SESSION_ID_PATTERN.test((value as { sessionId: string }).sessionId));
130
+ }
131
+ function isStrictlyUnder(root: string, candidate: string): boolean {
132
+ const relative = path.relative(root, candidate);
133
+ return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative);
134
+ }
135
+
136
+ /**
137
+ * Validates a locator without shortening it. Prose and structured JSON both
138
+ * carry the canonical JSON string literal, so JSON.stringify is the final
139
+ * representation whose escaping must fit the encoded bound.
140
+ */
141
+ export function validateArtifactLocator(locator: string): string {
142
+ if (!path.isAbsolute(locator) || path.resolve(locator) !== locator) {
143
+ throw new ArtifactLifecycleError('artifact_path_too_long', `artifact locator must be normalized absolute path: ${JSON.stringify(locator)}`);
144
+ }
145
+ const sourceBytes = bytes(locator);
146
+ const jsonBytes = bytes(JSON.stringify(locator));
147
+ // The prose encoder's XML text context expands these characters; prove both final encodings.
148
+ const proseBytes = bytes(JSON.stringify(locator).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'));
149
+ const encodedBytes = Math.max(jsonBytes, proseBytes);
150
+ if (sourceBytes > MAX_ARTIFACT_LOCATOR_SOURCE_BYTES || encodedBytes > MAX_ARTIFACT_LOCATOR_ENCODED_BYTES) {
151
+ throw new ArtifactLifecycleError('artifact_path_too_long', `artifact locator exceeds limits (source=${sourceBytes}/${MAX_ARTIFACT_LOCATOR_SOURCE_BYTES}, encoded=${encodedBytes}/${MAX_ARTIFACT_LOCATOR_ENCODED_BYTES}); use a shorter Capture root or snapshot path`);
152
+ }
153
+ return locator;
154
+ }
155
+
156
+ /** Inventory excludes `meta.json`: callers embed this value into that file. */
157
+ export function buildSourceArtifactInventory(directory: string): SourceArtifactInventory {
158
+ const root = path.resolve(directory);
159
+ validateArtifactLocator(root);
160
+ const artifacts: SourceArtifactInventoryEntry[] = [];
161
+ const walk = (dir: string): void => {
162
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
163
+ const full = path.join(dir, entry.name);
164
+ if (entry.isDirectory()) walk(full);
165
+ else if (entry.isFile() && path.relative(root, full) !== 'meta.json') {
166
+ const relative = path.relative(root, full).split(path.sep).join('/');
167
+ artifacts.push({ path: relative, bytes: fs.statSync(full).size, sha256: crypto.createHash('sha256').update(fs.readFileSync(full)).digest('hex') });
168
+ } else if (entry.isSymbolicLink()) {
169
+ throw new ArtifactLifecycleError('snapshot_publication_invalid', `source artifact inventory refuses symlink ${full}`);
170
+ }
171
+ }
172
+ };
173
+ walk(root);
174
+ return { schemaVersion: 1, artifacts };
175
+ }
176
+
177
+ /** Current process identity used by split recovery; null means it cannot be attested live. */
178
+ export function currentProcessStartIdentity(pid = process.pid): string | null {
179
+ try {
180
+ // Portable enough for supported unix hosts; exact PID-start pairing prevents PID reuse from looking live.
181
+ return execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
182
+ } catch { return null; }
183
+ }
184
+ function markerIsLive(marker: PublicationMarker): boolean {
185
+ if (!marker.ownerProcessStartIdentity || marker.ownerPid <= 0) return false;
186
+ return currentProcessStartIdentity(marker.ownerPid) === marker.ownerProcessStartIdentity;
187
+ }
188
+
189
+ /** An owner-attested lock: dead holders are reclaimed by PID/start identity, never elapsed time. */
190
+ export function withSnapshotIndexLock<T>(fn: () => T, artifactRoot = CAPTURE_ROOT): T {
191
+ const root = rootFor(artifactRoot);
192
+ const lock = path.join(root, '.snapshot-index.lock');
193
+ for (;;) {
194
+ try {
195
+ fs.mkdirSync(lock, { mode: DIR_MODE });
196
+ writeJsonPrivate(path.join(lock, 'owner.json'), { pid: process.pid, processStartIdentity: currentProcessStartIdentity() });
197
+ break;
198
+ } catch (err) {
199
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err;
200
+ let owner: { pid?: unknown; processStartIdentity?: unknown } | null = null;
201
+ try { owner = readJson(path.join(lock, 'owner.json')) as { pid?: unknown; processStartIdentity?: unknown }; } catch { /* interrupted lock creation is reclaimable */ }
202
+ const live = typeof owner?.pid === 'number' && typeof owner.processStartIdentity === 'string' && currentProcessStartIdentity(owner.pid) === owner.processStartIdentity;
203
+ if (live) throw new ArtifactLifecycleError('artifact_lifecycle_lock_unavailable', 'snapshot index lock is held by a live operation');
204
+ fs.rmSync(lock, { recursive: true, force: true });
205
+ }
206
+ }
207
+ try { return fn(); }
208
+ finally { fs.rmSync(lock, { recursive: true, force: true }); }
209
+ }
210
+
211
+ function readJson(file: string): unknown { return JSON.parse(fs.readFileSync(file, 'utf8')); }
212
+ function parseMarker(value: unknown): PublicationMarker | null {
213
+ if (!value || typeof value !== 'object') return null;
214
+ const publication = (value as { publication?: unknown }).publication;
215
+ if (!publication || typeof publication !== 'object') return null;
216
+ const p = publication as Partial<PublicationMarker>;
217
+ if (p.state !== 'unpublished-final' || typeof p.snapshotId !== 'string' || !SNAPSHOT_ID_PATTERN.test(p.snapshotId) || typeof p.publicationOperationId !== 'string' || !PUBLICATION_OPERATION_ID_PATTERN.test(p.publicationOperationId) || !Number.isSafeInteger(p.ownerPid) || p.ownerPid <= 0 || (p.ownerProcessStartIdentity !== null && typeof p.ownerProcessStartIdentity !== 'string') || typeof p.finalDirectory !== 'string' || !path.isAbsolute(p.finalDirectory) || path.resolve(p.finalDirectory) !== p.finalDirectory) return null;
218
+ return p as PublicationMarker;
219
+ }
220
+
221
+ /** Removes/quarantines only unindexed finalized trees whose owner is provably not live. */
222
+ function recoverUnpublishedSnapshotsUnderLock(artifactRoot = CAPTURE_ROOT): void {
223
+ const root = rootFor(artifactRoot);
224
+ ensurePrivateDir(snapshotsDir(root));
225
+ for (const entry of fs.readdirSync(snapshotsDir(root), { withFileTypes: true })) {
226
+ if (!entry.isDirectory() || !SNAPSHOT_ID_PATTERN.test(entry.name)) continue;
227
+ const finalDirectory = path.join(snapshotsDir(root), entry.name);
228
+ const metaPath = path.join(finalDirectory, 'meta.json');
229
+ let marker: PublicationMarker | null = null;
230
+ try { marker = parseMarker(readJson(metaPath)); } catch { continue; }
231
+ if (!marker || marker.snapshotId !== entry.name || marker.finalDirectory !== finalDirectory || fs.existsSync(indexPath(root, entry.name))) continue;
232
+ if (markerIsLive(marker)) throw new ArtifactLifecycleError('snapshot_publication_owner_live', `unpublished snapshot ${entry.name} is owned by live process ${marker.ownerPid}`);
233
+ try {
234
+ fs.rmSync(finalDirectory, { recursive: true, force: true });
235
+ if (!fs.existsSync(finalDirectory)) continue;
236
+ } catch { /* quarantine below */ }
237
+ const quarantine = path.join(quarantineDir(root), `${entry.name}-${marker.publicationOperationId}`);
238
+ try {
239
+ ensurePrivateDir(quarantineDir(root));
240
+ fs.renameSync(finalDirectory, quarantine);
241
+ if (!fs.existsSync(finalDirectory) && fs.existsSync(quarantine)) continue;
242
+ } catch { /* stable failure below */ }
243
+ throw new ArtifactLifecycleError('snapshot_index_recovery_failed', `could not delete or quarantine unpublished snapshot ${finalDirectory} for ${marker.publicationOperationId}`);
244
+ }
245
+ }
246
+
247
+ /** Runs split recovery while holding the global publication lock. */
248
+ export function recoverUnpublishedSnapshots(artifactRoot = CAPTURE_ROOT): void {
249
+ withSnapshotIndexLock(() => recoverUnpublishedSnapshotsUnderLock(artifactRoot), artifactRoot);
250
+ }
251
+
252
+ /** Allocates a globally unique snapshot ID and private staging tree. */
253
+ export function allocateSnapshotStaging(artifactRoot = CAPTURE_ROOT): SnapshotStaging {
254
+ // Validate the worst-case public locator before allocating any durable state.
255
+ const readRoot = readRootFor(artifactRoot);
256
+ validateArtifactLocator(path.join(snapshotsDir(readRoot), `snap_${'z'.repeat(26)}`));
257
+ return withSnapshotIndexLock(() => {
258
+ const root = rootFor(artifactRoot);
259
+ recoverUnpublishedSnapshotsUnderLock(root);
260
+ ensurePrivateDir(allocationsDir(root));
261
+ ensurePrivateDir(stagingDir(root));
262
+ for (;;) {
263
+ const snapshotId = randomId('snap');
264
+ try { fs.mkdirSync(path.join(allocationsDir(root), snapshotId), { mode: DIR_MODE }); }
265
+ catch (err) { if ((err as NodeJS.ErrnoException).code === 'EEXIST') continue; throw err; }
266
+ const publicationOperationId = randomId('pub');
267
+ const directory = ensurePrivateDir(path.join(stagingDir(root), `${snapshotId}-${publicationOperationId}`));
268
+ const finalDirectory = path.join(snapshotsDir(root), snapshotId);
269
+ validateArtifactLocator(finalDirectory);
270
+ return { snapshotId, publicationOperationId, directory, finalDirectory };
271
+ }
272
+ }, artifactRoot);
273
+ }
274
+
275
+ /** Removes only an allocation-owned staging tree. The durable allocation tombstone remains reserved. */
276
+ export function cleanupSnapshotStaging(staging: SnapshotStaging, artifactRoot = CAPTURE_ROOT): void {
277
+ const root = rootFor(artifactRoot);
278
+ requireSnapshotId(staging.snapshotId); requireOperationId(staging.publicationOperationId);
279
+ const expected = path.join(stagingDir(root), `${staging.snapshotId}-${staging.publicationOperationId}`);
280
+ if (path.resolve(staging.directory) !== expected || staging.finalDirectory !== path.join(snapshotsDir(root), staging.snapshotId)) throw new ArtifactLifecycleError('snapshot_publication_invalid', 'staging cleanup refused an unowned directory');
281
+ fs.rmSync(staging.directory, { recursive: true, force: true });
282
+ if (fs.existsSync(staging.directory)) throw new ArtifactLifecycleError('snapshot_publication_invalid', `could not remove staging directory ${staging.directory}`);
283
+ }
284
+
285
+ /** Direct reads expose one root plus trusted source keys, never plural or truncated paths. */
286
+ export interface DirectSourceAccess { readonly root: string; readonly manifest: 'meta.json'; readonly keys: readonly string[]; }
287
+ export function validateDirectSourceAccess(value: unknown): DirectSourceAccess {
288
+ if (!value || typeof value !== 'object') throw new ArtifactLifecycleError('snapshot_publication_invalid', 'direct source access must be an object');
289
+ const v = value as Partial<DirectSourceAccess>;
290
+ if (typeof v.root !== 'string' || validateArtifactLocator(v.root) !== v.root || v.manifest !== 'meta.json' || !Array.isArray(v.keys) || v.keys.length < 1 || v.keys.length > 16 || v.keys.some(key => typeof key !== 'string' || !key || Buffer.byteLength(key, 'utf8') > 64) || new Set(v.keys).size !== v.keys.length) throw new ArtifactLifecycleError('snapshot_publication_invalid', 'direct source access is invalid');
291
+ return { root: v.root, manifest: 'meta.json', keys: [...v.keys] };
292
+ }
293
+
294
+ /** Writes final v2 meta only after collectors have completed; publication remains impossible until the index rename. */
295
+ export function finalizeSnapshotManifest(staging: SnapshotStaging, manifest: Omit<SnapshotMetaV2, 'snapshotId'>): void {
296
+ const finalDirectory = validateArtifactLocator(staging.finalDirectory);
297
+ const metaPath = path.join(staging.directory, 'meta.json');
298
+ if (fs.existsSync(metaPath)) throw new ArtifactLifecycleError('snapshot_publication_invalid', 'snapshot staging manifest is already finalized');
299
+ if (Object.prototype.hasOwnProperty.call(manifest, 'publication') || Object.prototype.hasOwnProperty.call(manifest, 'sourceArtifactInventory')) throw new ArtifactLifecycleError('snapshot_publication_invalid', 'collectors cannot supply lifecycle fields');
300
+ const inventory = buildSourceArtifactInventory(staging.directory);
301
+ const source = manifest.source_artifact_manifest as SourceArtifactManifest;
302
+ const inventoryPaths = inventory.artifacts.map(item => item.path).join('\0');
303
+ const declaredPaths = source?.artifacts?.map(item => item.path).join('\0');
304
+ const candidate = { ...manifest, snapshotId: staging.snapshotId } as SnapshotMetaV2;
305
+ const validation = validateSnapshotMetaV2(candidate);
306
+ if (!validation.valid || inventoryPaths !== declaredPaths || source.artifacts.some((item, index) => item.bytes !== inventory.artifacts[index]?.bytes || item.sha256 !== inventory.artifacts[index]?.sha256)) throw new ArtifactLifecycleError('snapshot_publication_invalid', `invalid v2 snapshot manifest: ${validation.errors.join('; ') || 'source manifest does not match finalized files'}`);
307
+ const publication: PublicationMarker = { state: 'unpublished-final', snapshotId: staging.snapshotId, publicationOperationId: staging.publicationOperationId, ownerPid: process.pid, ownerProcessStartIdentity: currentProcessStartIdentity(), finalDirectory };
308
+ writeJsonPrivate(metaPath, { ...candidate, sourceArtifactInventory: inventory, publication });
309
+ }
310
+
311
+ /** Two-rename publication: final tree first, global ID index second (the linearization point). */
312
+ export function publishSnapshot(staging: SnapshotStaging, association: SnapshotAssociation, artifactRoot = CAPTURE_ROOT): SnapshotIndexEntry {
313
+ return withSnapshotIndexLock(() => {
314
+ const root = rootFor(artifactRoot);
315
+ recoverUnpublishedSnapshotsUnderLock(root);
316
+ requireSnapshotId(staging.snapshotId); requireOperationId(staging.publicationOperationId);
317
+ if (!isSnapshotAssociation(association)) throw new ArtifactLifecycleError('snapshot_publication_invalid', 'snapshot association must be one-shot or one exact session ID');
318
+ if (path.resolve(staging.directory) !== staging.directory || !isStrictlyUnder(stagingDir(root), staging.directory)) throw new ArtifactLifecycleError('snapshot_publication_invalid', 'staging directory is outside the snapshot staging root');
319
+ if (staging.finalDirectory !== path.join(snapshotsDir(root), staging.snapshotId)) throw new ArtifactLifecycleError('snapshot_publication_invalid', 'final directory does not match snapshot identity');
320
+ let meta: Record<string, unknown>;
321
+ try { meta = readJson(path.join(staging.directory, 'meta.json')) as Record<string, unknown>; }
322
+ catch { throw new ArtifactLifecycleError('snapshot_publication_invalid', 'staging meta is missing or invalid'); }
323
+ const marker = parseMarker(meta);
324
+ if (!marker || marker.snapshotId !== staging.snapshotId || marker.publicationOperationId !== staging.publicationOperationId || marker.finalDirectory !== staging.finalDirectory) throw new ArtifactLifecycleError('snapshot_publication_invalid', 'staging meta does not carry the matching unpublished-final marker');
325
+ const recordedInventory = meta.sourceArtifactInventory;
326
+ const actualInventory = buildSourceArtifactInventory(staging.directory);
327
+ if (JSON.stringify(recordedInventory) !== JSON.stringify(actualInventory)) throw new ArtifactLifecycleError('snapshot_publication_invalid', 'source artifacts changed after manifest finalization');
328
+ ensurePrivateDir(snapshotsDir(root));
329
+ if (fs.existsSync(staging.finalDirectory) || fs.existsSync(indexPath(root, staging.snapshotId))) throw new ArtifactLifecycleError('snapshot_publication_invalid', `snapshot identity ${staging.snapshotId} is already published or finalized`);
330
+ fs.renameSync(staging.directory, staging.finalDirectory);
331
+ const index: SnapshotIndexEntry = { snapshotId: staging.snapshotId, absoluteDirectory: validateArtifactLocator(staging.finalDirectory), association, publicationOperationId: staging.publicationOperationId };
332
+ writeJsonPrivate(indexPath(root, staging.snapshotId), index);
333
+ return index;
334
+ }, artifactRoot);
335
+ }
336
+
337
+ /** Resolves only an exact globally-indexed ID or a normalized absolute root. Never reads active session state. */
338
+ export function resolveSnapshotRoot(ref: string, artifactRoot = CAPTURE_ROOT): SnapshotIndexEntry {
339
+ // Read resolution deliberately creates no root, lock, recovery, or session state.
340
+ const root = readRootFor(artifactRoot);
341
+ if (path.isAbsolute(ref)) {
342
+ const absoluteDirectory = validateArtifactLocator(ref);
343
+ let marker: PublicationMarker | null;
344
+ try { marker = parseMarker(readJson(path.join(absoluteDirectory, 'meta.json'))); }
345
+ catch { throw new ArtifactLifecycleError('snapshot_not_found', `absolute snapshot directory is unavailable: ${absoluteDirectory}`); }
346
+ if (!marker) throw new ArtifactLifecycleError('snapshot_not_found', `absolute snapshot directory has no immutable published meta: ${absoluteDirectory}`);
347
+ const entry = readSnapshotIndex(root, marker.snapshotId);
348
+ if (entry.absoluteDirectory !== absoluteDirectory) throw new ArtifactLifecycleError('snapshot_not_found', `absolute snapshot directory is not the indexed root for ${marker.snapshotId}`);
349
+ return entry;
350
+ }
351
+ requireSnapshotId(ref);
352
+ return readSnapshotIndex(root, ref);
353
+ }
354
+ function readSnapshotIndex(root: string, snapshotId: string): SnapshotIndexEntry {
355
+ let raw: unknown;
356
+ try { raw = readJson(indexPath(root, snapshotId)); }
357
+ catch { throw new ArtifactLifecycleError('snapshot_not_found', `snapshot ${snapshotId} is not globally published`); }
358
+ if (!raw || typeof raw !== 'object') throw new ArtifactLifecycleError('snapshot_not_found', `snapshot ${snapshotId} is not globally published`);
359
+ const entry = raw as Partial<SnapshotIndexEntry>;
360
+ const associationValid = isSnapshotAssociation(entry.association);
361
+ if (entry.snapshotId !== snapshotId || typeof entry.absoluteDirectory !== 'string' || entry.absoluteDirectory !== path.join(snapshotsDir(root), snapshotId) || !associationValid || typeof entry.publicationOperationId !== 'string' || !PUBLICATION_OPERATION_ID_PATTERN.test(entry.publicationOperationId)) throw new ArtifactLifecycleError('snapshot_not_found', `snapshot index entry for ${snapshotId} is invalid`);
362
+ try {
363
+ validateArtifactLocator(entry.absoluteDirectory);
364
+ const marker = parseMarker(readJson(path.join(entry.absoluteDirectory, 'meta.json')));
365
+ if (!marker || marker.snapshotId !== snapshotId || marker.publicationOperationId !== entry.publicationOperationId || marker.finalDirectory !== entry.absoluteDirectory) throw new Error('published marker mismatch');
366
+ } catch (error) {
367
+ if (error instanceof ArtifactLifecycleError && error.code === 'artifact_path_too_long') throw error;
368
+ throw new ArtifactLifecycleError('snapshot_not_found', `published snapshot root is unavailable or does not match its index: ${snapshotId}`);
369
+ }
370
+ return entry as SnapshotIndexEntry;
371
+ }
@@ -235,6 +235,7 @@ const CREATING_COMMAND_BY_FILE: Record<string, string> = {
235
235
  'forms.json': 'capture measure snap',
236
236
  'animation.json': 'capture measure snap',
237
237
  'ax.json': 'capture measure snap',
238
+ 'media.json': 'capture measure snap',
238
239
  'queries.json': 'capture measure snap',
239
240
  'focus.json': 'capture measure snap',
240
241
  'scroll.json': 'capture measure snap',
@@ -412,6 +413,11 @@ export function readPixels<T = unknown>(ref: SnapRef): T {
412
413
  return readJsonArtifactFile<T>(ref, 'pixels.json');
413
414
  }
414
415
 
416
+ export function readMedia<T = unknown>(ref: SnapRef): T {
417
+ assertKind(ref, 'snap', 'readMedia');
418
+ return readJsonArtifactFile<T>(ref, 'media.json');
419
+ }
420
+
415
421
  export function readRects<T = unknown>(ref: RecRef): T[] {
416
422
  assertKind(ref, 'rec', 'readRects');
417
423
  return readNdjsonArtifactFile<T>(ref, 'rects.jsonl');
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Leaf-owned exact-raw output foundation. It deliberately has no structured
3
+ * fallback: successful payloads are written exactly as their handler supplied
4
+ * them, and global --json is rejected before the handler can perform effects.
5
+ */
6
+ import { OK, type ValidationResult, fail } from '../contracts/primitives.js';
7
+
8
+ export type ExactRawPayload = string | Uint8Array;
9
+
10
+ /** The only two public leaf output modes at the hard cut. */
11
+ export type LeafOutputMode = 'structured-json-capable' | 'exact-raw-json-rejected';
12
+
13
+ export interface StructuredOutputOwner {
14
+ readonly mode: 'structured-json-capable';
15
+ readonly canonicalPath: string;
16
+ }
17
+
18
+ export interface ExactRawOutputOwner {
19
+ readonly mode: 'exact-raw-json-rejected';
20
+ readonly canonicalPath: string;
21
+ readonly payloadType: string;
22
+ readonly size: 'unbounded';
23
+ }
24
+
25
+ export type LeafOutputOwner = StructuredOutputOwner | ExactRawOutputOwner;
26
+
27
+ /** The fixed exhaustive public output-mode matrix. */
28
+ export const PUBLIC_OUTPUT_OWNERS: readonly LeafOutputOwner[] = [
29
+ ...['session start', 'session stop', 'session list', 'session view'].map((canonicalPath) => ({ mode: 'structured-json-capable' as const, canonicalPath })),
30
+ ...['page screenshot', 'page click', 'page type', 'page navigate'].map((canonicalPath) => ({ mode: 'structured-json-capable' as const, canonicalPath })),
31
+ ...['measure snap', 'measure check', 'measure geometry', 'measure map focus', 'measure map scroll', 'measure map layers', 'measure explain', 'measure variation diff', 'measure variation census', 'measure variation sweep'].map((canonicalPath) => ({ mode: 'structured-json-capable' as const, canonicalPath })),
32
+ ...['motion rec', 'motion mask', 'motion timeline', 'motion jank', 'motion response'].map((canonicalPath) => ({ mode: 'structured-json-capable' as const, canonicalPath })),
33
+ ...['traffic record', 'traffic har create', 'traffic har delete'].map((canonicalPath) => ({ mode: 'structured-json-capable' as const, canonicalPath })),
34
+ ...['browser detect', 'browser list', 'browser open', 'browser reset', 'browser network'].map((canonicalPath) => ({ mode: 'structured-json-capable' as const, canonicalPath })),
35
+ ...['library list', 'library search', 'library show'].map((canonicalPath) => ({ mode: 'structured-json-capable' as const, canonicalPath })),
36
+ { mode: 'exact-raw-json-rejected', canonicalPath: 'session log', payloadType: 'recorded session log bytes', size: 'unbounded' },
37
+ { mode: 'exact-raw-json-rejected', canonicalPath: 'page a11y', payloadType: 'full accessibility-tree text', size: 'unbounded' },
38
+ { mode: 'exact-raw-json-rejected', canonicalPath: 'page exec', payloadType: 'handler evaluation bytes/text', size: 'unbounded' },
39
+ { mode: 'exact-raw-json-rejected', canonicalPath: 'traffic har read', payloadType: 'stored HAR bytes', size: 'unbounded' },
40
+ { mode: 'exact-raw-json-rejected', canonicalPath: 'browser cdp', payloadType: 'raw protocol response bytes', size: 'unbounded' },
41
+ { mode: 'exact-raw-json-rejected', canonicalPath: 'library read', payloadType: 'bundled function source bytes', size: 'unbounded' },
42
+ ];
43
+
44
+ /** Validate a registry's exhaustive output-mode partition before route effects are bound. */
45
+ export function validateLeafOutputOwners(owners: readonly LeafOutputOwner[]): ValidationResult {
46
+ const expected = new Map(PUBLIC_OUTPUT_OWNERS.map((owner) => [owner.canonicalPath, owner]));
47
+ const paths = new Set<string>();
48
+ const errors: string[] = [];
49
+ for (const owner of owners) {
50
+ if (!owner.canonicalPath) errors.push('output owner has empty canonical path');
51
+ if (paths.has(owner.canonicalPath)) errors.push(`duplicate output owner path: ${owner.canonicalPath}`);
52
+ paths.add(owner.canonicalPath);
53
+ const required = expected.get(owner.canonicalPath);
54
+ if (!required) { errors.push(`unexpected output owner path: ${owner.canonicalPath}`); continue; }
55
+ if (owner.mode !== required.mode) { errors.push(`wrong output mode for ${owner.canonicalPath}`); continue; }
56
+ if (owner.mode === 'exact-raw-json-rejected' && (owner.payloadType !== required.payloadType || owner.size !== 'unbounded')) {
57
+ errors.push(`wrong exact raw declaration for ${owner.canonicalPath}`);
58
+ }
59
+ }
60
+ for (const path of expected.keys()) if (!paths.has(path)) errors.push(`missing output owner path: ${path}`);
61
+ return errors.length === 0 ? OK : fail(...errors);
62
+ }
63
+
64
+ export interface ExactRawJsonRejection {
65
+ readonly code: 'output_mode_unsupported';
66
+ readonly field: '--json';
67
+ readonly expected: 'omit --json for exact raw output';
68
+ readonly next_action: string;
69
+ }
70
+
71
+ export interface ExactRawLeaf {
72
+ readonly canonicalPath: string;
73
+ readonly argv: readonly string[];
74
+ /** The leaf handler; callers must put all effects inside this callback. */
75
+ readonly produce: () => ExactRawPayload | Promise<ExactRawPayload>;
76
+ }
77
+
78
+ export type ExactRawRunResult =
79
+ | { readonly ok: true }
80
+ | { readonly ok: false; readonly error: ExactRawJsonRejection };
81
+
82
+ export function exactRawJsonRejection(canonicalPath: string): ExactRawJsonRejection {
83
+ if (!canonicalPath) throw new Error('canonical exact-raw path must not be empty');
84
+ return {
85
+ code: 'output_mode_unsupported',
86
+ field: '--json',
87
+ expected: 'omit --json for exact raw output',
88
+ next_action: `run ${canonicalPath} -h`,
89
+ };
90
+ }
91
+
92
+ /** True only for the global JSON switch, never for payload text that resembles it. */
93
+ export function rejectsGlobalJson(argv: readonly string[]): boolean {
94
+ for (const token of argv) {
95
+ if (token === '--') return false;
96
+ if (token === '--json') return true;
97
+ }
98
+ return false;
99
+ }
100
+
101
+ /** Writes a supplied payload with no newline insertion or any transformation. */
102
+ export function emitExactRaw(payload: ExactRawPayload, write: (chunk: ExactRawPayload) => void = process.stdout.write.bind(process.stdout)): void {
103
+ write(payload);
104
+ }
105
+
106
+ /**
107
+ * Execute an exact-raw leaf. `produce` is intentionally unreachable whenever
108
+ * --json is present, giving route adapters a pre-effect validation seam.
109
+ */
110
+ export async function runExactRaw(leaf: ExactRawLeaf, write: (chunk: ExactRawPayload) => void = process.stdout.write.bind(process.stdout)): Promise<ExactRawRunResult> {
111
+ if (rejectsGlobalJson(leaf.argv)) return { ok: false, error: exactRawJsonRejection(leaf.canonicalPath) };
112
+ emitExactRaw(await leaf.produce(), write);
113
+ return { ok: true };
114
+ }