humanish 0.123.0 → 0.125.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/CONTEXT.md +2 -2
  3. package/README.md +2 -2
  4. package/dist/actors/computer-use/loop/trace.js +6 -1
  5. package/dist/actors/computer-use/openai-conversation.d.ts +0 -6
  6. package/dist/actors/computer-use/openai-conversation.js +0 -8
  7. package/dist/actors/computer-use/openai-provider.js +5 -8
  8. package/dist/analysis/evidence.js +2 -2
  9. package/dist/evidence/image.js +82 -3
  10. package/dist/feedback/export-bundle.js +1 -5
  11. package/dist/feedback/export.js +30 -11
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.js +1 -0
  14. package/dist/observer/artifact.js +2 -9
  15. package/dist/observer/data.d.ts +9 -2
  16. package/dist/observer/data.js +17 -2
  17. package/dist/observer/page-data.d.ts +5 -0
  18. package/dist/observer/page-data.js +21 -0
  19. package/dist/observer/participant-items.d.ts +45 -0
  20. package/dist/observer/participant-items.js +85 -0
  21. package/dist/observer/pinned-files.d.ts +3 -1
  22. package/dist/observer/pinned-files.js +10 -7
  23. package/dist/observer/render.js +2 -1
  24. package/dist/observer/run-routes.js +16 -5
  25. package/dist/observer-app.html +4 -4
  26. package/dist/routes/computer-use/live-flush.d.ts +9 -4
  27. package/dist/routes/computer-use/live-flush.js +16 -3
  28. package/dist/run/bundle-shape.js +2 -2
  29. package/dist/run/bundle.d.ts +10 -3
  30. package/dist/run/bundle.js +11 -2
  31. package/dist/run/contained-output.d.ts +22 -0
  32. package/dist/run/contained-output.js +97 -1
  33. package/dist/run/evidence-files.d.ts +12 -1
  34. package/dist/run/evidence-files.js +70 -40
  35. package/dist/run/live-items.d.ts +48 -0
  36. package/dist/run/live-items.js +242 -0
  37. package/dist/run/locate.d.ts +10 -0
  38. package/dist/run/locate.js +10 -0
  39. package/dist/run/run.d.ts +3 -2
  40. package/dist/run/run.js +31 -9
  41. package/dist/run/sandbox-ids.d.ts +0 -5
  42. package/dist/run/sandbox-ids.js +0 -7
  43. package/dist/run/stream-trace.d.ts +18 -9
  44. package/dist/run/stream-trace.js +113 -22
  45. package/dist/run/streams.d.ts +17 -2
  46. package/dist/study/parse/actors.js +23 -6
  47. package/dist/study/parse/execution.js +6 -0
  48. package/dist/study/plan-base.js +4 -1
  49. package/dist/study/plan.js +20 -0
  50. package/dist/verify/artifacts.js +39 -51
  51. package/dist/verify/screenshot-reads.d.ts +21 -0
  52. package/dist/verify/screenshot-reads.js +46 -0
  53. package/dist/verify/verify.js +7 -6
  54. package/docs/architecture/github-feedback-loop.md +1 -1
  55. package/docs/architecture/observer.md +24 -5
  56. package/docs/contracts/README.md +1 -1
  57. package/docs/contracts/run-bundle.md +78 -14
  58. package/docs/contracts/schemas.md +38 -37
  59. package/docs/contracts/study-analysis.md +1 -1
  60. package/docs/decisions/0002-observer-is-one-self-contained-file.md +2 -1
  61. package/package.json +4 -2
@@ -5,16 +5,21 @@ import type { RunBundle } from "../../run/bundle.js";
5
5
  import type { RunStudyHomes } from "../../study/run-study-homes.js";
6
6
  import type { DesktopParticipantRun } from "./types.js";
7
7
  export interface LiveTraceFlush {
8
- /** Record a participant's recorded-so-far items; the bundle rewrite follows on the flush
9
- * schedule. */
8
+ /**
9
+ * Record a participant's recorded-so-far items; the bundle write follows on the flush schedule.
10
+ * Each item is deep-frozen here: the run writer appends an item to the participant's live file
11
+ * once, so an item edited after it was recorded would leave the file stale. A producer that
12
+ * needs a different item records a new one.
13
+ */
10
14
  flush: (participantId: string, items: readonly ActorTraceItem[], usage?: ActorTokenUsage, metadata?: CuaLiveMetadata) => void;
11
15
  /** Stop flushing, clear the timer and wait for an in-flight write. Call it on every exit. */
12
16
  stop: () => Promise<void>;
13
17
  }
14
18
  /**
15
19
  * Incremental live flush: as each participant's loop reports its recorded-so-far items,
16
- * rewrite the in-progress bundle with per-stream `liveActor` partials so the attached
17
- * Observer's 5s poll sees the timeline grow. Throttled (one write per interval, trailing
20
+ * publish the in-progress bundle with per-stream `liveActor` partials so the attached
21
+ * Observer's 5s poll sees the timeline grow; the run writer appends each participant's new items
22
+ * to its live file. Throttled (one write per interval, trailing
18
23
  * write guaranteed). `write` is the run's `writeSnapshot`, which serializes writes and refuses
19
24
  * any snapshot once the final write began, so a late flush cannot resurrect the in-progress
20
25
  * bundle. A flush failure is swallowed: mid-run observability must never break the run itself.
@@ -2,8 +2,9 @@ import { attachObserverRuntimeStreamUrls } from "../../observer/render.js";
2
2
  import {} from "../../observer/run-routes.js";
3
3
  /**
4
4
  * Incremental live flush: as each participant's loop reports its recorded-so-far items,
5
- * rewrite the in-progress bundle with per-stream `liveActor` partials so the attached
6
- * Observer's 5s poll sees the timeline grow. Throttled (one write per interval, trailing
5
+ * publish the in-progress bundle with per-stream `liveActor` partials so the attached
6
+ * Observer's 5s poll sees the timeline grow; the run writer appends each participant's new items
7
+ * to its live file. Throttled (one write per interval, trailing
7
8
  * write guaranteed). `write` is the run's `writeSnapshot`, which serializes writes and refuses
8
9
  * any snapshot once the final write began, so a late flush cannot resurrect the in-progress
9
10
  * bundle. A flush failure is swallowed: mid-run observability must never break the run itself.
@@ -56,7 +57,9 @@ export function startLiveTraceFlush(args) {
56
57
  ids: { model },
57
58
  }),
58
59
  ...liveMetadataByStream.get(stream.id),
59
- items: [...liveItems],
60
+ // The flush's own copy, replaced and never changed, so the run writer compares
61
+ // each write's items with the last by identity.
62
+ items: liveItems,
60
63
  },
61
64
  };
62
65
  }),
@@ -94,6 +97,8 @@ export function startLiveTraceFlush(args) {
94
97
  const streamId = streamIdByParticipant.get(participantId);
95
98
  if (streamId === undefined)
96
99
  return;
100
+ for (const item of items)
101
+ deepFreeze(item);
97
102
  liveItemsByStream.set(streamId, items.slice());
98
103
  if (metadata !== undefined)
99
104
  liveMetadataByStream.set(streamId, metadata);
@@ -112,6 +117,14 @@ export function startLiveTraceFlush(args) {
112
117
  };
113
118
  return { flush, stop };
114
119
  }
120
+ /** `value` and every object and array it holds, frozen. */
121
+ function deepFreeze(value) {
122
+ if (value === null || typeof value !== "object" || Object.isFrozen(value))
123
+ return;
124
+ Object.freeze(value);
125
+ for (const child of Object.values(value))
126
+ deepFreeze(child);
127
+ }
115
128
  /**
116
129
  * The live desktop views (runtime stream URLs) participants report, attached to whichever Observer shows
117
130
  * the run. The URLs carry auth, so they live in memory and on the Observer result, never in run
@@ -1,6 +1,6 @@
1
1
  import { isCommsReceivingEvidence } from "../comms/receiving-evidence.js";
2
2
  import { isCapturedGitState } from "./git-state.js";
3
- import { PUBLIC_TARGET_CWD, REVIEW_SCHEMA, RUN_BUNDLE_SCHEMA, RUN_BUNDLE_V1_SCHEMA, } from "./bundle.js";
3
+ import { PUBLIC_TARGET_CWD, REVIEW_SCHEMA, isReadRunBundleSchema, } from "./bundle.js";
4
4
  import { CLEANUP_SCHEMA, } from "./results.js";
5
5
  import { isRunSimulationStatus, isRunStream, isRunStreamKind } from "./stream-shape.js";
6
6
  import { isLocalEvidenceArtifactPath } from "./paths.js";
@@ -10,7 +10,7 @@ import { isSharedWorldEvidence } from "./shared-world-shape.js";
10
10
  import { isNonNegativeSafeInteger, isPositiveSafeInteger, isRecord } from "./type-guards.js";
11
11
  export function isRunBundle(value) {
12
12
  return (isRecord(value) &&
13
- (value.schema === RUN_BUNDLE_SCHEMA || value.schema === RUN_BUNDLE_V1_SCHEMA) &&
13
+ isReadRunBundleSchema(value.schema) &&
14
14
  (value.commsReceiving === undefined || isCommsReceivingEvidence(value.commsReceiving)) &&
15
15
  (value.publication === undefined ||
16
16
  (isRecord(value.publication) &&
@@ -5,10 +5,17 @@ import type { SharedWorldEvidence } from "./shared-world-evidence.js";
5
5
  import { type RunStudyProvenance } from "./study-provenance.js";
6
6
  import type { RunInterruptSignal } from "./status.js";
7
7
  import type { RunSimulationStatus, RunStream, RunStreamKind } from "./streams.js";
8
- /** The run bundle version this release writes: each stream's trace is a file run.json indexes. */
9
- export declare const RUN_BUNDLE_SCHEMA = "humanish.run-bundle.v2";
8
+ /**
9
+ * The run bundle version this release writes: each stream's trace, and each running participant's
10
+ * items so far, are files run.json indexes.
11
+ */
12
+ export declare const RUN_BUNDLE_SCHEMA = "humanish.run-bundle.v3";
13
+ /** The version 0.123.0 and 0.124.0 wrote, with a running participant's items inline. It is still read. */
14
+ export declare const RUN_BUNDLE_V2_SCHEMA = "humanish.run-bundle.v2";
10
15
  /** The version 0.122.0 and earlier wrote, with every trace inline. It is still read. */
11
16
  export declare const RUN_BUNDLE_V1_SCHEMA = "humanish.run-bundle.v1";
17
+ /** Whether `value` names a run bundle version this release reads: v3, v2 or v1. */
18
+ export declare function isReadRunBundleSchema(value: unknown): value is RunBundle["schema"];
12
19
  /** The run bundle file, relative to the run directory. */
13
20
  export declare const RUN_BUNDLE_FILE = "run.json";
14
21
  export declare const REVIEW_SCHEMA = "humanish.review.v1";
@@ -231,7 +238,7 @@ export interface RunBundle {
231
238
  restrictions: ["real-communications"];
232
239
  };
233
240
  commsReceiving?: CommsReceivingEvidence;
234
- schema: typeof RUN_BUNDLE_SCHEMA | typeof RUN_BUNDLE_V1_SCHEMA;
241
+ schema: typeof RUN_BUNDLE_SCHEMA | typeof RUN_BUNDLE_V2_SCHEMA | typeof RUN_BUNDLE_V1_SCHEMA;
235
242
  runId: string;
236
243
  mode: "dry-run" | "live";
237
244
  simCount: number;
@@ -1,10 +1,19 @@
1
1
  import path from "node:path";
2
2
  import { captureGitState } from "./git-state.js";
3
3
  import { studyFields } from "./study-provenance.js";
4
- /** The run bundle version this release writes: each stream's trace is a file run.json indexes. */
5
- export const RUN_BUNDLE_SCHEMA = "humanish.run-bundle.v2";
4
+ /**
5
+ * The run bundle version this release writes: each stream's trace, and each running participant's
6
+ * items so far, are files run.json indexes.
7
+ */
8
+ export const RUN_BUNDLE_SCHEMA = "humanish.run-bundle.v3";
9
+ /** The version 0.123.0 and 0.124.0 wrote, with a running participant's items inline. It is still read. */
10
+ export const RUN_BUNDLE_V2_SCHEMA = "humanish.run-bundle.v2";
6
11
  /** The version 0.122.0 and earlier wrote, with every trace inline. It is still read. */
7
12
  export const RUN_BUNDLE_V1_SCHEMA = "humanish.run-bundle.v1";
13
+ /** Whether `value` names a run bundle version this release reads: v3, v2 or v1. */
14
+ export function isReadRunBundleSchema(value) {
15
+ return (value === RUN_BUNDLE_SCHEMA || value === RUN_BUNDLE_V2_SCHEMA || value === RUN_BUNDLE_V1_SCHEMA);
16
+ }
8
17
  /** The run bundle file, relative to the run directory. */
9
18
  export const RUN_BUNDLE_FILE = "run.json";
10
19
  export const REVIEW_SCHEMA = "humanish.review.v1";
@@ -46,6 +46,21 @@ export declare function writeContainedOutputFile(rootInput: PreparedOutputRoot,
46
46
  * writeContainedOutputFile renames a finished file into place instead, replacing what is there.
47
47
  */
48
48
  export declare function createContainedOutputFile(rootInput: PreparedOutputRoot, relativePath: string, data: string): Promise<void>;
49
+ /**
50
+ * Appends `data` to an existing single-link regular file inside the root that holds exactly
51
+ * `size` bytes, the count the caller says it wrote, so an append never lands after bytes the
52
+ * caller did not write. The checks writeContainedOutputFile makes run before the open; the opened
53
+ * file must be the one checked, and the root and its folders are checked again before the write.
54
+ * The data is synced before this resolves, so a run.json written after it never indexes bytes the
55
+ * disk may not hold.
56
+ */
57
+ export declare function appendContainedOutputFile(rootInput: PreparedOutputRoot, relativePath: string, data: Uint8Array, size: number): Promise<void>;
58
+ /**
59
+ * Removes a single-link regular file or an empty folder inside the root. Nothing at the path, or a
60
+ * folder on it missing, is not an error. A link, a hard-linked file or a folder that holds anything
61
+ * is refused and kept.
62
+ */
63
+ export declare function removeContainedOutputFile(rootInput: PreparedOutputRoot, relativePath: string): Promise<void>;
49
64
  /**
50
65
  * A file in a run directory or a preflight journal. verify reads every run file whole to scan it,
51
66
  * and a frame whole to check it, so this covers the largest screenshot src/evidence/image.ts
@@ -99,6 +114,13 @@ export declare function readContainedRegularFile(rootInput: PreparedOutputRoot,
99
114
  * after it was opened is never read whole.
100
115
  */
101
116
  export declare function readOpenedAtMost(handle: FileHandle, maxBytes: number): Promise<Buffer | null>;
117
+ /**
118
+ * The first `length` bytes of one regular file, under the checks readContainedRegularFile makes,
119
+ * or all of a file that holds fewer. Bytes past `length`, such as a later append, are not read.
120
+ */
121
+ export declare function readContainedPrefix(rootInput: PreparedOutputRoot, relativePath: string, length: number): Promise<ContainedRead>;
122
+ /** The first `length` bytes of an opened file, read by position, or all of a shorter one. */
123
+ export declare function readOpenedPrefix(handle: FileHandle, length: number): Promise<Buffer>;
102
124
  /** The caller owns this checked descriptor and must close it after reading/streaming. */
103
125
  export declare function openContainedRegularFile(rootInput: PreparedOutputRoot, relativePath: string): Promise<FileHandle | null>;
104
126
  export declare function assertSafeOutputPathSegment(value: string, label?: string): void;
@@ -1,5 +1,5 @@
1
1
  import { constants } from "node:fs";
2
- import { lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises";
2
+ import { lstat, mkdir, open, realpath, rename, rmdir, unlink, } from "node:fs/promises";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import path from "node:path";
5
5
  import { assertDirectoryIdentity, assertRegularFileOrMissing, isPathInside, prepareHumanishStorageDirectory, resolveExistingHumanishStorageDirectory, validatePreparedRunArtifactPaths, validatePreparedRunRootIdentity, } from "./paths.js";
@@ -150,6 +150,70 @@ export async function createContainedOutputFile(rootInput, relativePath, data) {
150
150
  }
151
151
  await handle.close();
152
152
  }
153
+ /**
154
+ * Appends `data` to an existing single-link regular file inside the root that holds exactly
155
+ * `size` bytes, the count the caller says it wrote, so an append never lands after bytes the
156
+ * caller did not write. The checks writeContainedOutputFile makes run before the open; the opened
157
+ * file must be the one checked, and the root and its folders are checked again before the write.
158
+ * The data is synced before this resolves, so a run.json written after it never indexes bytes the
159
+ * disk may not hold.
160
+ */
161
+ export async function appendContainedOutputFile(rootInput, relativePath, data, size) {
162
+ const filePath = await prepareContainedOutputFile(rootInput, relativePath);
163
+ const root = await resolveOutputRoot(rootInput);
164
+ await assertContainedDirectoryChain(root, path.dirname(filePath));
165
+ const before = await lstat(filePath, { bigint: true });
166
+ if (!before.isFile() || before.nlink > 1n)
167
+ throw new Error(`${relativePath} is not a single-link regular file.`);
168
+ // O_NONBLOCK: a file swapped for a FIFO after the lstat fails the open at once (no reader), where
169
+ // a blocking open would wait for a reader and hold up every later write in the run's queue.
170
+ // Writes to a regular file ignore it.
171
+ const handle = await open(filePath, constants.O_WRONLY | constants.O_APPEND | constants.O_NOFOLLOW | constants.O_NONBLOCK);
172
+ try {
173
+ const after = await handle.stat({ bigint: true });
174
+ if (!after.isFile() || after.nlink > 1n || after.dev !== before.dev || after.ino !== before.ino)
175
+ throw new Error(`${relativePath} changed while it was opened.`);
176
+ if (after.size !== BigInt(size))
177
+ throw new Error(`${relativePath} holds ${after.size} bytes, not ${size}.`);
178
+ if ((await resolveOutputRoot(rootInput)) !== root)
179
+ throw new Error("Output root changed after it was prepared.");
180
+ await assertContainedDirectoryChain(root, path.dirname(filePath));
181
+ const { bytesWritten } = await handle.write(data, 0, data.length);
182
+ if (bytesWritten !== data.length)
183
+ throw new Error(`${relativePath} was not appended in full.`);
184
+ await handle.datasync();
185
+ }
186
+ finally {
187
+ await handle.close();
188
+ }
189
+ }
190
+ /**
191
+ * Removes a single-link regular file or an empty folder inside the root. Nothing at the path, or a
192
+ * folder on it missing, is not an error. A link, a hard-linked file or a folder that holds anything
193
+ * is refused and kept.
194
+ */
195
+ export async function removeContainedOutputFile(rootInput, relativePath) {
196
+ assertSafeRelativeOutputPath(relativePath, false);
197
+ const root = await resolveOutputRoot(rootInput);
198
+ const candidate = path.resolve(root, normalizeRelativeOutputPath(relativePath));
199
+ if (!isPathInside(root, candidate) || candidate === root)
200
+ throw new Error("Output file must stay inside its selected root.");
201
+ let stats;
202
+ try {
203
+ await assertContainedDirectoryChain(root, path.dirname(candidate));
204
+ stats = await lstat(candidate, { bigint: true });
205
+ }
206
+ catch (error) {
207
+ if (isNodeError(error) && error.code === "ENOENT")
208
+ return;
209
+ throw error;
210
+ }
211
+ if (stats.isDirectory())
212
+ return rmdir(candidate);
213
+ if (!stats.isFile() || stats.nlink > 1n)
214
+ throw new Error(`${relativePath} is not a single-link regular file.`);
215
+ await unlink(candidate);
216
+ }
153
217
  // The most humanish reads of one file of each kind. A larger file is refused unread.
154
218
  /**
155
219
  * A file in a run directory or a preflight journal. verify reads every run file whole to scan it,
@@ -240,6 +304,38 @@ export async function readOpenedAtMost(handle, maxBytes) {
240
304
  chunks.push(chunk.subarray(0, bytesRead));
241
305
  }
242
306
  }
307
+ /**
308
+ * The first `length` bytes of one regular file, under the checks readContainedRegularFile makes,
309
+ * or all of a file that holds fewer. Bytes past `length`, such as a later append, are not read.
310
+ */
311
+ export async function readContainedPrefix(rootInput, relativePath, length) {
312
+ const opened = await openContained(rootInput, relativePath);
313
+ if (opened.status === "missing")
314
+ return opened;
315
+ if (opened.status === "refused")
316
+ return { status: "refused", reason: opened.reason, limit: length };
317
+ try {
318
+ return { status: "read", bytes: await readOpenedPrefix(opened.handle, length) };
319
+ }
320
+ catch {
321
+ return { status: "refused", reason: "unreadable", limit: length };
322
+ }
323
+ finally {
324
+ await opened.handle.close().catch(() => { });
325
+ }
326
+ }
327
+ /** The first `length` bytes of an opened file, read by position, or all of a shorter one. */
328
+ export async function readOpenedPrefix(handle, length) {
329
+ const bytes = Buffer.allocUnsafe(length);
330
+ let total = 0;
331
+ while (total < length) {
332
+ const { bytesRead } = await handle.read(bytes, total, length - total, total);
333
+ if (bytesRead === 0)
334
+ break;
335
+ total += bytesRead;
336
+ }
337
+ return bytes.subarray(0, total);
338
+ }
243
339
  /** The caller owns this checked descriptor and must close it after reading/streaming. */
244
340
  export async function openContainedRegularFile(rootInput, relativePath) {
245
341
  const opened = await openContained(rootInput, relativePath);
@@ -1,3 +1,4 @@
1
+ import { type BigIntStats } from "node:fs";
1
2
  import { type PreparedOutputRoot } from "./contained-output.js";
2
3
  /**
3
4
  * Analysis inputs are retained local artifacts, never URLs or caller-selected outputs. The path is
@@ -17,6 +18,8 @@ export declare function decodesToPlainRelativePath(value: string, maxLength: num
17
18
  * authority to select another file or initiate a network request.
18
19
  */
19
20
  export declare function readBoundedFile(root: PreparedOutputRoot, relativePath: string, maxBytes: number): Promise<Buffer | null>;
21
+ /** The fields of a file's lstat that sameFile compares: which file it is, its size and times. */
22
+ export type FileStamp = Pick<BigIntStats, "dev" | "ino" | "size" | "mtimeNs" | "ctimeNs">;
20
23
  /**
21
24
  * After a bounded read returned nothing: true when the file is absent, false when something is
22
25
  * there that could not be read. Errors other than ENOENT propagate.
@@ -25,6 +28,7 @@ export declare function pathMissing(filePath: string): Promise<boolean>;
25
28
  export type BoundedFileResult = {
26
29
  state: "read";
27
30
  bytes: Buffer;
31
+ stamp: FileStamp;
28
32
  } | {
29
33
  state: "limit";
30
34
  size: bigint;
@@ -37,6 +41,13 @@ export declare function readBoundedFileResult(root: PreparedOutputRoot, relative
37
41
  * At most `maxBytes` of a contained single-link regular file that stays the same file, with the
38
42
  * same size and times, from the first check to the last. Size refusals are distinguished only after
39
43
  * the same checks. `relativePath` uses `/`, and any name a contained read accepts
40
- * (isSafeRelativeFilePath) is read: verify and export read the names they always did.
44
+ * (isSafeRelativeFilePath) is read: verify and export read the names they always did. A read
45
+ * returns the stamp it held the file to.
41
46
  */
42
47
  export declare function readUnchangedFile(root: PreparedOutputRoot, relativePath: string, maxBytes: number): Promise<BoundedFileResult>;
48
+ /**
49
+ * True when the file readUnchangedFile would read at `relativePath` is still the file `stamp`
50
+ * describes: it passes the same root, parent and leaf checks, before and after its lstat matches
51
+ * the stamp. Opens nothing.
52
+ */
53
+ export declare function isUnchangedFile(root: PreparedOutputRoot, relativePath: string, stamp: FileStamp): Promise<boolean>;
@@ -99,47 +99,19 @@ export async function readBoundedFileResult(root, relativePath, maxBytes) {
99
99
  * At most `maxBytes` of a contained single-link regular file that stays the same file, with the
100
100
  * same size and times, from the first check to the last. Size refusals are distinguished only after
101
101
  * the same checks. `relativePath` uses `/`, and any name a contained read accepts
102
- * (isSafeRelativeFilePath) is read: verify and export read the names they always did.
102
+ * (isSafeRelativeFilePath) is read: verify and export read the names they always did. A read
103
+ * returns the stamp it held the file to.
103
104
  */
104
105
  export async function readUnchangedFile(root, relativePath, maxBytes) {
105
- if (!isSafeRelativeFilePath(relativePath) ||
106
- relativePath.includes("\\") ||
107
- !Number.isSafeInteger(maxBytes) ||
108
- maxBytes < 1)
106
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1)
109
107
  return unavailable;
110
- const validateRoot = async () => {
111
- if ("physicalRunRoot" in root) {
112
- await validatePreparedRunRootIdentity(root);
113
- return root.physicalRunRoot;
114
- }
115
- await assertPreparedSelectedOutputDirectory(root);
116
- return root.physicalPath;
117
- };
118
108
  try {
119
- const physicalRoot = await validateRoot();
120
- const candidate = path.join(physicalRoot, relativePath);
121
- if (!isPathInside(physicalRoot, candidate) || candidate === physicalRoot)
122
- return unavailable;
123
- const validateParents = async () => {
124
- let current = physicalRoot;
125
- for (const segment of relativePath.split("/").slice(0, -1)) {
126
- current = path.join(current, segment);
127
- const info = await lstat(current);
128
- if (!info.isDirectory() || info.isSymbolicLink())
129
- throw new Error("Unsafe analysis input directory.");
130
- }
131
- };
132
- await validateParents();
133
- const before = await lstat(candidate, { bigint: true });
134
- if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n)
135
- return unavailable;
136
- if ((await realpath(candidate)) !== candidate)
109
+ const located = await locateContainedFile(root, relativePath);
110
+ if (located === null)
137
111
  return unavailable;
112
+ const { candidate, before, stillSameFile } = located;
138
113
  if (before.size > BigInt(maxBytes)) {
139
- if ((await validateRoot()) !== physicalRoot)
140
- return unavailable;
141
- await validateParents();
142
- if (!sameFile(before, await lstat(candidate, { bigint: true })))
114
+ if (!(await stillSameFile(before)))
143
115
  return unavailable;
144
116
  return { state: "limit", size: before.size };
145
117
  }
@@ -153,12 +125,10 @@ export async function readUnchangedFile(root, relativePath, maxBytes) {
153
125
  !sameFile(before, await handle.stat({ bigint: true })) ||
154
126
  bytes.length !== Number(before.size))
155
127
  return unavailable;
156
- if ((await validateRoot()) !== physicalRoot)
157
- return unavailable;
158
- await validateParents();
159
- if (!sameFile(before, await lstat(candidate, { bigint: true })))
128
+ if (!(await stillSameFile(before)))
160
129
  return unavailable;
161
- return { state: "read", bytes };
130
+ const { dev, ino, size, mtimeNs, ctimeNs } = before;
131
+ return { state: "read", bytes, stamp: { dev, ino, size, mtimeNs, ctimeNs } };
162
132
  }
163
133
  finally {
164
134
  await handle.close();
@@ -168,3 +138,63 @@ export async function readUnchangedFile(root, relativePath, maxBytes) {
168
138
  return unavailable;
169
139
  }
170
140
  }
141
+ /**
142
+ * True when the file readUnchangedFile would read at `relativePath` is still the file `stamp`
143
+ * describes: it passes the same root, parent and leaf checks, before and after its lstat matches
144
+ * the stamp. Opens nothing.
145
+ */
146
+ export async function isUnchangedFile(root, relativePath, stamp) {
147
+ try {
148
+ const located = await locateContainedFile(root, relativePath);
149
+ return (located !== null && sameFile(stamp, located.before) && (await located.stillSameFile(stamp)));
150
+ }
151
+ catch {
152
+ return false;
153
+ }
154
+ }
155
+ /**
156
+ * The contained single-link regular file at `relativePath`: its path, its lstat, and a recheck that
157
+ * the root, its parents and the leaf are unchanged since. Null for a path or leaf a read refuses;
158
+ * a root or parent that fails its check throws.
159
+ */
160
+ async function locateContainedFile(root, relativePath) {
161
+ if (!isSafeRelativeFilePath(relativePath) || relativePath.includes("\\"))
162
+ return null;
163
+ const validateRoot = async () => {
164
+ if ("physicalRunRoot" in root) {
165
+ await validatePreparedRunRootIdentity(root);
166
+ return root.physicalRunRoot;
167
+ }
168
+ await assertPreparedSelectedOutputDirectory(root);
169
+ return root.physicalPath;
170
+ };
171
+ const physicalRoot = await validateRoot();
172
+ const candidate = path.join(physicalRoot, relativePath);
173
+ if (!isPathInside(physicalRoot, candidate) || candidate === physicalRoot)
174
+ return null;
175
+ const validateParents = async () => {
176
+ let current = physicalRoot;
177
+ for (const segment of relativePath.split("/").slice(0, -1)) {
178
+ current = path.join(current, segment);
179
+ const info = await lstat(current);
180
+ if (!info.isDirectory() || info.isSymbolicLink())
181
+ throw new Error("Unsafe analysis input directory.");
182
+ }
183
+ };
184
+ await validateParents();
185
+ const before = await lstat(candidate, { bigint: true });
186
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1n)
187
+ return null;
188
+ if ((await realpath(candidate)) !== candidate)
189
+ return null;
190
+ return {
191
+ candidate,
192
+ before,
193
+ stillSameFile: async (stamp) => {
194
+ if ((await validateRoot()) !== physicalRoot)
195
+ return false;
196
+ await validateParents();
197
+ return sameFile(stamp, await lstat(candidate, { bigint: true }));
198
+ },
199
+ };
200
+ }
@@ -0,0 +1,48 @@
1
+ import type { ActorTraceItem } from "../actors/contract.js";
2
+ import type { RunEvent } from "./bundle.js";
3
+ import { type PreparedOutputRoot } from "./contained-output.js";
4
+ import type { LiveItemsIndex, RunStream } from "./streams.js";
5
+ /** The live actor of a v3 run.json, whose items are in the file `itemsFile` names. */
6
+ export declare const LIVE_ACTOR_SCHEMA = "humanish.live-actor.v2";
7
+ /** The live actor of v1 and v2 run.json, with its items inline. */
8
+ export declare const LIVE_ACTOR_V1_SCHEMA = "humanish.live-actor.v1";
9
+ /** Whether `value` is a live items index naming a live file name and its own generation. */
10
+ export declare function isLiveItemsIndex(value: unknown): value is LiveItemsIndex;
11
+ /**
12
+ * The items the indexed prefix of a live file holds, or why the bytes do not match the index:
13
+ * `bytes` must be exactly the indexed byte count, hash to the indexed sha256, and hold the indexed
14
+ * number of lines, each a JSON object.
15
+ */
16
+ export declare function liveItemsFromBytes(bytes: Buffer, index: LiveItemsIndex): {
17
+ items: ActorTraceItem[];
18
+ } | {
19
+ refused: string;
20
+ };
21
+ /** One run's live files: what each stream position's current generation holds. */
22
+ export interface LiveItemsWriter {
23
+ /**
24
+ * `streams` with each running participant's items in its live file: every `liveActor` that
25
+ * carries `items` becomes humanish.live-actor.v2 with `itemsFile` in their place. A `liveActor`
26
+ * that already carries `itemsFile` (a bundle built from a run.json this writer wrote) stands for
27
+ * the list this writer last wrote for its position, and gets the same checks; any other index
28
+ * (another position's file, an earlier generation, a file this writer did not write) rejects the
29
+ * write, since the writer can vouch for and scan only its own files. Items past what the participant's current
30
+ * generation holds are appended, each through publicSandboxView with `sandboxIds`; a new
31
+ * generation holding the full list starts when appending cannot keep the file equal to the list.
32
+ * Every byte is on disk when this resolves, so a run.json written after it indexes nothing
33
+ * missing. Call `published` once run.json naming the returned streams is written: it removes
34
+ * every superseded generation those streams do not name, including the file of a stream that no
35
+ * longer carries live items. A generation whose removal fails, or whose write's run.json failed
36
+ * so `published` never ran, is removed by the next `published`. `events` is the one warn event
37
+ * the run records once a participant's generation was replaced on two writes in a row, from that
38
+ * write on; empty until then.
39
+ */
40
+ write(streams: readonly RunStream[], sandboxIds: readonly string[]): Promise<{
41
+ streams: RunStream[];
42
+ events: RunEvent[];
43
+ published: () => Promise<void>;
44
+ }>;
45
+ /** Remove every live file this writer created, and live/ once empty: after the final run.json. */
46
+ remove(): Promise<void>;
47
+ }
48
+ export declare function liveItemsWriter(root: PreparedOutputRoot): LiveItemsWriter;