@crouton-kit/capture 1.3.7 → 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 (141) hide show
  1. package/bin/capture +32786 -9989
  2. package/package.json +9 -3
  3. package/src/capture.ts +86 -163
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -116
  6. package/src/cdp/bridge/protocol.ts +187 -1
  7. package/src/cdp/bridge/server.ts +211 -84
  8. package/src/cdp/bridge/spawn.ts +110 -1
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +35 -4
  11. package/src/cdp/commands/cdp.ts +266 -69
  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 +87 -0
  15. package/src/cdp/commands/measure/check.ts +109 -0
  16. package/src/cdp/commands/measure/diff.ts +120 -0
  17. package/src/cdp/commands/measure/explain.ts +133 -0
  18. package/src/cdp/commands/measure/index.ts +106 -0
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +52 -0
  21. package/src/cdp/commands/measure/map-layers.ts +59 -0
  22. package/src/cdp/commands/measure/map-scroll.ts +94 -0
  23. package/src/cdp/commands/measure/snap.ts +416 -0
  24. package/src/cdp/commands/measure/sweep.ts +227 -0
  25. package/src/cdp/commands/motion/index.ts +61 -0
  26. package/src/cdp/commands/motion/jank.ts +132 -0
  27. package/src/cdp/commands/motion/mask.ts +92 -0
  28. package/src/cdp/commands/motion/rec.ts +534 -0
  29. package/src/cdp/commands/motion/response.ts +76 -0
  30. package/src/cdp/commands/motion/timeline.ts +152 -0
  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/coordinates.ts +158 -0
  46. package/src/cdp/detect.ts +42 -13
  47. package/src/cdp/dispatch.ts +76 -26
  48. package/src/cdp/domains.ts +75 -0
  49. package/src/cdp/har-recorder.ts +648 -115
  50. package/src/cdp/leaf-grammar.ts +99 -0
  51. package/src/cdp/measure/census.ts +205 -0
  52. package/src/cdp/measure/check.ts +318 -0
  53. package/src/cdp/measure/collectors/animation.ts +385 -0
  54. package/src/cdp/measure/collectors/ax.ts +278 -0
  55. package/src/cdp/measure/collectors/focus.ts +1041 -0
  56. package/src/cdp/measure/collectors/forms.ts +905 -0
  57. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  58. package/src/cdp/measure/collectors/hittest.ts +835 -0
  59. package/src/cdp/measure/collectors/layers.ts +697 -0
  60. package/src/cdp/measure/collectors/media.ts +522 -0
  61. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  62. package/src/cdp/measure/collectors/queries.ts +464 -0
  63. package/src/cdp/measure/collectors/scroll.ts +599 -0
  64. package/src/cdp/measure/collectors/states.ts +992 -0
  65. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  66. package/src/cdp/measure/collectors/styles.ts +355 -0
  67. package/src/cdp/measure/collectors/text.ts +713 -0
  68. package/src/cdp/measure/diff.ts +470 -0
  69. package/src/cdp/measure/explain.ts +498 -0
  70. package/src/cdp/measure/map-ax.ts +633 -0
  71. package/src/cdp/measure/map-focus.ts +188 -0
  72. package/src/cdp/measure/map-layers.ts +177 -0
  73. package/src/cdp/measure/map-scroll.ts +317 -0
  74. package/src/cdp/measure/redaction.ts +51 -0
  75. package/src/cdp/measure/settle.ts +1051 -0
  76. package/src/cdp/measure/snapshot.ts +390 -0
  77. package/src/cdp/measure/sweep.ts +330 -0
  78. package/src/cdp/measure/types.ts +220 -0
  79. package/src/cdp/measure/v2/collectors.ts +19 -0
  80. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  81. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  82. package/src/cdp/measure/v2/target-lock.ts +15 -0
  83. package/src/cdp/measure/v2/target.ts +16 -0
  84. package/src/cdp/motion/jank.ts +433 -0
  85. package/src/cdp/motion/mask.ts +421 -0
  86. package/src/cdp/motion/recorder.ts +764 -0
  87. package/src/cdp/motion/response.ts +356 -0
  88. package/src/cdp/motion/timeline.ts +215 -0
  89. package/src/cdp/record.ts +5 -212
  90. package/src/cdp/recorder-bridge.ts +1894 -0
  91. package/src/cdp/recorder-client.ts +326 -0
  92. package/src/cdp/scope-lock.ts +73 -0
  93. package/src/cdp/screenshot.ts +199 -66
  94. package/src/cdp/source-map.ts +309 -0
  95. package/src/cdp/targets.ts +21 -1
  96. package/src/cdp/timing.ts +87 -0
  97. package/src/cdp/types.ts +87 -6
  98. package/src/cdp/viewport.ts +21 -0
  99. package/src/cdp.ts +12 -58
  100. package/src/command-help.ts +29 -0
  101. package/src/command-resolution.ts +22 -0
  102. package/src/command-walker.ts +35 -0
  103. package/src/contracts/a11y.ts +33 -0
  104. package/src/contracts/cursor.ts +35 -0
  105. package/src/contracts/index.ts +15 -0
  106. package/src/contracts/motion.ts +19 -0
  107. package/src/contracts/neutral.ts +56 -0
  108. package/src/contracts/primitives.ts +205 -0
  109. package/src/contracts/release.ts +17 -0
  110. package/src/contracts/results.ts +208 -0
  111. package/src/contracts/routes.ts +140 -0
  112. package/src/contracts/snapshot.ts +92 -0
  113. package/src/cursor/index.ts +13 -0
  114. package/src/cursor/service.ts +310 -0
  115. package/src/errors.ts +71 -0
  116. package/src/har-manager.ts +992 -41
  117. package/src/interact.ts +410 -98
  118. package/src/output/artifact-lifecycle.ts +371 -0
  119. package/src/output/artifact.ts +526 -0
  120. package/src/output/diff.ts +111 -0
  121. package/src/output/exact-raw.ts +114 -0
  122. package/src/output/render.ts +987 -0
  123. package/src/output/selector.ts +185 -0
  124. package/src/output/structured.ts +211 -0
  125. package/src/registry.ts +208 -0
  126. package/src/session/artifacts.ts +481 -0
  127. package/src/session/commands.ts +1237 -368
  128. package/src/session/coordinator.ts +251 -0
  129. package/src/session/har-filter.ts +57 -0
  130. package/src/session/log-tailer.ts +653 -0
  131. package/src/session-context.ts +302 -34
  132. package/src/vault/bundle.ts +32 -17
  133. package/src/cdp/commands/exec.ts +0 -105
  134. package/src/cdp/commands/har.ts +0 -128
  135. package/src/cdp/commands/tabs.ts +0 -183
  136. package/src/cdp/commands/traffic.ts +0 -121
  137. package/src/cdp/commands/ui.ts +0 -172
  138. package/src/cdp/execute.ts +0 -113
  139. package/src/cdp/har-output.ts +0 -47
  140. package/src/cdp/usage.ts +0 -77
  141. 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
+ }