pi-daddy 0.22.0 → 0.23.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.
- package/CHANGELOG.md +30 -0
- package/contracts/daily-view/v1/README.md +114 -0
- package/contracts/daily-view/v1/fixtures/view.json +525 -0
- package/contracts/daily-view/v1/fixtures/view.txt +75 -0
- package/contracts/daily-view/v1/fixtures/work.jsonl +17 -0
- package/contracts/daily-view/v1/p03/README.md +26 -0
- package/contracts/daily-view/v1/p03/fixtures/retained-executions.json +241 -0
- package/contracts/daily-view/v1/p03/projection.schema.json +194 -0
- package/contracts/daily-view/v1/p03/provenance.json +21 -0
- package/contracts/dashboard-host/v1/README.md +176 -0
- package/contracts/debrief/v1/README.md +146 -0
- package/contracts/debrief/v1/intervention/README.md +32 -0
- package/contracts/debrief/v1/intervention/fixtures/artifact-3ee11136beb864f518252e5024dae9197421fbf97b6e981d3243c782b5c44bd0.txt +1 -0
- package/contracts/debrief/v1/intervention/fixtures/artifact-58905d872dd3c54a9705f67b05fa82edbad7af6476ae8993faf1a7b9fadfb905.txt +1 -0
- package/contracts/debrief/v1/intervention/fixtures/assessment.json +29 -0
- package/contracts/debrief/v1/intervention/fixtures/blind-view.json +20 -0
- package/contracts/debrief/v1/intervention/fixtures/choice.json +7 -0
- package/contracts/debrief/v1/intervention/fixtures/manifest.json +57 -0
- package/contracts/debrief/v1/intervention/fixtures/reveal.json +39 -0
- package/contracts/debrief/v1/provenance.json +89 -0
- package/contracts/debrief/v1/work-capture/README.md +38 -0
- package/contracts/debrief/v1/work-capture/fixtures/batch.json +17 -0
- package/contracts/debrief/v1/work-capture/fixtures/cases.json +184 -0
- package/contracts/debrief/v1/work-capture/fixtures/review.json +184 -0
- package/contracts/debrief/v1/work-capture/review-request.schema.json +41 -0
- package/contracts/debrief/v1/work-capture/work-case.schema.json +190 -0
- package/contracts/debrief/v2/README.md +71 -0
- package/contracts/debrief/v2/input-pin.json +66 -0
- package/contracts/debrief/v2/intervention-upstream.md +40 -0
- package/contracts/debrief/v2/work-signals-upstream.md +22 -0
- package/contracts/dispatch-control/v1/README.md +109 -0
- package/contracts/dispatch-control/v1/request.schema.json +19 -0
- package/contracts/effect-profile/v1/README.md +124 -0
- package/contracts/execution-retention/v1/README.md +109 -0
- package/contracts/execution-retention/v2/README.md +148 -0
- package/contracts/execution-retention/v2/fixtures/interrupted-native-bytes.json +91 -0
- package/contracts/execution-retention/v2/fixtures/missing-native-session.json +91 -0
- package/contracts/execution-retention/v2/fixtures/native-file-unknown-branch.json +91 -0
- package/contracts/execution-retention/v2/fixtures/native-id-without-bytes.json +91 -0
- package/contracts/execution-retention/v2/fixtures/native-live-branch.json +90 -0
- package/contracts/execution-retention/v2/fixtures/replaced-native-session.json +92 -0
- package/contracts/execution-retention/v2/fixtures/result-82c620cdfeb8ca6b858cf653d7f5e2eedca5315d6244216d60e8b36f03223d6a.bin +1 -0
- package/contracts/execution-retention/v2/fixtures/session-2ec1f134bc739984434f7fca689e1c3d1c39fce2dcadae709e9b6faedfcc4ac7.bin +3 -0
- package/contracts/execution-retention/v2/fixtures/session-9bdb4bd16a901da31580acf056bda525225259e15ec409e0410480109011772f.bin +3 -0
- package/contracts/execution-retention/v2/manifest.schema.json +858 -0
- package/contracts/experiment/v1/README.md +164 -0
- package/contracts/factory-order/v1/README.md +151 -0
- package/contracts/factory-order/v1/adoption-pin.json +8 -0
- package/contracts/intent-control/v1/README.md +135 -0
- package/contracts/intent-control/v1/request.schema.json +1096 -0
- package/contracts/intent-control/v2/README.md +34 -0
- package/contracts/intent-control/v2/request.schema.json +1070 -0
- package/contracts/ledger/v4/README.md +241 -0
- package/contracts/ledger/v4/fixtures/layout-options.json +428 -0
- package/contracts/ledger/v4/fixtures/work-acceptance.json +57 -0
- package/contracts/ledger/v4/fixtures/work-occurrence.json +44 -0
- package/contracts/ledger/v4/fixtures/work-revision.json +26 -0
- package/contracts/ledger/v4/fixtures/work-snapshot.json +73 -0
- package/contracts/ledger/v4/ledger-event.schema.json +162 -0
- package/contracts/ordinary-control/v1/README.md +59 -0
- package/contracts/producer-ipc/v1/README.md +137 -0
- package/dist/check-runner.d.ts +5 -0
- package/dist/check-runner.d.ts.map +1 -1
- package/dist/check-runner.js +16 -2
- package/dist/check-runner.js.map +1 -1
- package/dist/control-journal.d.ts +20 -0
- package/dist/control-journal.d.ts.map +1 -0
- package/dist/control-journal.js +73 -0
- package/dist/control-journal.js.map +1 -0
- package/dist/daily-view-input.d.ts +44 -0
- package/dist/daily-view-input.d.ts.map +1 -0
- package/dist/daily-view-input.js +68 -0
- package/dist/daily-view-input.js.map +1 -0
- package/dist/daily-view-render.d.ts +4 -0
- package/dist/daily-view-render.d.ts.map +1 -0
- package/dist/daily-view-render.js +41 -0
- package/dist/daily-view-render.js.map +1 -0
- package/dist/daily-view.d.ts +74 -0
- package/dist/daily-view.d.ts.map +1 -0
- package/dist/daily-view.js +170 -0
- package/dist/daily-view.js.map +1 -0
- package/dist/dashboard-cli.d.ts +26 -1
- package/dist/dashboard-cli.d.ts.map +1 -1
- package/dist/dashboard-cli.js +121 -3
- package/dist/dashboard-cli.js.map +1 -1
- package/dist/dashboard-harness.d.ts +19 -0
- package/dist/dashboard-harness.d.ts.map +1 -0
- package/dist/dashboard-harness.js +75 -0
- package/dist/dashboard-harness.js.map +1 -0
- package/dist/dashboard-herdr.d.ts.map +1 -1
- package/dist/dashboard-herdr.js +7 -2
- package/dist/dashboard-herdr.js.map +1 -1
- package/dist/dashboard-host-contract.d.ts +111 -0
- package/dist/dashboard-host-contract.d.ts.map +1 -0
- package/dist/dashboard-host-contract.js +14 -0
- package/dist/dashboard-host-contract.js.map +1 -0
- package/dist/dashboard-host-transport.d.ts +15 -0
- package/dist/dashboard-host-transport.d.ts.map +1 -0
- package/dist/dashboard-host-transport.js +122 -0
- package/dist/dashboard-host-transport.js.map +1 -0
- package/dist/dashboard-host.d.ts +149 -0
- package/dist/dashboard-host.d.ts.map +1 -0
- package/dist/dashboard-host.js +329 -0
- package/dist/dashboard-host.js.map +1 -0
- package/dist/dashboard-observation.d.ts +28 -0
- package/dist/dashboard-observation.d.ts.map +1 -0
- package/dist/dashboard-observation.js +86 -0
- package/dist/dashboard-observation.js.map +1 -0
- package/dist/debrief-contract.d.ts +65 -0
- package/dist/debrief-contract.d.ts.map +1 -0
- package/dist/debrief-contract.js +151 -0
- package/dist/debrief-contract.js.map +1 -0
- package/dist/debrief-fixture.d.ts +16 -0
- package/dist/debrief-fixture.d.ts.map +1 -0
- package/dist/debrief-fixture.js +41 -0
- package/dist/debrief-fixture.js.map +1 -0
- package/dist/debrief-host.d.ts +59 -0
- package/dist/debrief-host.d.ts.map +1 -0
- package/dist/debrief-host.js +49 -0
- package/dist/debrief-host.js.map +1 -0
- package/dist/debrief-render.d.ts +5 -0
- package/dist/debrief-render.d.ts.map +1 -0
- package/dist/debrief-render.js +65 -0
- package/dist/debrief-render.js.map +1 -0
- package/dist/debrief.d.ts +90 -0
- package/dist/debrief.d.ts.map +1 -0
- package/dist/debrief.js +263 -0
- package/dist/debrief.js.map +1 -0
- package/dist/delegate-types.d.ts +2 -0
- package/dist/delegate-types.d.ts.map +1 -1
- package/dist/delegate-types.js.map +1 -1
- package/dist/delegate.d.ts.map +1 -1
- package/dist/delegate.js +1 -0
- package/dist/delegate.js.map +1 -1
- package/dist/dispatch-control.d.ts +45 -0
- package/dist/dispatch-control.d.ts.map +1 -0
- package/dist/dispatch-control.js +76 -0
- package/dist/dispatch-control.js.map +1 -0
- package/dist/effect-profile-runtime.d.ts +40 -0
- package/dist/effect-profile-runtime.d.ts.map +1 -0
- package/dist/effect-profile-runtime.js +51 -0
- package/dist/effect-profile-runtime.js.map +1 -0
- package/dist/effect-profile.d.ts +40 -0
- package/dist/effect-profile.d.ts.map +1 -0
- package/dist/effect-profile.js +152 -0
- package/dist/effect-profile.js.map +1 -0
- package/dist/execution-retention.d.ts +90 -0
- package/dist/execution-retention.d.ts.map +1 -0
- package/dist/execution-retention.js +247 -0
- package/dist/execution-retention.js.map +1 -0
- package/dist/experiment-contract.d.ts +56 -0
- package/dist/experiment-contract.d.ts.map +1 -0
- package/dist/experiment-contract.js +80 -0
- package/dist/experiment-contract.js.map +1 -0
- package/dist/experiment-state.d.ts +22 -0
- package/dist/experiment-state.d.ts.map +1 -0
- package/dist/experiment-state.js +79 -0
- package/dist/experiment-state.js.map +1 -0
- package/dist/experiment-store.d.ts +28 -0
- package/dist/experiment-store.d.ts.map +1 -0
- package/dist/experiment-store.js +130 -0
- package/dist/experiment-store.js.map +1 -0
- package/dist/experiment.d.ts +65 -0
- package/dist/experiment.d.ts.map +1 -0
- package/dist/experiment.js +357 -0
- package/dist/experiment.js.map +1 -0
- package/dist/factory-contract.d.ts +79 -0
- package/dist/factory-contract.d.ts.map +1 -0
- package/dist/factory-contract.js +89 -0
- package/dist/factory-contract.js.map +1 -0
- package/dist/factory-migration.d.ts +20 -0
- package/dist/factory-migration.d.ts.map +1 -0
- package/dist/factory-migration.js +55 -0
- package/dist/factory-migration.js.map +1 -0
- package/dist/factory-order.d.ts +848 -0
- package/dist/factory-order.d.ts.map +1 -0
- package/dist/factory-order.js +83 -0
- package/dist/factory-order.js.map +1 -0
- package/dist/factory-registry.d.ts +121 -0
- package/dist/factory-registry.d.ts.map +1 -0
- package/dist/factory-registry.js +133 -0
- package/dist/factory-registry.js.map +1 -0
- package/dist/file-lock.d.ts +8 -2
- package/dist/file-lock.d.ts.map +1 -1
- package/dist/file-lock.js +78 -19
- package/dist/file-lock.js.map +1 -1
- package/dist/herdr-poll.d.ts +8 -0
- package/dist/herdr-poll.d.ts.map +1 -1
- package/dist/herdr-poll.js +23 -0
- package/dist/herdr-poll.js.map +1 -1
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -1
- package/dist/intent-application.d.ts +17 -0
- package/dist/intent-application.d.ts.map +1 -0
- package/dist/intent-application.js +113 -0
- package/dist/intent-application.js.map +1 -0
- package/dist/intent-control.d.ts +201 -0
- package/dist/intent-control.d.ts.map +1 -0
- package/dist/intent-control.js +110 -0
- package/dist/intent-control.js.map +1 -0
- package/dist/intent-scheduling.d.ts +9 -0
- package/dist/intent-scheduling.d.ts.map +1 -0
- package/dist/intent-scheduling.js +15 -0
- package/dist/intent-scheduling.js.map +1 -0
- package/dist/ledger-append.d.ts +15 -0
- package/dist/ledger-append.d.ts.map +1 -0
- package/dist/ledger-append.js +94 -0
- package/dist/ledger-append.js.map +1 -0
- package/dist/ledger.d.ts +11 -0
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +3 -7
- package/dist/ledger.js.map +1 -1
- package/dist/native-session-target.d.ts +9 -0
- package/dist/native-session-target.d.ts.map +1 -0
- package/dist/native-session-target.js +29 -0
- package/dist/native-session-target.js.map +1 -0
- package/dist/native-session.d.ts +59 -0
- package/dist/native-session.d.ts.map +1 -0
- package/dist/native-session.js +154 -0
- package/dist/native-session.js.map +1 -0
- package/dist/order-schedule.d.ts +37 -0
- package/dist/order-schedule.d.ts.map +1 -0
- package/dist/order-schedule.js +81 -0
- package/dist/order-schedule.js.map +1 -0
- package/dist/ordinary-children.d.ts +52 -0
- package/dist/ordinary-children.d.ts.map +1 -0
- package/dist/ordinary-children.js +93 -0
- package/dist/ordinary-children.js.map +1 -0
- package/dist/producer-ipc-contract.d.ts +29 -0
- package/dist/producer-ipc-contract.d.ts.map +1 -0
- package/dist/producer-ipc-contract.js +29 -0
- package/dist/producer-ipc-contract.js.map +1 -0
- package/dist/producer-ipc.d.ts +55 -0
- package/dist/producer-ipc.d.ts.map +1 -0
- package/dist/producer-ipc.js +143 -0
- package/dist/producer-ipc.js.map +1 -0
- package/dist/resource-budget.d.ts +334 -0
- package/dist/resource-budget.d.ts.map +1 -0
- package/dist/resource-budget.js +415 -0
- package/dist/resource-budget.js.map +1 -0
- package/dist/retention-contract.d.ts +73 -0
- package/dist/retention-contract.d.ts.map +1 -0
- package/dist/retention-contract.js +104 -0
- package/dist/retention-contract.js.map +1 -0
- package/dist/retention-json.d.ts +3 -0
- package/dist/retention-json.d.ts.map +1 -0
- package/dist/retention-json.js +32 -0
- package/dist/retention-json.js.map +1 -0
- package/dist/run-child.d.ts +4 -0
- package/dist/run-child.d.ts.map +1 -1
- package/dist/run-child.js +16 -2
- package/dist/run-child.js.map +1 -1
- package/dist/run-herdr.d.ts +6 -0
- package/dist/run-herdr.d.ts.map +1 -1
- package/dist/run-herdr.js +19 -6
- package/dist/run-herdr.js.map +1 -1
- package/dist/vendor/adoption.d.ts +129 -0
- package/dist/vendor/adoption.d.ts.map +1 -0
- package/dist/vendor/adoption.js +102 -0
- package/dist/vendor/adoption.js.map +1 -0
- package/dist/work-ledger-destination.d.ts +20 -0
- package/dist/work-ledger-destination.d.ts.map +1 -0
- package/dist/work-ledger-destination.js +131 -0
- package/dist/work-ledger-destination.js.map +1 -0
- package/dist/work-ledger-json.d.ts +25 -0
- package/dist/work-ledger-json.d.ts.map +1 -0
- package/dist/work-ledger-json.js +267 -0
- package/dist/work-ledger-json.js.map +1 -0
- package/dist/work-ledger-occurrences.d.ts +227 -0
- package/dist/work-ledger-occurrences.d.ts.map +1 -0
- package/dist/work-ledger-occurrences.js +115 -0
- package/dist/work-ledger-occurrences.js.map +1 -0
- package/dist/work-ledger-projection.d.ts +295 -0
- package/dist/work-ledger-projection.d.ts.map +1 -0
- package/dist/work-ledger-projection.js +237 -0
- package/dist/work-ledger-projection.js.map +1 -0
- package/dist/work-ledger-snapshot.d.ts +29 -0
- package/dist/work-ledger-snapshot.d.ts.map +1 -0
- package/dist/work-ledger-snapshot.js +267 -0
- package/dist/work-ledger-snapshot.js.map +1 -0
- package/dist/work-ledger-types.d.ts +261 -0
- package/dist/work-ledger-types.d.ts.map +1 -0
- package/dist/work-ledger-types.js +13 -0
- package/dist/work-ledger-types.js.map +1 -0
- package/dist/work-ledger-validation.d.ts +14 -0
- package/dist/work-ledger-validation.d.ts.map +1 -0
- package/dist/work-ledger-validation.js +226 -0
- package/dist/work-ledger-validation.js.map +1 -0
- package/dist/work-ledger.d.ts +43 -0
- package/dist/work-ledger.d.ts.map +1 -0
- package/dist/work-ledger.js +225 -0
- package/dist/work-ledger.js.map +1 -0
- package/extensions/delegate-chain.ts +1 -0
- package/extensions/delegation-native.ts +12 -0
- package/extensions/delegation.ts +4 -3
- package/extensions/execute-child.ts +34 -4
- package/extensions/grants.ts +2 -0
- package/extensions/ordinary-runtime.ts +14 -0
- package/extensions/run-delegation.ts +7 -7
- package/extensions/session.ts +3 -1
- package/package.json +78 -4
- package/src/check-runner.ts +20 -2
- package/src/control-journal.ts +26 -0
- package/src/daily-view-input.ts +59 -0
- package/src/daily-view-render.ts +44 -0
- package/src/daily-view.ts +152 -0
- package/src/dashboard-cli.ts +103 -4
- package/src/dashboard-harness.ts +28 -0
- package/src/dashboard-herdr.ts +7 -2
- package/src/dashboard-host-contract.ts +42 -0
- package/src/dashboard-host-transport.ts +38 -0
- package/src/dashboard-host.ts +168 -0
- package/src/dashboard-observation.ts +44 -0
- package/src/debrief-contract.ts +127 -0
- package/src/debrief-fixture.ts +29 -0
- package/src/debrief-host.ts +53 -0
- package/src/debrief-render.ts +49 -0
- package/src/debrief.ts +198 -0
- package/src/delegate-types.ts +2 -0
- package/src/delegate.ts +1 -0
- package/src/dispatch-control.ts +81 -0
- package/src/effect-profile-runtime.ts +46 -0
- package/src/effect-profile.ts +129 -0
- package/src/execution-retention.ts +230 -0
- package/src/experiment-contract.ts +66 -0
- package/src/experiment-state.ts +44 -0
- package/src/experiment-store.ts +90 -0
- package/src/experiment.ts +231 -0
- package/src/factory-contract.ts +47 -0
- package/src/factory-migration.ts +29 -0
- package/src/factory-order.ts +56 -0
- package/src/factory-registry.ts +60 -0
- package/src/file-lock.ts +47 -11
- package/src/herdr-poll.ts +18 -0
- package/src/index.ts +32 -0
- package/src/intent-application.ts +90 -0
- package/src/intent-control.ts +107 -0
- package/src/intent-scheduling.ts +14 -0
- package/src/ledger-append.ts +72 -0
- package/src/ledger.ts +3 -9
- package/src/native-session-target.ts +23 -0
- package/src/native-session.ts +139 -0
- package/src/order-schedule.ts +42 -0
- package/src/ordinary-children.ts +59 -0
- package/src/producer-ipc-contract.ts +26 -0
- package/src/producer-ipc.ts +107 -0
- package/src/resource-budget.ts +349 -0
- package/src/retention-contract.ts +84 -0
- package/src/retention-json.ts +20 -0
- package/src/run-child.ts +13 -2
- package/src/run-herdr.ts +11 -7
- package/src/vendor/adoption.ts +109 -0
- package/src/work-ledger-destination.ts +113 -0
- package/src/work-ledger-json.ts +228 -0
- package/src/work-ledger-occurrences.ts +99 -0
- package/src/work-ledger-projection.ts +262 -0
- package/src/work-ledger-snapshot.ts +246 -0
- package/src/work-ledger-types.ts +229 -0
- package/src/work-ledger-validation.ts +174 -0
- package/src/work-ledger.ts +186 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, writeFile, rename } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join } from "node:path";
|
|
4
|
+
import { buildExecutionRetentionManifest } from "./retention-contract.ts";
|
|
5
|
+
import { missingNativeSession, readNativeSession, type NativeSessionObservation, type NativeSessionManager, type NativeSessionCapture } from "./native-session.ts";
|
|
6
|
+
export { buildExecutionRetentionManifest, parseExecutionRetentionManifest, RETENTION_SCHEMA } from "./retention-contract.ts";
|
|
7
|
+
export { ENV_NATIVE_SESSION_ROOT, readNativeSession, parseNativeSessionBytes, type NativeSessionObservation, type NativeSessionManager } from "./native-session.ts";
|
|
8
|
+
|
|
9
|
+
/** Operator-owned archive boundary; never a model-facing parameter or an authority source. */
|
|
10
|
+
export const ENV_EXECUTION_ARCHIVE = "PI_GRANTS_EXECUTION_ARCHIVE";
|
|
11
|
+
export const RETENTION_VERSION = "2.0";
|
|
12
|
+
const LIMIT = 1024 * 1024;
|
|
13
|
+
const MAX_ACTIVE = 32;
|
|
14
|
+
let active = 0;
|
|
15
|
+
const digest = (bytes: Uint8Array | string) => createHash("sha256").update(bytes).digest("hex");
|
|
16
|
+
export interface RetentionIdentity {
|
|
17
|
+
executionId: string;
|
|
18
|
+
parentExecutionId: string | null;
|
|
19
|
+
childId: string;
|
|
20
|
+
toolCallId: string | null;
|
|
21
|
+
executor: "process" | "herdr" | "check";
|
|
22
|
+
taskDigest: string | null;
|
|
23
|
+
definitionDigest: string | null;
|
|
24
|
+
configurationDigest: string;
|
|
25
|
+
workspaceId: string | null;
|
|
26
|
+
}
|
|
27
|
+
export interface RetainedContent {
|
|
28
|
+
status: "retained" | "missing";
|
|
29
|
+
path: string | null;
|
|
30
|
+
sha256: string | null;
|
|
31
|
+
bytes: number | null;
|
|
32
|
+
}
|
|
33
|
+
export interface ExecutionRetentionManifest {
|
|
34
|
+
version: "2.0";
|
|
35
|
+
archiveId: string;
|
|
36
|
+
identity: RetentionIdentity;
|
|
37
|
+
native: { pid: number | null; paneId: string | null; agentName: string | null; tabId: string | null;
|
|
38
|
+
sessionId: string | null; sessionPath: string | null; branchLeafId: string | null };
|
|
39
|
+
nativeSession: NativeSessionObservation;
|
|
40
|
+
state: "running" | "terminal";
|
|
41
|
+
outcome: { code: number | null; signal: string | null; timedOut: boolean; aborted: boolean;
|
|
42
|
+
truncated: boolean; failed: boolean } | null;
|
|
43
|
+
content: Record<"stdout" | "stderr" | "paneSnapshot" | "checkReceipt" | "result" | "session", RetainedContent>;
|
|
44
|
+
coverage: { complete: false; losses: string[] };
|
|
45
|
+
/** Retention is observation, never a work-v4 acceptance decision. */
|
|
46
|
+
acceptance: "not-assessed";
|
|
47
|
+
}
|
|
48
|
+
export interface RetentionStatus { manifestPath: string | null; status: "disabled" | "pending" | "retained" | "lost" }
|
|
49
|
+
const drains = new WeakMap<RetentionStatus, () => Promise<RetentionStatus>>();
|
|
50
|
+
/** Original live status only; drains admitted observations, not producer completeness or fsync. */
|
|
51
|
+
export function drainExecutionRetention(status: RetentionStatus): Promise<RetentionStatus> {
|
|
52
|
+
const drain = drains.get(status);
|
|
53
|
+
if (!drain) return Promise.reject(new TypeError("original live retention status required"));
|
|
54
|
+
return drain();
|
|
55
|
+
}
|
|
56
|
+
const missing = (): RetainedContent => ({ status: "missing", path: null, sha256: null, bytes: null });
|
|
57
|
+
|
|
58
|
+
/** Hash only an explicitly selected configuration. Callers must not pass environment/auth objects. */
|
|
59
|
+
export const retentionConfigurationDigest = (configuration: unknown): string => digest(JSON.stringify(configuration));
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A bounded, coalescing observation queue. No worker-control path awaits publication. A failed or stalled
|
|
63
|
+
* archive never changes required ledger/lease semantics. `flush` is exclusively for external archival at
|
|
64
|
+
* quiescence; it is NOT called by the spawner. Missing terminal publication means incomplete observation.
|
|
65
|
+
*/
|
|
66
|
+
export function beginExecutionRetention(identity: RetentionIdentity, directory = process.env[ENV_EXECUTION_ARCHIVE]) {
|
|
67
|
+
const archiveId = randomUUID();
|
|
68
|
+
const valid = Boolean(directory && isAbsolute(directory) && !directory.split(/[\\/]/).includes(".pi"));
|
|
69
|
+
const admitted = valid && active < MAX_ACTIVE;
|
|
70
|
+
const root = admitted ? join(directory!, archiveId) : null;
|
|
71
|
+
const manifestPath = root ? join(root, "manifest.json") : null;
|
|
72
|
+
let status: RetentionStatus["status"] = !directory ? "disabled" : admitted ? "pending" : "lost";
|
|
73
|
+
const manifest: ExecutionRetentionManifest = {
|
|
74
|
+
version: RETENTION_VERSION, archiveId, identity: {
|
|
75
|
+
executionId: identity.executionId, parentExecutionId: identity.parentExecutionId, childId: identity.childId,
|
|
76
|
+
toolCallId: identity.toolCallId, executor: identity.executor, taskDigest: identity.taskDigest,
|
|
77
|
+
definitionDigest: identity.definitionDigest, configurationDigest: identity.configurationDigest, workspaceId: identity.workspaceId,
|
|
78
|
+
},
|
|
79
|
+
native: { pid: null, paneId: null, agentName: null, tabId: null, sessionId: null, sessionPath: null, branchLeafId: null },
|
|
80
|
+
nativeSession: missingNativeSession(),
|
|
81
|
+
state: "running", outcome: null,
|
|
82
|
+
content: { stdout: missing(), stderr: missing(), paneSnapshot: missing(), checkReceipt: missing(), result: missing(), session: missing() },
|
|
83
|
+
coverage: { complete: false, losses: ["native-session-content-unavailable", "active-branch-unknown"] },
|
|
84
|
+
acceptance: "not-assessed",
|
|
85
|
+
};
|
|
86
|
+
const buffers = new Map<keyof typeof manifest.content, Buffer>();
|
|
87
|
+
const written = new Set<string>();
|
|
88
|
+
let total = 0;
|
|
89
|
+
let dirty = false;
|
|
90
|
+
let running: Promise<void> | undefined;
|
|
91
|
+
let finished = false;
|
|
92
|
+
let released = false;
|
|
93
|
+
let nativeTask: Promise<void> | undefined;
|
|
94
|
+
let nextNative: { source: "herdr-id" | "herdr-path" | "pi-session-file" | "pi-session-manager"; value: string; manager?: NativeSessionManager } | undefined;
|
|
95
|
+
let pinnedSessionId: string | undefined;
|
|
96
|
+
let pinnedFile: NativeSessionCapture["fileIdentity"];
|
|
97
|
+
const loss = (reason: string) => { if (!manifest.coverage.losses.includes(reason)) manifest.coverage.losses.push(reason); };
|
|
98
|
+
const release = () => { if (admitted && !released) { active--; released = true; } };
|
|
99
|
+
if (admitted) active++;
|
|
100
|
+
else if (directory) loss("archive-unavailable-or-capacity-exceeded");
|
|
101
|
+
const publish = async () => {
|
|
102
|
+
try {
|
|
103
|
+
await mkdir(root!, { recursive: true, mode: 0o700 });
|
|
104
|
+
while (dirty) {
|
|
105
|
+
dirty = false;
|
|
106
|
+
// Detached checkpoint: asynchronous I/O cannot mix later outcome/content into this generation.
|
|
107
|
+
const checkpoint: ExecutionRetentionManifest = JSON.parse(JSON.stringify(manifest));
|
|
108
|
+
const contents = [...buffers];
|
|
109
|
+
for (const [kind, bytes] of contents) {
|
|
110
|
+
const sha256 = digest(bytes);
|
|
111
|
+
const name = `${kind}-${sha256}.bin`;
|
|
112
|
+
if (!written.has(name)) {
|
|
113
|
+
await writeFile(join(root!, name), bytes, { flag: "wx", mode: 0o600 });
|
|
114
|
+
written.add(name);
|
|
115
|
+
}
|
|
116
|
+
checkpoint.content[kind] = { status: "retained", path: name, sha256, bytes: bytes.length };
|
|
117
|
+
}
|
|
118
|
+
await writeFile(join(root!, "manifest.pending"), JSON.stringify(buildExecutionRetentionManifest(checkpoint)) + "\n", { mode: 0o600 });
|
|
119
|
+
await rename(join(root!, "manifest.pending"), manifestPath!);
|
|
120
|
+
status = "retained";
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
status = "lost";
|
|
124
|
+
loss("archive-write-failed");
|
|
125
|
+
buffers.clear();
|
|
126
|
+
release();
|
|
127
|
+
} finally {
|
|
128
|
+
running = undefined;
|
|
129
|
+
if (finished && !nativeTask) { buffers.clear(); release(); }
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const schedule = () => {
|
|
133
|
+
if (!admitted || status === "lost" || released) return;
|
|
134
|
+
dirty = true;
|
|
135
|
+
status = "pending";
|
|
136
|
+
if (!running) running = Promise.resolve().then(publish);
|
|
137
|
+
};
|
|
138
|
+
const observeSession = (reference: NonNullable<typeof nextNative>) => {
|
|
139
|
+
if (finished || !admitted || released || status === "lost") return;
|
|
140
|
+
if (nextNative && (nextNative.source !== reference.source || nextNative.value !== reference.value)) loss("native-session-reference-coalesced");
|
|
141
|
+
nextNative = { source: reference.source, value: reference.value, ...(reference.manager ? { manager: reference.manager } : {}) };
|
|
142
|
+
if (nativeTask) return;
|
|
143
|
+
nativeTask = Promise.resolve().then(async () => {
|
|
144
|
+
while (nextNative) {
|
|
145
|
+
const current = nextNative; nextNative = undefined;
|
|
146
|
+
const captured: NativeSessionCapture = current.source === "herdr-id"
|
|
147
|
+
? { observation: { ...missingNativeSession(), source: current.source, status: "missing" as const,
|
|
148
|
+
sessionId: current.value, reason: "native-session-path-unavailable" } }
|
|
149
|
+
: await readNativeSession({ path: current.value, source: current.source, manager: current.manager, expectedSessionId: pinnedSessionId, expectedFile: pinnedFile });
|
|
150
|
+
if (captured.observation.sessionId && captured.fileIdentity) pinnedFile ??= captured.fileIdentity;
|
|
151
|
+
let observation = captured.observation;
|
|
152
|
+
if (pinnedSessionId && observation.sessionId && pinnedSessionId !== observation.sessionId) {
|
|
153
|
+
observation = { ...missingNativeSession(), source: current.source, status: "changed", reason: "native-session-id-changed" };
|
|
154
|
+
}
|
|
155
|
+
if (observation.sessionId) pinnedSessionId ??= observation.sessionId;
|
|
156
|
+
manifest.nativeSession = { ...observation };
|
|
157
|
+
manifest.native.sessionId = observation.sessionId;
|
|
158
|
+
manifest.native.sessionPath = observation.sessionPath;
|
|
159
|
+
manifest.native.branchLeafId = observation.branchLeafId;
|
|
160
|
+
buffers.delete("session");
|
|
161
|
+
if (captured.bytes && observation === captured.observation) buffers.set("session", captured.bytes);
|
|
162
|
+
if (observation.reason) loss(observation.reason);
|
|
163
|
+
schedule();
|
|
164
|
+
}
|
|
165
|
+
}).catch(() => { loss("native-session-observation-failed"); }).finally(() => { nativeTask = undefined; schedule(); });
|
|
166
|
+
};
|
|
167
|
+
const drain = async (): Promise<RetentionStatus> => {
|
|
168
|
+
while (nativeTask || running) { if (nativeTask) await nativeTask; if (running) await running; }
|
|
169
|
+
return { manifestPath, status };
|
|
170
|
+
};
|
|
171
|
+
schedule();
|
|
172
|
+
return {
|
|
173
|
+
/** Read-only native API reference; never derive IDs from the locator or a displayed name. */
|
|
174
|
+
observeSession,
|
|
175
|
+
observeSessionManager(manager: NativeSessionManager) {
|
|
176
|
+
try {
|
|
177
|
+
const value = manager.getSessionFile();
|
|
178
|
+
if (value) observeSession({ source: "pi-session-manager", value, manager });
|
|
179
|
+
else { loss("native-session-not-persisted"); schedule(); }
|
|
180
|
+
} catch { loss("native-session-manager-unavailable"); schedule(); }
|
|
181
|
+
},
|
|
182
|
+
status: (): RetentionStatus => { const value = { manifestPath, status }; drains.set(value, drain); return value; },
|
|
183
|
+
native(value: Partial<Pick<ExecutionRetentionManifest["native"], "pid" | "paneId" | "agentName" | "tabId">>) {
|
|
184
|
+
if (finished) return;
|
|
185
|
+
for (const key of ["pid", "paneId", "agentName", "tabId"] as const) {
|
|
186
|
+
const item = value[key];
|
|
187
|
+
if (key === "pid") { if (typeof item === "number" && Number.isSafeInteger(item) && item > 0) manifest.native.pid = item; }
|
|
188
|
+
else if (typeof item === "string" && item.length <= 4096) manifest.native[key] = item;
|
|
189
|
+
}
|
|
190
|
+
schedule();
|
|
191
|
+
},
|
|
192
|
+
capture(kind: "stdout" | "stderr" | "paneSnapshot" | "checkReceipt" | "result", bytes: Uint8Array, replace = false) {
|
|
193
|
+
if (finished || !admitted || status === "lost") return;
|
|
194
|
+
const indivisible = kind === "checkReceipt" || kind === "result";
|
|
195
|
+
if (indivisible) replace = true;
|
|
196
|
+
const old = buffers.get(kind) ?? Buffer.alloc(0);
|
|
197
|
+
const available = indivisible ? LIMIT : LIMIT - total + (replace ? old.length : 0);
|
|
198
|
+
// Receipts and assembled results each have reserved capacity and are never partially published.
|
|
199
|
+
if (indivisible && bytes.length > available) { loss(`${kind === "checkReceipt" ? "check-receipt" : kind}-not-retained-capacity`); schedule(); return; }
|
|
200
|
+
const kept = Buffer.from(bytes.subarray(0, Math.max(0, available)));
|
|
201
|
+
if (kept.length !== bytes.length) loss(`${kind}-observation-dropped`);
|
|
202
|
+
if (kind === "paneSnapshot") loss("pane-snapshot-not-full-transcript");
|
|
203
|
+
const next = replace ? kept : Buffer.concat([old, kept]);
|
|
204
|
+
if (!indivisible) total += next.length - old.length;
|
|
205
|
+
buffers.set(kind, next);
|
|
206
|
+
schedule();
|
|
207
|
+
},
|
|
208
|
+
finish(outcome: NonNullable<ExecutionRetentionManifest["outcome"]>) {
|
|
209
|
+
if (finished) return;
|
|
210
|
+
manifest.outcome = { code: outcome.code, signal: outcome.signal, timedOut: outcome.timedOut,
|
|
211
|
+
aborted: outcome.aborted, truncated: outcome.truncated, failed: outcome.failed };
|
|
212
|
+
manifest.state = "terminal";
|
|
213
|
+
if (outcome.truncated) loss("executor-output-truncated");
|
|
214
|
+
if (outcome.aborted || outcome.timedOut) loss("execution-interrupted");
|
|
215
|
+
finished = true;
|
|
216
|
+
schedule();
|
|
217
|
+
},
|
|
218
|
+
/** Observation-only diagnostic; never establishes successful execution or accepted work. */
|
|
219
|
+
coverage: () => ({ complete: false as const, losses: [...manifest.coverage.losses] }),
|
|
220
|
+
flush: drain,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
export type ExecutionRetention = ReturnType<typeof beginExecutionRetention>;
|
|
224
|
+
|
|
225
|
+
/** Public consumer check: neither a reference nor a wire status supplies the missing bytes. */
|
|
226
|
+
export function verifyRetainedBytes(reference: RetainedContent, bytes?: Uint8Array): "retained" | "missing" | "mismatch" {
|
|
227
|
+
if (reference.status !== "retained" || bytes === undefined) return "missing";
|
|
228
|
+
if (reference.bytes !== bytes.byteLength || reference.sha256 !== digest(bytes)) return "mismatch";
|
|
229
|
+
return "retained";
|
|
230
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { WorkFrozen } from "./work-ledger-types.ts";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { intentKey, type WorkIntentBinding } from "./intent-control.ts";
|
|
4
|
+
import { workIntentBinding } from "./intent-application.ts";
|
|
5
|
+
import { freezeWork, parseWorkJson } from "./work-ledger-json.ts";
|
|
6
|
+
import { orderSchedule, type OrderSchedule } from "./order-schedule.ts";
|
|
7
|
+
import { DIGEST_PROFILE } from "./effect-profile.ts";
|
|
8
|
+
export const experimentHash = (value: unknown) => createHash("sha256").update(intentKey(value)).digest("hex");
|
|
9
|
+
export const byteHash = (bytes: Uint8Array | string) => createHash("sha256").update(bytes).digest("hex");
|
|
10
|
+
export const cloneExperiment = <T>(value: T): T => JSON.parse(intentKey(value));
|
|
11
|
+
export function closed(value: unknown, keys: string[]): asserts value is Record<string, unknown> {
|
|
12
|
+
if (!value || typeof value !== "object" || Object.keys(value).sort().join() !== keys.sort().join()) throw new TypeError("closed experiment data required");
|
|
13
|
+
}
|
|
14
|
+
const id = (x: unknown) => typeof x === "string" && /^[a-zA-Z0-9:_-]{1,128}$/.test(x);
|
|
15
|
+
const hex = (x: unknown) => typeof x === "string" && /^[a-f0-9]{64}$/.test(x);
|
|
16
|
+
export interface ExperimentVariant {
|
|
17
|
+
variantId: string; executionId: string; kind: "primary" | "shadow" | "retry";
|
|
18
|
+
parentExecutionId: string | null; suffixBase64: string; operation: "digest" | "hold";
|
|
19
|
+
configuration: { model: null; effort: null; skills: null };
|
|
20
|
+
}
|
|
21
|
+
export interface ExperimentCharter {
|
|
22
|
+
version: "fixed-experiment-v1" | "fixed-experiment-v2"; order?: OrderSchedule; experimentId: string; orderId: string; budgetDigest: string;
|
|
23
|
+
profile: typeof DIGEST_PROFILE; common: { sha256: string; bytes: number; work: WorkFrozen<WorkIntentBinding> | null; workTextDigest: string | null };
|
|
24
|
+
mode: "bounded-waves" | "concurrent-shadow"; deadlineMs: number; variants: readonly ExperimentVariant[];
|
|
25
|
+
}
|
|
26
|
+
export function experimentCharter(input: ExperimentCharter): ExperimentCharter {
|
|
27
|
+
const c = cloneExperiment(input); closed(c, ["version", "experimentId", "orderId", "budgetDigest", "profile", "common", "mode", "deadlineMs", "variants", ...(c.version === "fixed-experiment-v2" ? ["order"] : [])]);
|
|
28
|
+
if (!["fixed-experiment-v1", "fixed-experiment-v2"].includes(c.version) || !id(c.experimentId) || !id(c.orderId) || !hex(c.budgetDigest) || c.profile !== DIGEST_PROFILE || !["bounded-waves", "concurrent-shadow"].includes(c.mode) || !Number.isSafeInteger(c.deadlineMs) || c.deadlineMs < 1000 || c.deadlineMs > 30000 || Buffer.byteLength(intentKey(c)) > 48000) throw new TypeError("unsupported experiment charter/profile");
|
|
29
|
+
closed(c.common, ["sha256", "bytes", "work", "workTextDigest"]);
|
|
30
|
+
if (!hex(c.common.sha256) || !Number.isSafeInteger(c.common.bytes) || c.common.bytes < 0 || c.common.bytes > 16384) throw new TypeError("bounded common input required");
|
|
31
|
+
if (c.common.work !== null) { c.common.work = workIntentBinding(JSON.parse(intentKey(c.common.work))); if (!hex(c.common.workTextDigest)) throw new TypeError("exact work bytes required"); }
|
|
32
|
+
else if (c.common.workTextDigest !== null) throw new TypeError("unexpected work digest");
|
|
33
|
+
if (!Array.isArray(c.variants) || c.variants.length < (c.version === "fixed-experiment-v2" ? 1 : 2) || c.variants.length > 32) throw new TypeError("2..32 explicit attempts required");
|
|
34
|
+
const ids = new Set<string>(), variants = new Set<string>();
|
|
35
|
+
for (const [i, v] of (c.variants as readonly ExperimentVariant[]).entries()) {
|
|
36
|
+
closed(v, ["variantId", "executionId", "kind", "parentExecutionId", "suffixBase64", "operation", "configuration"]);
|
|
37
|
+
closed(v.configuration, ["model", "effort", "skills"]);
|
|
38
|
+
if (!id(v.variantId) || !id(v.executionId) || ids.has(v.executionId) || variants.has(v.variantId) || !["digest", "hold"].includes(v.operation) || Object.values(v.configuration).some(x => x !== null)) throw new TypeError("unknown/general configuration cannot execute");
|
|
39
|
+
if (c.version === "fixed-experiment-v2" ? !["primary", "retry"].includes(v.kind) || (v.kind === "primary" ? v.parentExecutionId !== null : !ids.has(v.parentExecutionId!)) : i === 0 ? v.kind !== "primary" || v.parentExecutionId !== null || v.operation !== "digest" : !["shadow", "retry"].includes(v.kind) || !ids.has(v.parentExecutionId!)) throw new TypeError("explicit ordered parent/primary required");
|
|
40
|
+
if (typeof v.suffixBase64 !== "string" || v.suffixBase64.length > 22000 || Buffer.from(v.suffixBase64, "base64").toString("base64") !== v.suffixBase64 || c.common.bytes + Buffer.from(v.suffixBase64, "base64").length > 16384) throw new TypeError("invalid bounded byte difference");
|
|
41
|
+
ids.add(v.executionId); variants.add(v.variantId);
|
|
42
|
+
}
|
|
43
|
+
if (c.mode === "concurrent-shadow" && (c.variants.length !== 2 || c.variants[1].kind !== "shadow")) throw new TypeError("concurrent shadow requires exactly primary and shadow");
|
|
44
|
+
if (c.version === "fixed-experiment-v2") {
|
|
45
|
+
if (c.mode !== "bounded-waves" || !c.common.work) throw new Error("orders require pinned work and bounded scheduling");
|
|
46
|
+
c.order = orderSchedule(c.order!, c.variants.map(v => v.executionId));
|
|
47
|
+
for (const n of c.order.nodes) for (const [i,id] of n.executions.entries()) { const v=c.variants.find(v=>v.executionId===id)!; if(i ? v.kind!=="retry"||v.parentExecutionId!==n.executions[i-1] : v.kind!=="primary") throw new Error("node recovery lineage mismatch"); }
|
|
48
|
+
}
|
|
49
|
+
return freezeWork(c) as ExperimentCharter;
|
|
50
|
+
}
|
|
51
|
+
export const parseExperimentCharter = (text: string) => experimentCharter(parseWorkJson(text) as unknown as ExperimentCharter);
|
|
52
|
+
export const experimentCharterDigest = (c: ExperimentCharter) => experimentHash(experimentCharter(c));
|
|
53
|
+
export interface ExperimentAuthority { authorityDigest: string; charterDigests: readonly string[]; cancellationDigests: readonly string[] }
|
|
54
|
+
export function experimentAuthority(value: ExperimentAuthority | null): ExperimentAuthority | null {
|
|
55
|
+
if (value === null) return null;
|
|
56
|
+
const a = cloneExperiment(value); closed(a, ["authorityDigest", "charterDigests", "cancellationDigests"]);
|
|
57
|
+
if (!hex(a.authorityDigest) || [a.charterDigests, a.cancellationDigests].some(xs => !Array.isArray(xs) || xs.length > 128 || xs.some(x => !hex(x)))) throw new TypeError("independent bounded host approvals required");
|
|
58
|
+
return freezeWork(a);
|
|
59
|
+
}
|
|
60
|
+
export interface ExperimentCancellation { version: "experiment-cancel-v1"; requestId: string; bindingDigest: string; executionId: string }
|
|
61
|
+
export function experimentCancellation(value: ExperimentCancellation): ExperimentCancellation {
|
|
62
|
+
const c = cloneExperiment(value); closed(c, ["version", "requestId", "bindingDigest", "executionId"]);
|
|
63
|
+
if (c.version !== "experiment-cancel-v1" || !id(c.requestId) || !hex(c.bindingDigest) || !id(c.executionId)) throw new TypeError("exact cancellation required");
|
|
64
|
+
return freezeWork(c);
|
|
65
|
+
}
|
|
66
|
+
export const experimentCancellationDigest = (c: ExperimentCancellation) => experimentHash(experimentCancellation(c));
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { closed, experimentCancellation, type ExperimentCancellation, type ExperimentCharter, type ExperimentVariant } from "./experiment-contract.ts";
|
|
2
|
+
import { factoryDecision, type FactoryDecision } from "./order-schedule.ts";
|
|
3
|
+
export type VariantState = "unstarted" | "queued" | "dispatching" | "running" | "completed" | "failed" | "cancelled" | "timed-out" | "unknown";
|
|
4
|
+
export interface VariantRecord extends Pick<ExperimentVariant, "variantId" | "kind" | "operation"> { executionId: string; state: VariantState; artifactDigest: string | null; spawned: boolean }
|
|
5
|
+
export function replayExperiment(c: ExperimentCharter, events: Record<string, unknown>[]) {
|
|
6
|
+
let claim: { owner: string; deadlineAt: number } | null = null, admitted = false;
|
|
7
|
+
const variants: VariantRecord[] = c.variants.map(v => ({ variantId: v.variantId, kind: v.kind, operation: v.operation, executionId: v.executionId, state: "unstarted", artifactDigest: null, spawned: false }));
|
|
8
|
+
const cancellations: ExperimentCancellation[] = [], decisions: FactoryDecision[] = [];
|
|
9
|
+
let superseded = false, controllerFailed = false;
|
|
10
|
+
for (const e of events) {
|
|
11
|
+
if (e.type === "order-decision") {
|
|
12
|
+
closed(e,["type","request"]);const d=factoryDecision(e.request as FactoryDecision);
|
|
13
|
+
if(!c.order || !c.order.nodes.some(n=>n.nodeId===d.nodeId&&n.decision?.authorityId===d.authorityId)||decisions.some(r=>r.nodeId===d.nodeId||r.requestId===d.requestId))throw new Error("invalid order decision sequence");decisions.push(d);
|
|
14
|
+
} else if(e.type === "order-migrated") {
|
|
15
|
+
closed(e,["type","requestId","successorDigest"]);if(!c.order||claim||superseded||typeof e.requestId!=="string"||typeof e.successorDigest!=="string"||!/^[a-f0-9]{64}$/.test(e.successorDigest))throw new Error("migration requires unstarted order");superseded=true;
|
|
16
|
+
} else if (e.type === "claim") {
|
|
17
|
+
closed(e, ["type", "owner", "deadlineAt"]);
|
|
18
|
+
if (superseded || claim || typeof e.owner !== "string" || !/^[a-f0-9-]{36}$/.test(e.owner) || !Number.isSafeInteger(e.deadlineAt)) throw new Error("invalid experiment claim");
|
|
19
|
+
claim = { owner: e.owner, deadlineAt: e.deadlineAt as number }; variants.forEach(v => v.state = "queued");
|
|
20
|
+
} else if (e.type === "admitted") {
|
|
21
|
+
closed(e, ["type"]); if (!claim || admitted) throw new Error("invalid experiment admission"); admitted = true;
|
|
22
|
+
} else if (e.type === "cancel") {
|
|
23
|
+
closed(e, ["type", "request"]); const request = experimentCancellation(e.request as ExperimentCancellation);
|
|
24
|
+
if (!variants.some(v => v.executionId === request.executionId) || cancellations.some(r => r.requestId === request.requestId) || cancellations.length >= 64) throw new Error("invalid cancellation sequence");
|
|
25
|
+
cancellations.push(request);
|
|
26
|
+
} else if (e.type === "controller-unknown") {
|
|
27
|
+
closed(e, ["type"]); if (!claim) throw new Error("missing claim"); controllerFailed = true;
|
|
28
|
+
variants.filter(v => v.artifactDigest === null).forEach(v => v.state = "unknown");
|
|
29
|
+
} else {
|
|
30
|
+
const v = variants.find(v => v.executionId === e.executionId);
|
|
31
|
+
if (!claim || !admitted || !v || v.artifactDigest !== null) throw new Error("invalid variant sequence");
|
|
32
|
+
if (e.type === "dispatch") {
|
|
33
|
+
closed(e, ["type", "executionId"]); if (v.state !== "queued") throw new Error("duplicate dispatch"); v.state = "dispatching";
|
|
34
|
+
} else if (e.type === "spawn") {
|
|
35
|
+
closed(e, ["type", "executionId"]); if (v.state !== "dispatching") throw new Error("invalid spawn observation"); v.state = "running"; v.spawned = true;
|
|
36
|
+
} else if (e.type === "result") {
|
|
37
|
+
closed(e, ["type", "executionId", "state", "artifactDigest"]);
|
|
38
|
+
if (!["dispatching", "running"].includes(v.state) || !["completed", "failed", "cancelled", "timed-out"].includes(String(e.state)) || typeof e.artifactDigest !== "string" || !/^[a-f0-9]{64}$/.test(e.artifactDigest)) throw new Error("invalid result observation");
|
|
39
|
+
v.state = e.state as VariantState; v.artifactDigest = e.artifactDigest;
|
|
40
|
+
} else throw new Error("unknown experiment event");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { claim, admitted, variants, cancellations, decisions, superseded, controllerFailed };
|
|
44
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, mkdir, open, realpath } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { withFileLock } from "./file-lock.ts";
|
|
5
|
+
import { runWithFinalizers } from "./finalization.ts";
|
|
6
|
+
import { parseWorkJson, freezeWork } from "./work-ledger-json.ts";
|
|
7
|
+
import { resourceBindingDigest, type ExperimentBudgetBinding } from "./resource-budget.ts";
|
|
8
|
+
import { byteHash, cloneExperiment, closed, experimentCharter, experimentHash, type ExperimentCharter } from "./experiment-contract.ts";
|
|
9
|
+
export interface ExperimentBinding { version: "experiment-binding-v1"; directory: string; device: string; inode: string; journalDevice: string; journalInode: string; budget: ExperimentBudgetBinding; charter: ExperimentCharter }
|
|
10
|
+
export const experimentBindingDigest = (b: ExperimentBinding) => experimentHash(b);
|
|
11
|
+
export async function ownedDirectory(path: string) {
|
|
12
|
+
const s = await lstat(path, { bigint: true });
|
|
13
|
+
if (!s.isDirectory() || s.isSymbolicLink() || s.uid !== BigInt(process.getuid!()) || (s.mode & 0o077n) !== 0n || await realpath(path) !== path) throw new Error("canonical private experiment directory required");
|
|
14
|
+
return s;
|
|
15
|
+
}
|
|
16
|
+
export async function readExperimentFile(path: string, max: number): Promise<Buffer> {
|
|
17
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
18
|
+
return runWithFinalizers(async () => {
|
|
19
|
+
const s = await file.stat({ bigint: true });
|
|
20
|
+
if (!s.isFile() || s.nlink !== 1n || s.uid !== BigInt(process.getuid!()) || (s.mode & 0o077n) !== 0n || s.size > BigInt(max)) throw new Error("unsafe experiment file");
|
|
21
|
+
const data = Buffer.alloc(Number(s.size)); let offset = 0;
|
|
22
|
+
while (offset < data.length) { const n = (await file.read(data, offset, data.length - offset, offset)).bytesRead; if (!n) throw new Error("short experiment read"); offset += n; }
|
|
23
|
+
const end = await file.stat({ bigint: true }), current = await lstat(path, { bigint: true });
|
|
24
|
+
if (end.size !== s.size || end.mtimeNs !== s.mtimeNs || current.dev !== s.dev || current.ino !== s.ino || current.nlink !== 1n) throw new Error("experiment source changed");
|
|
25
|
+
return data;
|
|
26
|
+
}, [{ label: "experiment read close failed", run: () => file.close() }]);
|
|
27
|
+
}
|
|
28
|
+
export async function writeExperimentFile(path: string, bytes: Uint8Array) {
|
|
29
|
+
const f = await open(path, "wx", 0o600);
|
|
30
|
+
await runWithFinalizers(async () => { await f.writeFile(bytes); await f.sync(); await f.chmod(0o400); }, [{ label: "experiment artifact close failed", run: () => f.close() }]);
|
|
31
|
+
const dir = await open(dirname(path), constants.O_RDONLY | constants.O_DIRECTORY);
|
|
32
|
+
await runWithFinalizers(() => dir.sync(), [{ label: "experiment artifact directory close failed", run: () => dir.close() }]);
|
|
33
|
+
}
|
|
34
|
+
export function validateExperimentBinding(value: ExperimentBinding): ExperimentBinding {
|
|
35
|
+
const b = cloneExperiment(value); closed(b, ["version", "directory", "device", "inode", "journalDevice", "journalInode", "budget", "charter"]);
|
|
36
|
+
if (b.version !== "experiment-binding-v1" || typeof b.directory !== "string" || b.directory !== resolve(b.directory) || b.directory.length > 1024 || b.directory.split("/").includes(".pi") || [b.device, b.inode, b.journalDevice, b.journalInode].some(v => typeof v !== "string" || !/^\d+$/.test(v)) || b.budget.version !== "4.0") throw new TypeError("independent experiment binding required");
|
|
37
|
+
b.charter = experimentCharter(b.charter);
|
|
38
|
+
if (resourceBindingDigest(b.budget) !== b.charter.budgetDigest) throw new TypeError("experiment budget mismatch");
|
|
39
|
+
return freezeWork(b) as ExperimentBinding;
|
|
40
|
+
}
|
|
41
|
+
export async function createExperimentStore(directory: string, budget: ExperimentBudgetBinding, charter: ExperimentCharter, bytes: Uint8Array): Promise<ExperimentBinding> {
|
|
42
|
+
if (directory !== resolve(directory) || directory.split("/").includes(".pi")) throw new Error("explicit unprotected absolute destination required");
|
|
43
|
+
await ownedDirectory(dirname(directory)); await mkdir(directory, { mode: 0o700 }); const s = await ownedDirectory(directory);
|
|
44
|
+
await writeExperimentFile(join(directory, "common.bin"), bytes);
|
|
45
|
+
for (const v of charter.variants) await mkdir(join(directory, "variant-" + byteHash(v.executionId)), { mode: 0o700 });
|
|
46
|
+
const journal = await open(join(directory, "experiment.jsonl"), "wx", 0o600);
|
|
47
|
+
const b = await runWithFinalizers(async () => {
|
|
48
|
+
const st = await journal.stat({ bigint: true });
|
|
49
|
+
const value = validateExperimentBinding({ version: "experiment-binding-v1", directory, device: String(s.dev), inode: String(s.ino), journalDevice: String(st.dev), journalInode: String(st.ino), budget, charter });
|
|
50
|
+
await journal.writeFile(JSON.stringify(value) + "\n"); await journal.sync(); return value;
|
|
51
|
+
}, [{ label: "experiment creation close failed", run: () => journal.close() }]);
|
|
52
|
+
const dir = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY);
|
|
53
|
+
await runWithFinalizers(() => dir.sync(), [{ label: "experiment creation directory close failed", run: () => dir.close() }]); return b;
|
|
54
|
+
}
|
|
55
|
+
export function experimentStore(value: ExperimentBinding) {
|
|
56
|
+
const b = validateExperimentBinding(value), path = join(b.directory, "experiment.jsonl");
|
|
57
|
+
const check = async () => {
|
|
58
|
+
const root = await ownedDirectory(b.directory), file = await lstat(path, { bigint: true });
|
|
59
|
+
if (String(root.dev) !== b.device || String(root.ino) !== b.inode || String(file.dev) !== b.journalDevice || String(file.ino) !== b.journalInode) throw new Error("experiment identity replaced");
|
|
60
|
+
};
|
|
61
|
+
const read = async () => {
|
|
62
|
+
await check(); const bytes = await readExperimentFile(path, 256 * 1024); await check();
|
|
63
|
+
const text = new TextDecoder("utf8", { fatal: true }).decode(bytes); if (!text.endsWith("\n")) throw new Error("torn experiment journal");
|
|
64
|
+
const lines = text.slice(0, -1).split("\n"); if (lines.length > 260 || experimentHash(parseWorkJson(lines[0])) !== experimentHash(b)) throw new Error("experiment header/bound mismatch");
|
|
65
|
+
let previous = experimentHash(b); const events: Record<string, unknown>[] = [];
|
|
66
|
+
for (const line of lines.slice(1)) {
|
|
67
|
+
const record = parseWorkJson(line) as Record<string, unknown>; closed(record, ["seq", "previous", "event"]);
|
|
68
|
+
if (record.seq !== events.length || record.previous !== previous || !record.event || typeof record.event !== "object" || Array.isArray(record.event)) throw new Error("invalid experiment sequence");
|
|
69
|
+
previous = experimentHash(record); events.push(record.event as Record<string, unknown>);
|
|
70
|
+
}
|
|
71
|
+
return { events, previous, bytes: bytes.length };
|
|
72
|
+
};
|
|
73
|
+
return { binding: b, read, async transaction<T>(fn: (events: Record<string, unknown>[], append: (event: unknown) => Promise<void>) => Promise<T>) {
|
|
74
|
+
await check(); return withFileLock(path, "experiment", async () => {
|
|
75
|
+
const state = await read(); let size = state.bytes;
|
|
76
|
+
return fn(state.events, async event => {
|
|
77
|
+
if (state.events.length >= 258) throw new Error("experiment record bound reached");
|
|
78
|
+
const record = { seq: state.events.length, previous: state.previous, event }, data = Buffer.from(JSON.stringify(record) + "\n");
|
|
79
|
+
if (size + data.length > 256 * 1024) throw new Error("experiment journal byte bound reached");
|
|
80
|
+
await check(); const f = await open(path, constants.O_WRONLY | constants.O_APPEND | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
81
|
+
await runWithFinalizers(async () => {
|
|
82
|
+
const s = await f.stat({ bigint: true });
|
|
83
|
+
if (!s.isFile() || s.nlink !== 1n || String(s.dev) !== b.journalDevice || String(s.ino) !== b.journalInode || s.size !== BigInt(size) || (s.mode & 0o077n) !== 0n) throw new Error("journal changed under lock");
|
|
84
|
+
await f.writeFile(data); await f.sync();
|
|
85
|
+
}, [{ label: "experiment append close failed", run: () => f.close() }]);
|
|
86
|
+
size += data.length; await check(); state.previous = experimentHash(record); state.events.push(cloneExperiment(event) as Record<string, unknown>);
|
|
87
|
+
});
|
|
88
|
+
}, { staleRecovery: "disabled" });
|
|
89
|
+
} };
|
|
90
|
+
}
|