humanish 0.110.0 → 0.110.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +15 -12
- package/CHANGELOG.md +26 -0
- package/CONTEXT.md +8 -6
- package/README.md +103 -53
- package/dist/actors/computer-use/loop/ending.js +4 -4
- package/dist/actors/local-agent/claude-participant.d.ts +74 -0
- package/dist/actors/local-agent/claude-participant.js +243 -0
- package/dist/actors/local-agent/claude-session.d.ts +16 -3
- package/dist/actors/local-agent/claude-session.js +64 -23
- package/dist/actors/local-agent/cli.d.ts +17 -0
- package/dist/actors/local-agent/cli.js +111 -24
- package/dist/actors/local-agent/readiness.d.ts +2 -2
- package/dist/actors/local-agent/readiness.js +20 -12
- package/dist/cli/commands/analyze.js +21 -5
- package/dist/cli/commands/observe.js +15 -2
- package/dist/cli/commands/run-signals.d.ts +2 -0
- package/dist/cli/commands/run-signals.js +106 -45
- package/dist/cli/commands/runs.d.ts +5 -0
- package/dist/cli/commands/runs.js +113 -74
- package/dist/cli/commands/setup.js +23 -14
- package/dist/cli/commands/study-route-computer-use.js +9 -3
- package/dist/cli/commands/study-route-open.js +1 -0
- package/dist/cli/commands/study-route-preview.js +1 -0
- package/dist/cli/commands/study-route-scripted.js +2 -0
- package/dist/cli/commands/study-route-shared-world.js +6 -2
- package/dist/cli/commands/study-route-terminal.js +2 -0
- package/dist/cli/commands/watch.js +14 -4
- package/dist/cli/doctor.d.ts +4 -1
- package/dist/cli/doctor.js +51 -10
- package/dist/cli/findings.d.ts +103 -0
- package/dist/cli/findings.js +317 -0
- package/dist/cli/first-run-path.d.ts +32 -11
- package/dist/cli/first-run-path.js +113 -69
- package/dist/cli/invocation.d.ts +7 -0
- package/dist/cli/invocation.js +32 -0
- package/dist/cli/io.d.ts +3 -1
- package/dist/cli/io.js +8 -15
- package/dist/cli/observer-follow.d.ts +20 -0
- package/dist/cli/observer-follow.js +36 -1
- package/dist/cli/orientation.js +16 -14
- package/dist/cli/product-sentence.d.ts +3 -3
- package/dist/cli/product-sentence.js +3 -3
- package/dist/cli/version.d.ts +1 -0
- package/dist/cli/version.js +11 -0
- package/dist/evidence/encoded-text.d.ts +9 -6
- package/dist/evidence/encoded-text.js +84 -19
- package/dist/evidence/redaction.d.ts +2 -0
- package/dist/evidence/redaction.js +123 -21
- package/dist/feedback/export-bundle.js +2 -2
- package/dist/keys/env-file.d.ts +4 -0
- package/dist/keys/env-file.js +17 -0
- package/dist/keys/key-resolution.js +6 -5
- package/dist/observer/data.d.ts +14 -1
- package/dist/observer/data.js +19 -9
- package/dist/observer/exposure.js +1 -1
- package/dist/observer/library.d.ts +4 -0
- package/dist/observer/library.js +21 -17
- package/dist/observer/render.js +10 -3
- package/dist/observer/run-routes.d.ts +2 -0
- package/dist/observer/run-routes.js +51 -35
- package/dist/observer/serve.js +2 -1
- package/dist/observer-app.html +3 -3
- package/dist/routes/computer-use/bundle.js +3 -1
- package/dist/routes/computer-use/fanout-records.js +1 -1
- package/dist/routes/computer-use/participant-execution.js +3 -1
- package/dist/routes/computer-use/participant-model.js +5 -2
- package/dist/routes/computer-use/plan.js +9 -4
- package/dist/routes/computer-use/preflight.js +1 -1
- package/dist/routes/computer-use/result.js +17 -28
- package/dist/routes/computer-use/single-bundle.js +7 -1
- package/dist/routes/scripted/bundle.js +4 -2
- package/dist/routes/scripted/plan.js +2 -2
- package/dist/routes/scripted/result.d.ts +3 -5
- package/dist/routes/scripted/result.js +16 -22
- package/dist/routes/scripted/scenario.js +4 -4
- package/dist/routes/shared-world/bundle-records.js +6 -1
- package/dist/routes/shared-world/bundle.js +2 -0
- package/dist/routes/shared-world/plan.js +6 -2
- package/dist/routes/shared-world/result.js +22 -22
- package/dist/routes/terminal/bundle.js +6 -3
- package/dist/routes/terminal/credentials.d.ts +5 -5
- package/dist/routes/terminal/credentials.js +14 -6
- package/dist/routes/terminal/dry-run.js +8 -12
- package/dist/routes/terminal/ledger.d.ts +3 -3
- package/dist/routes/terminal/ledger.js +6 -6
- package/dist/routes/terminal/live-finish.js +15 -13
- package/dist/routes/terminal/plan.js +12 -4
- package/dist/routes/terminal/result.d.ts +6 -7
- package/dist/routes/terminal/result.js +6 -22
- package/dist/routes/terminal/route.js +5 -4
- package/dist/routes/terminal/runtime-auth.d.ts +6 -0
- package/dist/routes/terminal/runtime-auth.js +5 -0
- package/dist/routes/terminal/session.js +1 -1
- package/dist/run/bundle-shape.d.ts +3 -1
- package/dist/run/bundle-shape.js +25 -1
- package/dist/run/bundle.d.ts +24 -0
- package/dist/run/display.d.ts +59 -0
- package/dist/run/display.js +106 -0
- package/dist/run/dry-run.js +13 -8
- package/dist/run/judge.d.ts +3 -2
- package/dist/run/judge.js +12 -1
- package/dist/run/local-only-files.d.ts +13 -0
- package/dist/run/local-only-files.js +35 -0
- package/dist/run/pricing.js +1 -1
- package/dist/run/projection.d.ts +0 -2
- package/dist/run/projection.js +0 -11
- package/dist/run/reclaim-outcomes.d.ts +89 -0
- package/dist/run/reclaim-outcomes.js +213 -0
- package/dist/run/reclaim.d.ts +23 -17
- package/dist/run/reclaim.js +213 -114
- package/dist/run/results.d.ts +10 -20
- package/dist/run/results.js +2 -2
- package/dist/run/run-index.d.ts +11 -1
- package/dist/run/run-index.js +11 -0
- package/dist/run/run-not-found.js +3 -2
- package/dist/run/run.d.ts +34 -18
- package/dist/run/run.js +166 -67
- package/dist/run/sandbox-creates.d.ts +59 -0
- package/dist/run/sandbox-creates.js +140 -0
- package/dist/run/sandbox-receipts.d.ts +20 -5
- package/dist/run/sandbox-receipts.js +56 -7
- package/dist/run/stats.d.ts +5 -0
- package/dist/run/stats.js +8 -4
- package/dist/run/status.d.ts +6 -9
- package/dist/run/status.js +7 -23
- package/dist/run/stored-runs.d.ts +5 -14
- package/dist/run/stored-runs.js +31 -146
- package/dist/run/streams.d.ts +3 -2
- package/dist/study/agents-section.d.ts +21 -0
- package/dist/study/agents-section.js +86 -0
- package/dist/study/doctor.d.ts +1 -1
- package/dist/study/doctor.js +8 -4
- package/dist/study/init-templates.js +0 -95
- package/dist/study/init.js +45 -37
- package/dist/study/keys.d.ts +12 -0
- package/dist/study/keys.js +32 -0
- package/dist/study/local-browser-host.d.ts +11 -0
- package/dist/study/local-browser-host.js +42 -0
- package/dist/study/parse/actors.js +9 -1
- package/dist/study/parse/subject.js +12 -0
- package/dist/study/parse/url-credentials.d.ts +9 -0
- package/dist/study/parse/url-credentials.js +50 -0
- package/dist/study/persona.d.ts +4 -0
- package/dist/study/persona.js +4 -2
- package/dist/study/preflight-probes.js +2 -1
- package/dist/study/types.d.ts +8 -6
- package/dist/study/url-credentials.d.ts +6 -0
- package/dist/study/url-credentials.js +34 -0
- package/dist/substrates/e2b/peer-install.d.ts +51 -0
- package/dist/substrates/e2b/peer-install.js +161 -0
- package/dist/substrates/e2b/sandbox.d.ts +34 -6
- package/dist/substrates/e2b/sandbox.js +141 -30
- package/dist/substrates/e2b/sdk.d.ts +33 -0
- package/dist/substrates/e2b/sdk.js +21 -21
- package/dist/substrates/local/firecracker-desktop.js +3 -2
- package/dist/substrates/local/runtime-host.js +5 -4
- package/dist/substrates/local/runtime.js +5 -4
- package/dist/substrates/shell.d.ts +2 -0
- package/dist/substrates/shell.js +4 -0
- package/dist/tui/contract.js +5 -4
- package/dist/tui-app.js +133 -133
- package/dist/verify/liveness.d.ts +19 -3
- package/dist/verify/liveness.js +47 -25
- package/dist/verify/verify.d.ts +3 -0
- package/dist/verify/verify.js +8 -3
- package/docs/architecture/actor-contract.md +2 -1
- package/docs/architecture/observer.md +22 -7
- package/docs/architecture/task-protocol-support.md +13 -13
- package/docs/architecture/terminal-product-route.md +15 -13
- package/docs/contracts/run-bundle.md +95 -10
- package/docs/contracts/schemas.md +44 -26
- package/docs/contracts/study-analysis.md +45 -0
- package/docs/contracts/study-costs.md +8 -0
- package/docs/decisions/0001-run-bundle-is-the-source-of-truth.md +3 -0
- package/docs/decisions/0003-contained-paths-and-cleanup-authority.md +9 -3
- package/docs/principles/invariants-and-defaults.md +11 -8
- package/package.json +6 -3
- package/skills/humanish/SKILL.md +38 -20
package/dist/run/run.d.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { renderObserver, type ObserverResult } from "../observer/render.js";
|
|
2
2
|
import { type RunBundle } from "./bundle.js";
|
|
3
|
-
import type
|
|
3
|
+
import { type ExecutionFailure, type ExecutionOutcome, type OutcomePolicy } from "./judge.js";
|
|
4
4
|
import { type PreparedRunArtifactPaths, type RunIdInUse } from "./paths.js";
|
|
5
|
-
import { type RunStatusHandle } from "./status.js";
|
|
6
5
|
import type { RunStudyProvenance } from "./study-provenance.js";
|
|
7
6
|
interface StartRunOptions {
|
|
8
7
|
/** Project directory, in the form the route resolved it. */
|
|
@@ -22,6 +21,19 @@ interface StartRunOptions {
|
|
|
22
21
|
/** Clock for `createdAt` and the latest pointer. */
|
|
23
22
|
now?: (() => number) | undefined;
|
|
24
23
|
}
|
|
24
|
+
/** How a run ended as an execution, as run.json records it: the result's ok and execution outcome. */
|
|
25
|
+
interface RecordedOutcome {
|
|
26
|
+
ok: boolean;
|
|
27
|
+
execution: ExecutionOutcome;
|
|
28
|
+
}
|
|
29
|
+
/** What a route hands Run.finish about how its run ended, before the Observer renders. */
|
|
30
|
+
export interface FinishOutcome extends RecordedOutcome {
|
|
31
|
+
/**
|
|
32
|
+
* The route's entry in OUTCOME_POLICIES. FinishedRun.renderObserver records an Observer that
|
|
33
|
+
* did not render under it, as a failure or a warning.
|
|
34
|
+
*/
|
|
35
|
+
policy: OutcomePolicy;
|
|
36
|
+
}
|
|
25
37
|
interface Run {
|
|
26
38
|
readonly runId: string;
|
|
27
39
|
readonly createdAt: string;
|
|
@@ -41,14 +53,16 @@ interface Run {
|
|
|
41
53
|
*/
|
|
42
54
|
writeSnapshot(bundle: RunBundle): Promise<void>;
|
|
43
55
|
/**
|
|
44
|
-
* The one final publication: run.json
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
56
|
+
* The one final publication: run.json with `outcome` set from `outcome`, then the status
|
|
57
|
+
* outcome copied from it, then review.json, review.md, events.ndjson,
|
|
58
|
+
* observer/observer-data.json, and last the latest pointer. The status goes after run.json so
|
|
59
|
+
* the index never gets ahead of the evidence, and the pointer goes last so `latest` never
|
|
60
|
+
* selects a run whose projections are incomplete. Every projection reads the outcome, so none
|
|
61
|
+
* shows a pass for a run whose ok is false. Rejects before writing on a second call, after the
|
|
62
|
+
* scope closed, or when the bundle names another run or mode. A rejection partway leaves the
|
|
63
|
+
* files written so far and issues no FinishedRun.
|
|
50
64
|
*/
|
|
51
|
-
finish(bundle: RunBundle): Promise<FinishedRun>;
|
|
65
|
+
finish(bundle: RunBundle, outcome: FinishOutcome): Promise<FinishedRun>;
|
|
52
66
|
}
|
|
53
67
|
export interface RunScope {
|
|
54
68
|
/**
|
|
@@ -78,18 +92,20 @@ export declare class FinishedRun {
|
|
|
78
92
|
readonly runId: string;
|
|
79
93
|
/** The paths created by startRun and validated at the start of `finish`. */
|
|
80
94
|
readonly paths: PreparedRunArtifactPaths;
|
|
81
|
-
constructor(key: typeof issueKey, runId: string, paths: PreparedRunArtifactPaths, observer: ObserverTarget | undefined,
|
|
95
|
+
constructor(key: typeof issueKey, runId: string, paths: PreparedRunArtifactPaths, observer: ObserverTarget | undefined, outcome: RecordedOutcome, recordFailure: (failure: ExecutionFailure) => Promise<RecordedOutcome>);
|
|
82
96
|
static isIssued(value: unknown): value is FinishedRun;
|
|
83
|
-
/** Render the Observer for exactly these paths, never a directory re-resolved by name. */
|
|
84
|
-
renderObserver(): Promise<ObserverResult>;
|
|
85
97
|
/**
|
|
86
|
-
*
|
|
87
|
-
* Observer
|
|
98
|
+
* The run's ok and execution outcome as run.json records them: what the route passed to
|
|
99
|
+
* finish, plus an Observer that did not render. A route's result reads them from here.
|
|
100
|
+
*/
|
|
101
|
+
get outcome(): RecordedOutcome;
|
|
102
|
+
/**
|
|
103
|
+
* Render the Observer for exactly these paths, never a directory re-resolved by name. An
|
|
104
|
+
* Observer that did not render is an `evidence` failure: it is added to run.json's outcome and
|
|
105
|
+
* status.json under the route's policy before this resolves. No page exists then that could
|
|
106
|
+
* show the run without it. A render that throws records nothing and rejects.
|
|
88
107
|
*/
|
|
89
|
-
|
|
90
|
-
ok: boolean;
|
|
91
|
-
execution: ExecutionOutcome;
|
|
92
|
-
}): Promise<void>;
|
|
108
|
+
renderObserver(): Promise<ObserverResult>;
|
|
93
109
|
}
|
|
94
110
|
/**
|
|
95
111
|
* Run `fn` as the lifetime of at most one run. When `fn` settles the scope closes synchronously,
|
package/dist/run/run.js
CHANGED
|
@@ -2,15 +2,17 @@
|
|
|
2
2
|
// token that lets the Observer and automatic analysis read it. Routes build bundles and write
|
|
3
3
|
// their own evidence files; this module owns when the run starts, how it is published, and how it
|
|
4
4
|
// is closed on every exit.
|
|
5
|
+
import { redactText } from "../evidence/redaction.js";
|
|
5
6
|
import { buildObserverData } from "../observer/data.js";
|
|
6
7
|
import { renderObserver } from "../observer/render.js";
|
|
7
8
|
import { RUN_BUNDLE_FILE, PUBLIC_TARGET_CWD } from "./bundle.js";
|
|
9
|
+
import { judgeExecution, } from "./judge.js";
|
|
8
10
|
import {} from "./results.js";
|
|
9
11
|
import { createRunArtifactPaths, validatePreparedRunArtifactPaths, } from "./paths.js";
|
|
10
12
|
import { registerActiveRun } from "./active-runs.js";
|
|
11
13
|
import { writeContainedOutputFile, writePreparedRunLatestPointer } from "./contained-output.js";
|
|
12
14
|
import { scrubRunSandboxIds, withPublicSandboxIds } from "./sandbox-ids.js";
|
|
13
|
-
import { beginRunStatus, runStatusOutcome } from "./status.js";
|
|
15
|
+
import { beginRunStatus, runStatusOutcome, } from "./status.js";
|
|
14
16
|
/** An entry point called in a state that does not admit it. */
|
|
15
17
|
class RunLifecycleError extends Error {
|
|
16
18
|
name = "RunLifecycleError";
|
|
@@ -24,38 +26,66 @@ const issueKey = Symbol("FinishedRun");
|
|
|
24
26
|
*/
|
|
25
27
|
export class FinishedRun {
|
|
26
28
|
#observer;
|
|
27
|
-
#
|
|
29
|
+
#recordFailure;
|
|
30
|
+
#outcome;
|
|
28
31
|
runId;
|
|
29
32
|
/** The paths created by startRun and validated at the start of `finish`. */
|
|
30
33
|
paths;
|
|
31
|
-
constructor(key, runId, paths, observer,
|
|
34
|
+
constructor(key, runId, paths, observer, outcome, recordFailure) {
|
|
32
35
|
if (key !== issueKey)
|
|
33
36
|
throw new Error("Only Run.finish issues a FinishedRun.");
|
|
34
37
|
this.runId = runId;
|
|
35
38
|
this.paths = paths;
|
|
36
39
|
this.#observer = observer;
|
|
37
|
-
this.#
|
|
40
|
+
this.#outcome = outcome;
|
|
41
|
+
this.#recordFailure = recordFailure;
|
|
38
42
|
}
|
|
39
43
|
static isIssued(value) {
|
|
40
44
|
return typeof value === "object" && value !== null && #observer in value;
|
|
41
45
|
}
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return target.render(target.cwd, this.runId, { open: target.open, expectedRun: this.paths });
|
|
46
|
+
/**
|
|
47
|
+
* The run's ok and execution outcome as run.json records them: what the route passed to
|
|
48
|
+
* finish, plus an Observer that did not render. A route's result reads them from here.
|
|
49
|
+
*/
|
|
50
|
+
get outcome() {
|
|
51
|
+
return this.#outcome;
|
|
49
52
|
}
|
|
50
53
|
/**
|
|
51
|
-
*
|
|
52
|
-
* Observer render
|
|
54
|
+
* Render the Observer for exactly these paths, never a directory re-resolved by name. An
|
|
55
|
+
* Observer that did not render is an `evidence` failure: it is added to run.json's outcome and
|
|
56
|
+
* status.json under the route's policy before this resolves. No page exists then that could
|
|
57
|
+
* show the run without it. A render that throws records nothing and rejects.
|
|
53
58
|
*/
|
|
54
|
-
|
|
55
|
-
|
|
59
|
+
async renderObserver() {
|
|
60
|
+
const target = this.#observer;
|
|
61
|
+
if (target === undefined)
|
|
62
|
+
throw new RunLifecycleError("This run was started without an Observer.");
|
|
63
|
+
const observer = await target.render(target.cwd, this.runId, {
|
|
64
|
+
open: target.open,
|
|
65
|
+
expectedRun: this.paths,
|
|
66
|
+
});
|
|
67
|
+
if (!observer.ok) {
|
|
68
|
+
this.#outcome = await this.#recordFailure({
|
|
69
|
+
kind: "evidence",
|
|
70
|
+
message: observer.error?.message ?? "The Observer did not render.",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return observer;
|
|
56
74
|
}
|
|
57
75
|
}
|
|
58
76
|
const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
|
77
|
+
/** An execution outcome as run.json records it, each message through the shape redaction. */
|
|
78
|
+
function redactedExecution(execution) {
|
|
79
|
+
const redacted = (failure) => ({
|
|
80
|
+
kind: failure.kind,
|
|
81
|
+
message: redactText(failure.message),
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
succeeded: execution.succeeded,
|
|
85
|
+
failures: execution.failures.map(redacted),
|
|
86
|
+
...(execution.warnings === undefined ? {} : { warnings: execution.warnings.map(redacted) }),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
59
89
|
/** Where FinishedRun renders the Observer, when the run was started with one. */
|
|
60
90
|
function observerTarget(options) {
|
|
61
91
|
return options.observer === undefined
|
|
@@ -66,6 +96,99 @@ function observerTarget(options) {
|
|
|
66
96
|
render: options.observer.render ?? renderObserver,
|
|
67
97
|
};
|
|
68
98
|
}
|
|
99
|
+
function runPublisher(args) {
|
|
100
|
+
const { options, runId, paths, runStatus, now, admit } = args;
|
|
101
|
+
const observer = observerTarget(options);
|
|
102
|
+
let pointerWritten = false;
|
|
103
|
+
// The bundle of the last write asked for, and the outcome every later write carries once a
|
|
104
|
+
// signal stopped the run, so no write after the interrupt can drop it.
|
|
105
|
+
let lastBundle;
|
|
106
|
+
let interrupted;
|
|
107
|
+
// One chain serializes every write. A rejection reaches only the caller of that write; the
|
|
108
|
+
// chain itself continues, so a failed snapshot never blocks the final publication.
|
|
109
|
+
let tail = Promise.resolve();
|
|
110
|
+
const enqueue = (write) => {
|
|
111
|
+
const written = tail.then(write);
|
|
112
|
+
tail = written.then(() => undefined, () => undefined);
|
|
113
|
+
return written;
|
|
114
|
+
};
|
|
115
|
+
const writePointer = async () => {
|
|
116
|
+
const pointer = {
|
|
117
|
+
schema: "humanish.latest-run.v1",
|
|
118
|
+
runId,
|
|
119
|
+
path: paths.relativeRunRoot,
|
|
120
|
+
updatedAt: new Date(now()).toISOString(),
|
|
121
|
+
};
|
|
122
|
+
await writePreparedRunLatestPointer(paths, json(pointer), "utf8");
|
|
123
|
+
pointerWritten = true;
|
|
124
|
+
};
|
|
125
|
+
/** run.json with its outcome, then `afterBundle`, then the review and projections. */
|
|
126
|
+
const writeBundleFiles = async (bundle, outcome, afterBundle) => {
|
|
127
|
+
await validatePreparedRunArtifactPaths(paths);
|
|
128
|
+
// The run owns `outcome`: a bundle read back from disk keeps none of its own.
|
|
129
|
+
const { outcome: _previous, ...evidence } = bundle;
|
|
130
|
+
const recorded = interrupted ?? outcome;
|
|
131
|
+
const publicBundle = await withPublicSandboxIds(paths, {
|
|
132
|
+
...evidence,
|
|
133
|
+
cwd: PUBLIC_TARGET_CWD,
|
|
134
|
+
...(recorded === undefined ? {} : { outcome: recorded }),
|
|
135
|
+
});
|
|
136
|
+
await writeContainedOutputFile(paths, RUN_BUNDLE_FILE, json(publicBundle), "utf8");
|
|
137
|
+
await afterBundle(publicBundle);
|
|
138
|
+
await writeContainedOutputFile(paths, "review.json", json(publicBundle.review), "utf8");
|
|
139
|
+
await writeContainedOutputFile(paths, "review.md", options.renderReview(publicBundle), "utf8");
|
|
140
|
+
const events = publicBundle.events.map((event) => JSON.stringify(event)).join("\n");
|
|
141
|
+
await writeContainedOutputFile(paths, "events.ndjson", `${events}\n`, "utf8");
|
|
142
|
+
await writeContainedOutputFile(paths, "observer/observer-data.json", json(buildObserverData(publicBundle)), "utf8");
|
|
143
|
+
};
|
|
144
|
+
const snapshot = async (bundle) => {
|
|
145
|
+
await writeBundleFiles(bundle, undefined, async () => { });
|
|
146
|
+
if (!pointerWritten)
|
|
147
|
+
await writePointer();
|
|
148
|
+
};
|
|
149
|
+
const finishedOutcome = (outcome) => ({
|
|
150
|
+
state: "finished",
|
|
151
|
+
ok: outcome.ok,
|
|
152
|
+
execution: redactedExecution(outcome.execution),
|
|
153
|
+
});
|
|
154
|
+
const publish = async (bundle, outcome) => {
|
|
155
|
+
await writeBundleFiles(bundle, finishedOutcome(outcome), (publicBundle) => runStatus.finish(runStatusOutcome(publicBundle)));
|
|
156
|
+
await scrubRunSandboxIds(paths);
|
|
157
|
+
await writePointer();
|
|
158
|
+
let current = { ok: outcome.ok, execution: outcome.execution };
|
|
159
|
+
// An Observer that did not render, judged under the route's policy and written to run.json
|
|
160
|
+
// and status.json, so the two agree with the result the route returns. A run directory that
|
|
161
|
+
// can no longer be written (replaced, or a disk error) keeps the files it has, and the
|
|
162
|
+
// result still carries the failure.
|
|
163
|
+
const recordFailure = (failure) => admit(enqueue(async () => {
|
|
164
|
+
const { failures, warnings = [] } = current.execution;
|
|
165
|
+
const execution = judgeExecution([...failures, ...warnings, failure], outcome.policy);
|
|
166
|
+
current = { ok: current.ok && execution.succeeded, execution };
|
|
167
|
+
await writeBundleFiles(bundle, finishedOutcome(current), (publicBundle) => runStatus.restate(runStatusOutcome(publicBundle))).catch(() => undefined);
|
|
168
|
+
return current;
|
|
169
|
+
}));
|
|
170
|
+
return new FinishedRun(issueKey, runId, paths, observer, current, recordFailure);
|
|
171
|
+
};
|
|
172
|
+
const interrupt = async (signal) => {
|
|
173
|
+
interrupted = { state: "interrupted", ok: false, signal, at: new Date(now()).toISOString() };
|
|
174
|
+
const written = lastBundle;
|
|
175
|
+
if (written === undefined)
|
|
176
|
+
return;
|
|
177
|
+
// A failed write leaves status.json as the record of the interrupt.
|
|
178
|
+
await enqueue(() => writeBundleFiles(written, undefined, async () => { })).catch(() => undefined);
|
|
179
|
+
};
|
|
180
|
+
return {
|
|
181
|
+
snapshot(bundle) {
|
|
182
|
+
lastBundle = bundle;
|
|
183
|
+
return enqueue(() => snapshot(bundle));
|
|
184
|
+
},
|
|
185
|
+
publish(bundle, outcome) {
|
|
186
|
+
lastBundle = bundle;
|
|
187
|
+
return enqueue(() => publish(bundle, outcome));
|
|
188
|
+
},
|
|
189
|
+
interrupt,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
69
192
|
/**
|
|
70
193
|
* Run `fn` as the lifetime of at most one run. When `fn` settles the scope closes synchronously,
|
|
71
194
|
* so the scope and its run admit no further calls, even through references `fn` leaked into
|
|
@@ -100,65 +223,40 @@ export async function runScope(fn) {
|
|
|
100
223
|
if (!created.ok)
|
|
101
224
|
return created;
|
|
102
225
|
// beginRunStatus reads only the mode and the study provenance from the run's options.
|
|
103
|
-
|
|
226
|
+
const runStatus = beginRunStatus(created.paths, { ...options, runId });
|
|
227
|
+
status = runStatus;
|
|
228
|
+
let interruptBundle;
|
|
104
229
|
// Registered before the first write lands: a signal while it is still settling reaches
|
|
105
|
-
// the handle, whose interrupt queues behind that write.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
230
|
+
// the handle, whose interrupt queues behind that write. The interrupt lands in
|
|
231
|
+
// status.json first, then in run.json once the run has written one.
|
|
232
|
+
unregister = registerActiveRun({
|
|
233
|
+
cwd: options.cwd,
|
|
234
|
+
runId,
|
|
235
|
+
paths: created.paths,
|
|
236
|
+
status: {
|
|
237
|
+
async interrupt(signal) {
|
|
238
|
+
const recorded = await runStatus.interrupt(signal);
|
|
239
|
+
if (recorded)
|
|
240
|
+
await interruptBundle?.(signal);
|
|
241
|
+
return recorded;
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
await runStatus.started;
|
|
246
|
+
const run = openRun(options, runId, created.paths, runStatus);
|
|
247
|
+
interruptBundle = run.interrupt;
|
|
248
|
+
return { ok: true, run: run.run };
|
|
109
249
|
})());
|
|
110
250
|
};
|
|
111
251
|
const openRun = (options, runId, paths, runStatus) => {
|
|
112
252
|
const now = options.now ?? Date.now;
|
|
113
253
|
const createdAt = new Date(now()).toISOString();
|
|
114
|
-
const
|
|
254
|
+
const publisher = runPublisher({ options, runId, paths, runStatus, now, admit });
|
|
115
255
|
let finishCalled = false;
|
|
116
|
-
let pointerWritten = false;
|
|
117
|
-
// One chain serializes every write. A rejection reaches only the caller of that write; the
|
|
118
|
-
// chain itself continues, so a failed snapshot never blocks the final publication.
|
|
119
|
-
let tail = Promise.resolve();
|
|
120
|
-
const enqueue = (write) => {
|
|
121
|
-
const written = tail.then(write);
|
|
122
|
-
tail = written.then(() => undefined, () => undefined);
|
|
123
|
-
return written;
|
|
124
|
-
};
|
|
125
256
|
const checkIdentity = (bundle) => bundle.runId !== runId || bundle.mode !== options.mode
|
|
126
257
|
? "The bundle names another run or mode than this run."
|
|
127
258
|
: undefined;
|
|
128
|
-
const
|
|
129
|
-
const pointer = {
|
|
130
|
-
schema: "humanish.latest-run.v1",
|
|
131
|
-
runId,
|
|
132
|
-
path: paths.relativeRunRoot,
|
|
133
|
-
updatedAt: new Date(now()).toISOString(),
|
|
134
|
-
};
|
|
135
|
-
await writePreparedRunLatestPointer(paths, json(pointer), "utf8");
|
|
136
|
-
pointerWritten = true;
|
|
137
|
-
};
|
|
138
|
-
/** run.json, then `afterBundle`, then the review and projections. */
|
|
139
|
-
const writeBundleFiles = async (bundle, afterBundle) => {
|
|
140
|
-
await validatePreparedRunArtifactPaths(paths);
|
|
141
|
-
const publicBundle = await withPublicSandboxIds(paths, { ...bundle, cwd: PUBLIC_TARGET_CWD });
|
|
142
|
-
await writeContainedOutputFile(paths, RUN_BUNDLE_FILE, json(publicBundle), "utf8");
|
|
143
|
-
await afterBundle(publicBundle);
|
|
144
|
-
await writeContainedOutputFile(paths, "review.json", json(publicBundle.review), "utf8");
|
|
145
|
-
await writeContainedOutputFile(paths, "review.md", options.renderReview(publicBundle), "utf8");
|
|
146
|
-
const events = publicBundle.events.map((event) => JSON.stringify(event)).join("\n");
|
|
147
|
-
await writeContainedOutputFile(paths, "events.ndjson", `${events}\n`, "utf8");
|
|
148
|
-
await writeContainedOutputFile(paths, "observer/observer-data.json", json(buildObserverData(publicBundle)), "utf8");
|
|
149
|
-
};
|
|
150
|
-
const snapshot = async (bundle) => {
|
|
151
|
-
await writeBundleFiles(bundle, async () => { });
|
|
152
|
-
if (!pointerWritten)
|
|
153
|
-
await writePointer();
|
|
154
|
-
};
|
|
155
|
-
const publish = async (bundle) => {
|
|
156
|
-
await writeBundleFiles(bundle, (publicBundle) => runStatus.finish(runStatusOutcome(publicBundle)));
|
|
157
|
-
await scrubRunSandboxIds(paths);
|
|
158
|
-
await writePointer();
|
|
159
|
-
return new FinishedRun(issueKey, runId, paths, observer, runStatus);
|
|
160
|
-
};
|
|
161
|
-
return {
|
|
259
|
+
const run = {
|
|
162
260
|
runId,
|
|
163
261
|
createdAt,
|
|
164
262
|
mode: options.mode,
|
|
@@ -172,9 +270,9 @@ export async function runScope(fn) {
|
|
|
172
270
|
const mismatch = checkIdentity(bundle);
|
|
173
271
|
if (mismatch !== undefined)
|
|
174
272
|
return refuse(mismatch);
|
|
175
|
-
return admit(
|
|
273
|
+
return admit(publisher.snapshot(bundle));
|
|
176
274
|
},
|
|
177
|
-
finish(bundle) {
|
|
275
|
+
finish(bundle, outcome) {
|
|
178
276
|
if (closed)
|
|
179
277
|
return refuse("The run scope has closed.");
|
|
180
278
|
if (finishCalled)
|
|
@@ -183,12 +281,13 @@ export async function runScope(fn) {
|
|
|
183
281
|
if (mismatch !== undefined)
|
|
184
282
|
return refuse(mismatch);
|
|
185
283
|
finishCalled = true;
|
|
186
|
-
return admit(
|
|
284
|
+
return admit(publisher.publish(bundle, outcome).then((issued) => {
|
|
187
285
|
finished = issued;
|
|
188
286
|
return issued;
|
|
189
287
|
}));
|
|
190
288
|
},
|
|
191
289
|
};
|
|
290
|
+
return { run, interrupt: publisher.interrupt };
|
|
192
291
|
};
|
|
193
292
|
let outcome;
|
|
194
293
|
try {
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { PreparedOutputRoot } from "./contained-output.js";
|
|
2
|
+
import type { SandboxOwnerTags } from "./sandbox-receipts.js";
|
|
3
|
+
/** Refuses a sandbox create for a run directory whose process is stopping. */
|
|
4
|
+
export declare class SandboxCreateRefusedError extends Error {
|
|
5
|
+
constructor();
|
|
6
|
+
}
|
|
7
|
+
/** One sandbox a create in this process reported, and what its route did with it. */
|
|
8
|
+
export interface CreatedSandbox {
|
|
9
|
+
sandboxId: string;
|
|
10
|
+
/** The participant or subject label the receipt also records. */
|
|
11
|
+
participantId: string;
|
|
12
|
+
/** True once the route's own release confirmed the sandbox gone. */
|
|
13
|
+
released: boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The E2B metadata that ties a sandbox to the directory that journals it. `runId` is the
|
|
17
|
+
* directory's name: the run id, or a preflight probe's id. `runKey` is a digest of the directory's
|
|
18
|
+
* inode and creation time, so two projects that both use `--run-id smoke` never match each other's
|
|
19
|
+
* sandboxes, and a renamed project directory still matches its own. `tool` is the owner tag every
|
|
20
|
+
* route already sets.
|
|
21
|
+
*/
|
|
22
|
+
export declare function sandboxOwnerTags(root: PreparedOutputRoot): SandboxOwnerTags;
|
|
23
|
+
/** A create in progress; the acquire step in src/substrates/e2b/sandbox.ts holds one per create. */
|
|
24
|
+
export interface SandboxCreateTicket {
|
|
25
|
+
/** Whether the directory's process has started stopping; checked before each attempt. */
|
|
26
|
+
stopping(): boolean;
|
|
27
|
+
/** The provider reported a sandbox id for this create. Safe to call twice with one id. */
|
|
28
|
+
created(sandboxId: string): void;
|
|
29
|
+
/** The route's release confirmed this sandbox gone. */
|
|
30
|
+
released(sandboxId: string): void;
|
|
31
|
+
/** The create resolved or threw, and its receipt write, if any, has finished. */
|
|
32
|
+
settled(): void;
|
|
33
|
+
}
|
|
34
|
+
/** Register a create for `root`. Throws SandboxCreateRefusedError once the directory is stopping. */
|
|
35
|
+
export declare function beginSandboxCreate(root: PreparedOutputRoot, participantId: string): SandboxCreateTicket;
|
|
36
|
+
/**
|
|
37
|
+
* Run `write` once per directory and return its promise to every caller. Creates that start
|
|
38
|
+
* together then resume in the order they called, so the order they reach the provider does not
|
|
39
|
+
* depend on which file append finishes first. A write that reports failure is forgotten, so the
|
|
40
|
+
* next create tries again.
|
|
41
|
+
*/
|
|
42
|
+
export declare function recordSandboxOwnerOnce(root: PreparedOutputRoot, write: () => Promise<boolean>): Promise<void>;
|
|
43
|
+
/** What the signal handler holds after stopping a directory's creates. */
|
|
44
|
+
export interface StoppedSandboxCreates {
|
|
45
|
+
/**
|
|
46
|
+
* Calls `listener` at once for every sandbox a create has reported so far, then for each one an
|
|
47
|
+
* in-flight create reports later, until the returned function is called.
|
|
48
|
+
*/
|
|
49
|
+
watch(listener: (sandbox: CreatedSandbox) => void): () => void;
|
|
50
|
+
/** Resolves once every create that was in flight at the stop has settled. */
|
|
51
|
+
settled: Promise<void>;
|
|
52
|
+
/** How many of those creates have not settled yet. */
|
|
53
|
+
inFlight(): number;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Stop `root`'s creates: any create that starts from now on is refused. Called synchronously when
|
|
57
|
+
* the signal arrives, before anything is awaited, so no create can slip in after it.
|
|
58
|
+
*/
|
|
59
|
+
export declare function stopSandboxCreates(root: PreparedOutputRoot): StoppedSandboxCreates;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// The sandboxes this process is creating for each run directory, and the E2B tags that name the
|
|
2
|
+
// directory. E2B creates a sandbox before its id reaches this process: on @e2b/desktop the startup
|
|
3
|
+
// guard reports the id, and its receipt is written, only once the SDK has the handle, and a
|
|
4
|
+
// create from an SDK without the guard reports it when `Sandbox.create` resolves, after desktop
|
|
5
|
+
// startup. The CLI's signal handler (src/cli/commands/run-signals.ts) uses this registry to cover
|
|
6
|
+
// the window before a receipt lands:
|
|
7
|
+
// it stops new creates, kills every id a create has reported, and kills each in-flight create's
|
|
8
|
+
// sandbox as soon as its id arrives. The tags let `humanish reclaim` find a sandbox whose id
|
|
9
|
+
// never reached this process at all.
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
/** Refuses a sandbox create for a run directory whose process is stopping. */
|
|
13
|
+
export class SandboxCreateRefusedError extends Error {
|
|
14
|
+
constructor() {
|
|
15
|
+
super("The run is stopping, so no further sandbox is created.");
|
|
16
|
+
this.name = "SandboxCreateRefusedError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const directories = new Map();
|
|
20
|
+
// A process runs one study at a time; the cap only bounds a long-lived library caller, and only
|
|
21
|
+
// idle entries count against it.
|
|
22
|
+
const MAX_REMEMBERED_DIRECTORIES = 64;
|
|
23
|
+
function identityOf(root) {
|
|
24
|
+
return "physicalRunRoot" in root
|
|
25
|
+
? { where: root.physicalRunRoot, identity: root.runRootIdentity }
|
|
26
|
+
: { where: root.physicalPath, identity: root.identity };
|
|
27
|
+
}
|
|
28
|
+
function directoryKey(root) {
|
|
29
|
+
const { where, identity } = identityOf(root);
|
|
30
|
+
return `${where}\0${identity.dev}:${identity.ino}:${identity.birthtimeNs}`;
|
|
31
|
+
}
|
|
32
|
+
function directoryFor(root) {
|
|
33
|
+
const key = directoryKey(root);
|
|
34
|
+
const existing = directories.get(key);
|
|
35
|
+
if (existing !== undefined)
|
|
36
|
+
return existing;
|
|
37
|
+
const created = {
|
|
38
|
+
stopping: false,
|
|
39
|
+
created: new Map(),
|
|
40
|
+
inFlight: new Set(),
|
|
41
|
+
listeners: new Set(),
|
|
42
|
+
};
|
|
43
|
+
directories.set(key, created);
|
|
44
|
+
// Only an idle entry is forgotten: a stopping one keeps refusing creates, and one with a create
|
|
45
|
+
// in flight or a sandbox its route has not released is still needed by the signal handler.
|
|
46
|
+
for (const [oldest, entry] of directories) {
|
|
47
|
+
if (directories.size <= MAX_REMEMBERED_DIRECTORIES)
|
|
48
|
+
break;
|
|
49
|
+
if (entry === created || !idle(entry))
|
|
50
|
+
continue;
|
|
51
|
+
directories.delete(oldest);
|
|
52
|
+
}
|
|
53
|
+
return created;
|
|
54
|
+
}
|
|
55
|
+
function idle(entry) {
|
|
56
|
+
return (!entry.stopping &&
|
|
57
|
+
entry.inFlight.size === 0 &&
|
|
58
|
+
[...entry.created.values()].every((sandbox) => sandbox.released));
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The E2B metadata that ties a sandbox to the directory that journals it. `runId` is the
|
|
62
|
+
* directory's name: the run id, or a preflight probe's id. `runKey` is a digest of the directory's
|
|
63
|
+
* inode and creation time, so two projects that both use `--run-id smoke` never match each other's
|
|
64
|
+
* sandboxes, and a renamed project directory still matches its own. `tool` is the owner tag every
|
|
65
|
+
* route already sets.
|
|
66
|
+
*/
|
|
67
|
+
export function sandboxOwnerTags(root) {
|
|
68
|
+
const { where, identity } = identityOf(root);
|
|
69
|
+
const runKey = createHash("sha256")
|
|
70
|
+
.update(`${identity.ino}:${identity.birthtimeNs}`)
|
|
71
|
+
.digest("hex")
|
|
72
|
+
.slice(0, 16);
|
|
73
|
+
return { tool: "humanish", runId: path.basename(where), runKey };
|
|
74
|
+
}
|
|
75
|
+
/** Register a create for `root`. Throws SandboxCreateRefusedError once the directory is stopping. */
|
|
76
|
+
export function beginSandboxCreate(root, participantId) {
|
|
77
|
+
const directory = directoryFor(root);
|
|
78
|
+
if (directory.stopping)
|
|
79
|
+
throw new SandboxCreateRefusedError();
|
|
80
|
+
let settle;
|
|
81
|
+
const done = new Promise((resolve) => {
|
|
82
|
+
settle = resolve;
|
|
83
|
+
});
|
|
84
|
+
directory.inFlight.add(done);
|
|
85
|
+
return {
|
|
86
|
+
stopping: () => directory.stopping,
|
|
87
|
+
created(sandboxId) {
|
|
88
|
+
if (directory.created.has(sandboxId))
|
|
89
|
+
return;
|
|
90
|
+
const sandbox = { sandboxId, participantId, released: false };
|
|
91
|
+
directory.created.set(sandboxId, sandbox);
|
|
92
|
+
for (const listener of directory.listeners)
|
|
93
|
+
listener(sandbox);
|
|
94
|
+
},
|
|
95
|
+
released(sandboxId) {
|
|
96
|
+
const sandbox = directory.created.get(sandboxId);
|
|
97
|
+
if (sandbox !== undefined)
|
|
98
|
+
sandbox.released = true;
|
|
99
|
+
},
|
|
100
|
+
settled() {
|
|
101
|
+
directory.inFlight.delete(done);
|
|
102
|
+
settle();
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Run `write` once per directory and return its promise to every caller. Creates that start
|
|
108
|
+
* together then resume in the order they called, so the order they reach the provider does not
|
|
109
|
+
* depend on which file append finishes first. A write that reports failure is forgotten, so the
|
|
110
|
+
* next create tries again.
|
|
111
|
+
*/
|
|
112
|
+
export function recordSandboxOwnerOnce(root, write) {
|
|
113
|
+
const directory = directoryFor(root);
|
|
114
|
+
const pending = (directory.ownerLine ??= write().then((written) => {
|
|
115
|
+
if (!written && directory.ownerLine === pending)
|
|
116
|
+
delete directory.ownerLine;
|
|
117
|
+
}));
|
|
118
|
+
return pending;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Stop `root`'s creates: any create that starts from now on is refused. Called synchronously when
|
|
122
|
+
* the signal arrives, before anything is awaited, so no create can slip in after it.
|
|
123
|
+
*/
|
|
124
|
+
export function stopSandboxCreates(root) {
|
|
125
|
+
const directory = directoryFor(root);
|
|
126
|
+
directory.stopping = true;
|
|
127
|
+
const pending = [...directory.inFlight];
|
|
128
|
+
return {
|
|
129
|
+
watch(listener) {
|
|
130
|
+
for (const sandbox of directory.created.values())
|
|
131
|
+
listener(sandbox);
|
|
132
|
+
directory.listeners.add(listener);
|
|
133
|
+
return () => {
|
|
134
|
+
directory.listeners.delete(listener);
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
settled: Promise.all(pending).then(() => undefined),
|
|
138
|
+
inFlight: () => pending.filter((create) => directory.inFlight.has(create)).length,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
@@ -23,11 +23,26 @@ export interface ParsedSandboxReceipt extends Omit<SandboxReceipt, "provider"> {
|
|
|
23
23
|
/** The ids this process has receipted for `root`, whether or not the journal write landed. */
|
|
24
24
|
export declare function appendedSandboxIds(root: PreparedOutputRoot): string[];
|
|
25
25
|
/**
|
|
26
|
-
* Append one receipt. Best-effort by design: the receipt exists to
|
|
27
|
-
* receipt write must never fail the participant. The only cost of a
|
|
28
|
-
* this id and the TTL backstop covers it instead. Containment is
|
|
29
|
-
* artifact write uses; append (not atomic-replace) keeps racing
|
|
26
|
+
* Append one receipt; false when the write failed. Best-effort by design: the receipt exists to
|
|
27
|
+
* protect the run, so a failed receipt write must never fail the participant. The only cost of a
|
|
28
|
+
* miss is that `reclaim` cannot see this id and the TTL backstop covers it instead. Containment is
|
|
29
|
+
* the same prepare step every artifact write uses; append (not atomic-replace) keeps racing
|
|
30
|
+
* participants' receipts intact.
|
|
30
31
|
*/
|
|
31
|
-
export declare function appendSandboxReceipt(root: PreparedOutputRoot, receipt: SandboxReceipt): Promise<
|
|
32
|
+
export declare function appendSandboxReceipt(root: PreparedOutputRoot, receipt: SandboxReceipt): Promise<boolean>;
|
|
33
|
+
/** Append one owner line; false when the write failed, which never fails the create. */
|
|
34
|
+
export declare function appendSandboxOwner(root: PreparedOutputRoot, owner: SandboxOwnerTags): Promise<boolean>;
|
|
35
|
+
/** The owner tags every sandbox carries; sandboxOwnerTags in src/run/sandbox-creates.ts. */
|
|
36
|
+
export type SandboxOwnerTags = {
|
|
37
|
+
tool: "humanish";
|
|
38
|
+
runId: string;
|
|
39
|
+
runKey: string;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* The owner tag sets a journal's owner lines record for the run named `runId`. Only a complete
|
|
43
|
+
* tuple counts: provider e2b, `tool: humanish`, this run's id and a well-formed key, and nothing
|
|
44
|
+
* else. A partial or foreign line would widen reclaim's search past this run, so it is skipped.
|
|
45
|
+
*/
|
|
46
|
+
export declare function parseSandboxOwners(text: string, runId: string): SandboxOwnerTags[];
|
|
32
47
|
/** Parse a receipts file leniently: a torn final line (crash mid-append) drops, valid lines keep. */
|
|
33
48
|
export declare function parseSandboxReceipts(text: string): ParsedSandboxReceipt[];
|