@tea-agent/loop-agent 0.41.0 → 0.42.0-next.1
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 +102 -14
- package/dist/adapters/context-transfer/optional-pi-handoff.js +31 -0
- package/dist/adapters/context-transfer/pi-session.js +61 -0
- package/dist/build-stamp.json +3 -3
- package/dist/cli/command-definitions.js +12 -0
- package/dist/cli/program.js +23 -1
- package/dist/commands/dag-artifact.js +284 -0
- package/dist/commands/dag-context.js +184 -0
- package/dist/commands/dag-rerun.js +206 -1
- package/dist/executors/dag-pi-executor.js +39 -12
- package/dist/executors/pi-sdk-executor.js +3 -0
- package/dist/{worker → infrastructure}/console/app-data.js +4 -0
- package/dist/infrastructure/console/artifact-revision-store.js +430 -0
- package/dist/infrastructure/console/context-export-store.js +160 -0
- package/dist/infrastructure/console/dir-lock.js +132 -0
- package/dist/{worker → infrastructure}/console/operation-store.js +28 -0
- package/dist/infrastructure/harness/artifact-store.js +10 -1
- package/dist/infrastructure/harness/atomic-write.js +12 -2
- package/dist/shared/context-transfer/artifact-revision.js +172 -0
- package/dist/shared/context-transfer.js +418 -0
- package/dist/shared/operator/capabilities.js +14 -0
- package/dist/shared/operator/safe-run-summary.js +1 -0
- package/dist/shared/path-safety.js +93 -0
- package/dist/shared/preview.js +28 -4
- package/dist/task/config-types.js +2 -1
- package/dist/task/task-demand-routing.js +3 -0
- package/dist/worker/console/chat/chat-event-store.js +2 -2
- package/dist/worker/console/chat/resource-preferences-store.js +1 -1
- package/dist/worker/console/chat/routes.js +1 -1
- package/dist/worker/console/chat/session-store.js +1 -1
- package/dist/worker/console/chat/user-questions.js +1 -1
- package/dist/worker/console/console-update-runtime.js +1 -1
- package/dist/worker/console/context-transfer-diagnostics.js +198 -0
- package/dist/worker/console/dag-confirmation.js +1 -1
- package/dist/worker/console/dag-execution-receipt.js +1 -1
- package/dist/worker/console/doctor.js +1 -1
- package/dist/worker/console/draft-store.js +1 -1
- package/dist/worker/console/human-gate-token.js +1 -1
- package/dist/worker/console/index.js +3 -3
- package/dist/worker/console/interview/assessment.js +1 -1
- package/dist/worker/console/interview/session.js +1 -1
- package/dist/worker/console/operation-runner.js +45 -4
- package/dist/worker/console/operation-sse.js +1 -1
- package/dist/worker/console/operation-wait.js +1 -1
- package/dist/worker/console/operator-actions.js +104 -3
- package/dist/worker/console/operator-surface-health.js +1 -1
- package/dist/worker/console/operator-user-error.js +169 -0
- package/dist/worker/console/pi-plugins.js +57 -0
- package/dist/worker/console/routes.js +637 -8
- package/dist/worker/console/security.js +35 -0
- package/dist/worker/console/server.js +6 -3
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-DwVJxUxz.js → abnfDiagram-N423BO3Z-DZZ8m3PO.js} +1 -1
- package/dist/worker/console/static/assets/{arc-DqfCcXdu.js → arc-D6PvaVd-.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-qMVtggRv.js → architectureDiagram-T3A2C74G-B_OTOiI8.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-ECrV38dh.js → blockDiagram-VBNYF7ZC-Bv6rqHBg.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DBaaLqQv.js → c4Diagram-5PPSVZJV-B8eHr0oz.js} +1 -1
- package/dist/worker/console/static/assets/channel-BU5gOilw.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-BvfNHkAz.js → chunk-2GRJ4B5K-DYwR0im2.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-C5StiIbc.js → chunk-2Q5K7J3B-D2WPGqXt.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-HabGiJkS.js → chunk-5RXB4S5H-CQISJ_I7.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BiTg127i.js → chunk-5VM5RSS4-C0o2Du1e.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-CZsXBPuR.js → chunk-6Q2QTUOP-4f8kr-U7.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-D-r1MxLK.js → chunk-GF5L2VYU-D_OWvXzX.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-DQxb9plr.js → chunk-JWPE2WC7-CasdPz5X.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DMndGvCT.js → chunk-KBJHAD2P-Cj8lRrla.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-ByL4BgOJ.js → chunk-RYQCIY6F-CUvD1FSd.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-BVe1b1gL.js → chunk-XXDRQBXY-CqLw_eqB.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DIzKJGHr.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DIzKJGHr.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BAyGzN8j.js → cose-bilkent-JH36ORCC-ku-WqJPx.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-C5yVYU7E.js → cynefin-VYW2F7L2-Bq_PYDhl.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-x-MwsEm9.js → cynefinDiagram-MW4NZA55-BiGZV641.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CT5Tle5F.js → dagre-VZM6K2ZE-C4JRlglF.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-BARERhlS.js → diagram-7IWD3JNH-B0dNeWYG.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-BwfUz0VM.js → diagram-B4RE2ZJO-Cj35YvYM.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-Mk4r2WvJ.js → diagram-LBJQPF4R-uzQoJ2-8.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-KUrxuBs0.js → diagram-Q27KOJAE-D1a-Buoz.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-CjZOceZn.js → diagram-UB23O5K3-XjRrLRSs.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-rnwUCiEI.js → ebnfDiagram-BXEA7PRR-Da_O24cW.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DM4j23FM.js → erDiagram-JOGREHBK-BLJ8jrYU.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-DD5HdDEs.js → flowDiagram-UKHOOZJN-B9GrLjM0.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CIAGSF47.js → ganttDiagram-PKOTCBZU-CeJ0TqiK.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-DbwFbVEL.js → gitGraphDiagram-DS77QQ5N-Bm2eNNFX.js} +1 -1
- package/dist/worker/console/static/assets/index-H9rFJiGL.css +1 -0
- package/dist/worker/console/static/assets/{index-Bih9w9h9.js → index-xwu9GxEc.js} +80 -80
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-B-Pb0T66.js → infoDiagram-6WML65LV-DB26i3d8.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-B9Unk-YX.js → ishikawaDiagram-WSZJBQD7-BOGRyeZT.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-C2F3JkPt.js → journeyDiagram-NVQOT4AX-CZRoBO_6.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-Bl8-Xcoz.js → kanban-definition-27J2QSJJ-qlWhJyoB.js} +1 -1
- package/dist/worker/console/static/assets/{linear-yK4uZmC5.js → linear-CfUiDDB3.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-Bpa6PmuE.js → mermaid.core-BQe6fpqj.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-BfGRoCSe.js → mindmap-definition-FAOFIHXS-zFzWHw64.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-Bz6_HtST.js → pegDiagram-VL7TDLO6-DFGUqjZO.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-i0Umbsby.js → pieDiagram-7S7Q4E2Y-BB5i0l8Z.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-DaVQBobe.js → quadrantDiagram-CIZ2JOQS-obbVZ_X5.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-DMTGnSq7.js → railroadDiagram-AXF67PYL-BwdaNzc1.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-2ao_DaSq.js → requirementDiagram-LRYGKXZP-CkTJaugh.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-2SG1lTB9.js → sankeyDiagram-W5VNT64P-BwJ-hIgq.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BQYOLybR.js → sequenceDiagram-SI44F4Z6-BzSgmm7w.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-C8g3yjuI.js → sizeCapture-X5ZJPWSS-Bn3oj5Q1.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-q_F8nAQG.js → stateDiagram-OKZ733FA-dxhGtL8K.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Cld2qK9v.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-8LrWpH2s.js → swimlanes-SLNWSIFB-BLpEInya.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-TIHpiT7w.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-BQoY3qzl.js → timeline-definition-Z64GVDOM-ChJGYcXy.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BDu1UjSp.js → vennDiagram-T6HMQDX7-DK-qjRer.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-C48M7Y0a.js → wardleyDiagram-T6FBY63Y-3qeaWAg-.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-YU6FTl_F.js → xychartDiagram-ELKLHX3M-CootlyP9.js} +1 -1
- package/dist/worker/console/static/index.html +7 -2
- package/dist/worker/console/static-src/app/useOperatorActions.js +3 -2
- package/dist/worker/console/static-src/shell/console-update-reload.js +25 -0
- package/dist/worker/console/workspace-context.js +28 -2
- package/dist/worker/console/workspace-registry.js +2 -2
- package/dist/worker/continuation/worker-continuation.js +278 -0
- package/dist/worker/observe/health.js +1 -1
- package/dist/worker/observe/node-transparency.js +572 -0
- package/dist/worker/observe/routes.js +51 -1
- package/dist/worker/observe/static/api.js +69 -5
- package/dist/worker/observe/static/dag-context-reason-labels.d.ts +9 -0
- package/dist/worker/observe/static/dag-context-reason-labels.js +120 -0
- package/dist/worker/observe/static/state.js +3 -1
- package/dist/worker/observe/static/styles.css +1037 -126
- package/dist/worker/observe/static/views/dag-inspector.js +1215 -58
- package/dist/worker/observe/static/views/session-timeline.js +75 -10
- package/dist/worker/outcomes/adapters.js +19 -0
- package/dist/worker/outcomes/types.js +1 -0
- package/dist/worker/pool/attempt-lease.js +97 -66
- package/dist/worker/pool/begin-attempt-with-lease.js +1 -0
- package/dist/worker/pool/reconcile.js +46 -1
- package/dist/worker/pool/run-store.js +2 -0
- package/dist/worker/pool/state-projection.js +2 -0
- package/dist/worker/task-spec/workflow-routing.js +8 -3
- package/dist/workflows/dag/artifact-bindings.js +149 -0
- package/dist/workflows/dag/artifact-revision-schema-registry.js +31 -0
- package/dist/workflows/dag/budget-enforcement.js +10 -0
- package/dist/workflows/dag/context-receipt.js +305 -0
- package/dist/workflows/dag/context-transfer/context-bundle.js +423 -0
- package/dist/workflows/dag/context-transfer/operator-actions.js +61 -0
- package/dist/workflows/dag/context-transfer/renderers.js +93 -0
- package/dist/workflows/dag/frontend-writer-rollback.js +32 -0
- package/dist/workflows/dag/init-hybrid.js +146 -0
- package/dist/workflows/dag/node-execution.js +112 -27
- package/dist/workflows/dag/path-safety.js +1 -0
- package/dist/workflows/dag/prompt.js +29 -4
- package/dist/workflows/dag/rerun-plan.js +555 -1
- package/dist/workflows/dag/rerun-run.js +601 -15
- package/dist/workflows/dag/runner.js +20 -1
- package/dist/workflows/dag/skill-snapshot.js +17 -0
- package/dist/workflows/dag/types.js +183 -7
- package/docs/architecture/runtime-boundaries.md +6 -6
- package/docs/architecture/worker-and-feature.md +1 -1
- package/docs/templates/agent-dag.schema.json +32 -0
- package/docs/templates/product-line/task.yaml +1 -1
- package/package.json +3 -1
- package/skills/loop-agent/references/command-reference.md +6 -0
- package/dist/worker/console/static/assets/channel-CL1NRAmS.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-PBGzzjZA.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-PBGzzjZA.js +0 -1
- package/dist/worker/console/static/assets/index-BfleX-qj.css +0 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DrxGBDJu.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BhpDxzAf.js +0 -8
- /package/dist/{worker → infrastructure}/console/repo-fingerprint.js +0 -0
|
@@ -13,8 +13,9 @@ import { captureWorkspaceWriteSnapshot, diffWorkspaceWriteSnapshots, } from "./w
|
|
|
13
13
|
import { isTargetTemplateTransientRetryNode, isWriterEmptyDiffRetryCandidate, isWriterTransportRetryCandidate, INCOMPLETE_WRITE_SET_RETRY_CATEGORY, WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY, WRITER_EMPTY_DIFF_RETRY_CATEGORY, } from "../workflows/dag/retry-policy.js";
|
|
14
14
|
import { assessBackendTestMdPlanCompleteness, assessBackendTestMdWriterCompleteness, assessBackendTestPytestPlanCompleteness, assessBackendTestPytestWriterCompleteness, assessBackendTestShardChildCompleteness, backendTestWriterProgressRoleForTask, classifyBackendTestWriterCompletenessFailure, isBackendTestCompletenessRetryCandidate, isBackendTestMdPlanTask, isBackendTestPytestPlanTask, isBackendTestShardChildTask, writeBackendTestWriterProgressArtifacts, } from "../workflows/dag/backend-test-writer-completeness.js";
|
|
15
15
|
import { resolveBackendTestLayout } from "../workflows/dag/backend-test-layout.js";
|
|
16
|
-
import { frontendTestLayoutFromSpec
|
|
16
|
+
import { frontendTestLayoutFromSpec } from "../workflows/dag/frontend-test-layout.js";
|
|
17
17
|
import { redactSecrets, truncateUtf8Preview } from "../shared/preview.js";
|
|
18
|
+
import { writeEffectiveContextReceipt } from "../workflows/dag/context-receipt.js";
|
|
18
19
|
/**
|
|
19
20
|
* Writer classification for a length-stopped thinking-only attempt: the model
|
|
20
21
|
* exhausted its output budget thinking but never issued a write/edit tool call
|
|
@@ -516,7 +517,11 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
516
517
|
let playwrightToolContext;
|
|
517
518
|
if (allowsPlaywrightCli && !isWriteTask) {
|
|
518
519
|
return {
|
|
519
|
-
ok: false,
|
|
520
|
+
ok: false,
|
|
521
|
+
stdout: "",
|
|
522
|
+
stderr: "pi command capability requires an explicit bounded write tool profile",
|
|
523
|
+
failureCategory: "tool-policy",
|
|
524
|
+
durationMs: Date.now() - started,
|
|
520
525
|
};
|
|
521
526
|
}
|
|
522
527
|
const strictReadSet = (input.task.readSet?.length ?? 0) > 0;
|
|
@@ -642,14 +647,34 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
642
647
|
const piExtensionPaths = piExtensionsResolution && resolvePiBackend() !== "cli-only"
|
|
643
648
|
? piExtensionsResolution.resolved.flatMap((entry) => entry.entryPaths)
|
|
644
649
|
: undefined;
|
|
650
|
+
const modelConfig = resolveDagPiModelConfig(input.model, input.thinking ? { thinking: input.thinking } : undefined);
|
|
651
|
+
const toolNames = resolveDagPiToolNames(input.task);
|
|
652
|
+
const userMessage = buildDagPiUserMessage(input.task, persona, step);
|
|
653
|
+
await writeEffectiveContextReceipt({
|
|
654
|
+
runDir: meta.runDir,
|
|
655
|
+
runId: meta.runId,
|
|
656
|
+
nodeId: input.task.id,
|
|
657
|
+
attempt,
|
|
658
|
+
task: input.task,
|
|
659
|
+
spec: meta.spec,
|
|
660
|
+
repoRoot: input.cwd,
|
|
661
|
+
systemPrompt: input.prompt,
|
|
662
|
+
userMessage,
|
|
663
|
+
resolvedModel: `${modelConfig.provider}/${modelConfig.model}`,
|
|
664
|
+
resolvedSkills: input.resolvedSkills ?? [],
|
|
665
|
+
resolvedExtensions: piExtensionsResolution && resolvePiBackend() !== "cli-only"
|
|
666
|
+
? piExtensionsResolution.resolved.map((entry) => entry.id)
|
|
667
|
+
: [],
|
|
668
|
+
activeTools: toolNames,
|
|
669
|
+
});
|
|
645
670
|
result = await piStepFn({
|
|
646
671
|
attachedFiles: [],
|
|
647
|
-
modelConfig
|
|
672
|
+
modelConfig,
|
|
648
673
|
prompt: input.prompt,
|
|
649
674
|
repoRoot: input.cwd,
|
|
650
675
|
step,
|
|
651
|
-
toolNames
|
|
652
|
-
userMessage
|
|
676
|
+
toolNames,
|
|
677
|
+
userMessage,
|
|
653
678
|
sessionEventsPath: path.join(meta.runDir, input.task.id, "session-events.jsonl"),
|
|
654
679
|
// Absolute max wall clock from adaptive liveness policy (defaults to 4h).
|
|
655
680
|
timeoutMs: input.timeoutMs,
|
|
@@ -927,8 +952,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
927
952
|
});
|
|
928
953
|
}
|
|
929
954
|
let completenessFailure;
|
|
930
|
-
if (writeGuardOk &&
|
|
931
|
-
isBackendTestCompletenessRetryCandidate(input.task)) {
|
|
955
|
+
if (writeGuardOk && isBackendTestCompletenessRetryCandidate(input.task)) {
|
|
932
956
|
try {
|
|
933
957
|
const writerRole = backendTestWriterProgressRoleForTask(input.task.id);
|
|
934
958
|
const backendLayout = resolveBackendTestLayout(meta.spec.backendTestLayout
|
|
@@ -941,7 +965,9 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
941
965
|
const progress = isBackendTestShardChildTask(input.task)
|
|
942
966
|
? await assessBackendTestShardChildCompleteness({
|
|
943
967
|
workspaceRoot: input.cwd,
|
|
944
|
-
role: writerRole === "pytest-generate"
|
|
968
|
+
role: writerRole === "pytest-generate"
|
|
969
|
+
? "pytest-generate"
|
|
970
|
+
: "md-generate",
|
|
945
971
|
writeSet: input.task.writeSet ?? [],
|
|
946
972
|
})
|
|
947
973
|
: isBackendTestMdPlanTask(input.task)
|
|
@@ -987,7 +1013,10 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
987
1013
|
};
|
|
988
1014
|
}
|
|
989
1015
|
}
|
|
990
|
-
if (mapped.ok &&
|
|
1016
|
+
if (mapped.ok &&
|
|
1017
|
+
writeGuardOk &&
|
|
1018
|
+
!writerOutcomeViolation &&
|
|
1019
|
+
!completenessFailure) {
|
|
991
1020
|
return mapped;
|
|
992
1021
|
}
|
|
993
1022
|
const writerThinkingExhausted = isWriterThinkingExhausted(result, mapped, changeManifestChangedFiles);
|
|
@@ -1393,9 +1422,7 @@ async function persistWriterChangeManifest(input) {
|
|
|
1393
1422
|
...(input.approvalSourceNodeId
|
|
1394
1423
|
? { approvalSourceNodeId: input.approvalSourceNodeId }
|
|
1395
1424
|
: {}),
|
|
1396
|
-
...(input.approvalDigest
|
|
1397
|
-
? { approvalDigest: input.approvalDigest }
|
|
1398
|
-
: {}),
|
|
1425
|
+
...(input.approvalDigest ? { approvalDigest: input.approvalDigest } : {}),
|
|
1399
1426
|
changedFiles: input.changedFiles,
|
|
1400
1427
|
beforeStatusSha256: createHash("sha256")
|
|
1401
1428
|
.update(input.beforeStatus)
|
|
@@ -215,6 +215,9 @@ async function createSdkSession(sdk, input, shared) {
|
|
|
215
215
|
const model = input.provider && input.model
|
|
216
216
|
? await resolveModel(modelRuntime, input.provider, input.model)
|
|
217
217
|
: undefined;
|
|
218
|
+
if (input.provider && input.model && !model) {
|
|
219
|
+
throw new Error(`pi SDK model unavailable: ${input.provider}/${input.model}; refusing to fall back to the session default`);
|
|
220
|
+
}
|
|
218
221
|
// Inject rate-limit-tolerant auto-retry settings for the session lifecycle.
|
|
219
222
|
// Prefer SettingsManager.inMemory so harness runs do not depend on ~/.pi or
|
|
220
223
|
// project .pi/settings.json being present/trusted.
|
|
@@ -35,6 +35,8 @@ export function openConsoleAppData(options) {
|
|
|
35
35
|
fingerprint,
|
|
36
36
|
drafts: path.join(repoNamespace, "drafts"),
|
|
37
37
|
operations: path.join(repoNamespace, "operations"),
|
|
38
|
+
contextExports: path.join(repoNamespace, "context-exports"),
|
|
39
|
+
artifactRevisions: path.join(repoNamespace, "artifact-revisions"),
|
|
38
40
|
confirmations: path.join(repoNamespace, "confirmations"),
|
|
39
41
|
dagReceipts: path.join(repoNamespace, "dag-receipts"),
|
|
40
42
|
staged: path.join(repoNamespace, "staged"),
|
|
@@ -48,6 +50,8 @@ export function openConsoleAppData(options) {
|
|
|
48
50
|
for (const dir of [
|
|
49
51
|
paths.drafts,
|
|
50
52
|
paths.operations,
|
|
53
|
+
paths.contextExports,
|
|
54
|
+
paths.artifactRevisions,
|
|
51
55
|
paths.confirmations,
|
|
52
56
|
paths.dagReceipts,
|
|
53
57
|
paths.staged,
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { lstat, readdir, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { validateAndDescribeArtifactRevision, } from "../../shared/context-transfer/artifact-revision.js";
|
|
5
|
+
import { artifactRevisionV1Schema, computeOverrideSetHash, } from "../../shared/context-transfer.js";
|
|
6
|
+
import { assertSafeAppDataPath, ensureSecureDir, readJsonIfExists, writeSecureJson, } from "./app-data.js";
|
|
7
|
+
import { acquireDirLock, isDirLockStale, } from "./dir-lock.js";
|
|
8
|
+
import { readFileBoundarySafe } from "../../shared/path-safety.js";
|
|
9
|
+
export const DEFAULT_ARTIFACT_REVISION_TTL_MS = 60 * 60 * 1000;
|
|
10
|
+
/** Per-repo staging quota across uploads + staged candidates (M4b). */
|
|
11
|
+
export const ARTIFACT_REVISION_STAGING_QUOTA_BYTES = 200 * 1024 * 1024;
|
|
12
|
+
export const DEFAULT_REVISION_LEASE_TTL_MS = 5 * 60 * 1000;
|
|
13
|
+
/**
|
|
14
|
+
* Execute holds the revision across an entire child run (potentially hours);
|
|
15
|
+
* align with the Console default operation timeout instead of the short
|
|
16
|
+
* staging TTL.
|
|
17
|
+
*/
|
|
18
|
+
export const EXECUTE_REVISION_LEASE_TTL_MS = 4 * 60 * 60 * 1000;
|
|
19
|
+
const STAGING_LOCK_DIR = ".staging-lock";
|
|
20
|
+
const STAGING_LOCK_STALE_MS = 30_000;
|
|
21
|
+
function stagingLockDirPath(appData) {
|
|
22
|
+
return path.join(root(appData), STAGING_LOCK_DIR);
|
|
23
|
+
}
|
|
24
|
+
function revisionMutationLockDirPath(appData, revisionId) {
|
|
25
|
+
if (!/^[a-f0-9]{32}$/.test(revisionId))
|
|
26
|
+
throw new Error("invalid revision id");
|
|
27
|
+
return assertSafeAppDataPath(root(appData), path.join(root(appData), "locks", `${revisionId}.lock`));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Cross-process repo-wide mutex around the quota "measure → decide → write"
|
|
31
|
+
* window. In-process promise chains alone leave a TOCTOU window between the
|
|
32
|
+
* Console upload route and sibling CLI stage processes; a stale-tolerant
|
|
33
|
+
* exclusive directory lock closes it.
|
|
34
|
+
*
|
|
35
|
+
* Ownership is token-guarded: each holder writes a unique owner file inside
|
|
36
|
+
* the lock directory and only ever removes a lock whose owner token matches
|
|
37
|
+
* its own. Release verifies owner + freshness BEFORE touching the path (B2):
|
|
38
|
+
* a stale lock is never moved by its former holder, so an old holder cannot
|
|
39
|
+
* race a successor's takeover and a third contender cannot acquire while the
|
|
40
|
+
* new holder is still in its critical section.
|
|
41
|
+
*/
|
|
42
|
+
export async function acquireRepoStagingLock(appData, options) {
|
|
43
|
+
return acquireDirLock(stagingLockDirPath(appData), {
|
|
44
|
+
staleMs: options?.staleMs ?? STAGING_LOCK_STALE_MS,
|
|
45
|
+
token: options?.token,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
async function withRepoStagingLock(appData, operation) {
|
|
49
|
+
const handle = await acquireRepoStagingLock(appData);
|
|
50
|
+
try {
|
|
51
|
+
return await operation();
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
await handle.release();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Cross-process per-revision serialization of metadata read-modify-write
|
|
59
|
+
* (B1). `serializeRevision` only serializes within one process; the Console
|
|
60
|
+
* and sibling CLI processes are separate processes that can both read
|
|
61
|
+
* metadata.json, mutate it, and write it back — losing an update (e.g. two
|
|
62
|
+
* executes, or an execute racing a discard, both "taking over" the lease).
|
|
63
|
+
* A per-revision directory lock held across the whole read-modify-write
|
|
64
|
+
* closes that window.
|
|
65
|
+
*/
|
|
66
|
+
async function withRevisionMutationLock(appData, revisionId, operation) {
|
|
67
|
+
const lockDir = revisionMutationLockDirPath(appData, revisionId);
|
|
68
|
+
const handle = await acquireDirLock(lockDir);
|
|
69
|
+
try {
|
|
70
|
+
return await operation();
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
await handle.release();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function root(appData) {
|
|
77
|
+
if (!appData.artifactRevisions)
|
|
78
|
+
throw new Error("artifact revision root unavailable");
|
|
79
|
+
return assertSafeAppDataPath(appData.root, appData.artifactRevisions);
|
|
80
|
+
}
|
|
81
|
+
function dir(appData, revisionId) {
|
|
82
|
+
if (!/^[a-f0-9]{32}$/.test(revisionId))
|
|
83
|
+
throw new Error("invalid revision id");
|
|
84
|
+
return assertSafeAppDataPath(root(appData), path.join(root(appData), revisionId));
|
|
85
|
+
}
|
|
86
|
+
function metadataPath(appData, revisionId) {
|
|
87
|
+
return path.join(dir(appData, revisionId), "metadata.json");
|
|
88
|
+
}
|
|
89
|
+
export function artifactRevisionUploadPath(appData, candidateSha256) {
|
|
90
|
+
if (!/^[a-f0-9]{64}$/.test(candidateSha256)) {
|
|
91
|
+
throw new Error("invalid candidate upload hash");
|
|
92
|
+
}
|
|
93
|
+
const uploadsDir = assertSafeAppDataPath(root(appData), path.join(root(appData), "uploads"));
|
|
94
|
+
return assertSafeAppDataPath(uploadsDir, path.join(uploadsDir, `${candidateSha256}.bin`));
|
|
95
|
+
}
|
|
96
|
+
export async function totalArtifactRevisionStagingBytes(appData) {
|
|
97
|
+
let total = 0;
|
|
98
|
+
const stagingRoot = root(appData);
|
|
99
|
+
async function addFile(target) {
|
|
100
|
+
const stat = await lstat(target).catch(() => undefined);
|
|
101
|
+
if (stat?.isFile())
|
|
102
|
+
total += stat.size;
|
|
103
|
+
}
|
|
104
|
+
const uploadsDir = path.join(stagingRoot, "uploads");
|
|
105
|
+
const uploadsStat = await lstat(uploadsDir).catch(() => undefined);
|
|
106
|
+
if (uploadsStat?.isDirectory()) {
|
|
107
|
+
for (const entry of await readdir(uploadsDir)) {
|
|
108
|
+
if (!/^[a-f0-9]{64}\.bin$/.test(entry))
|
|
109
|
+
continue;
|
|
110
|
+
await addFile(path.join(uploadsDir, entry));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
for (const name of await readdir(stagingRoot)) {
|
|
114
|
+
if (!/^[a-f0-9]{32}$/.test(name))
|
|
115
|
+
continue;
|
|
116
|
+
await addFile(path.join(stagingRoot, name, "candidate.bin"));
|
|
117
|
+
}
|
|
118
|
+
return total;
|
|
119
|
+
}
|
|
120
|
+
function assertStagingQuota(input) {
|
|
121
|
+
const quota = input.quotaBytes ?? ARTIFACT_REVISION_STAGING_QUOTA_BYTES;
|
|
122
|
+
if (!Number.isSafeInteger(quota) || quota < 1)
|
|
123
|
+
throw new Error("invalid staging quota");
|
|
124
|
+
if (input.incomingBytes > quota) {
|
|
125
|
+
throw new Error("ARTIFACT_REVISION_QUOTA_EXCEEDED: candidate exceeds staging quota");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
export async function writeArtifactRevisionUpload(input) {
|
|
129
|
+
return withRepoStagingLock(input.appData, async () => {
|
|
130
|
+
const actual = createHash("sha256").update(input.candidateBytes).digest("hex");
|
|
131
|
+
if (actual !== input.candidateSha256) {
|
|
132
|
+
throw new Error("ARTIFACT_REVISION_DRIFT: candidate hash mismatch");
|
|
133
|
+
}
|
|
134
|
+
const uploadPath = artifactRevisionUploadPath(input.appData, input.candidateSha256);
|
|
135
|
+
// Idempotent upload (SF6): if the exact candidate already exists in the
|
|
136
|
+
// uploads dir, a retry of the same request must not consume quota again —
|
|
137
|
+
// the bytes are already accounted for. This prevents a spurious quota
|
|
138
|
+
// error when an idempotent retry arrives near the staging limit.
|
|
139
|
+
const existing = await lstat(uploadPath).catch(() => undefined);
|
|
140
|
+
if (existing) {
|
|
141
|
+
const existingBytes = await readFileBoundarySafe({
|
|
142
|
+
boundaryRoot: path.dirname(uploadPath),
|
|
143
|
+
targetPath: uploadPath,
|
|
144
|
+
label: "artifact revision upload",
|
|
145
|
+
});
|
|
146
|
+
if (createHash("sha256").update(existingBytes).digest("hex") !== input.candidateSha256) {
|
|
147
|
+
throw new Error("ARTIFACT_REVISION_DRIFT: existing staged upload hash mismatch");
|
|
148
|
+
}
|
|
149
|
+
return uploadPath;
|
|
150
|
+
}
|
|
151
|
+
assertStagingQuota({
|
|
152
|
+
appData: input.appData,
|
|
153
|
+
incomingBytes: input.candidateBytes.byteLength,
|
|
154
|
+
quotaBytes: input.quotaBytes,
|
|
155
|
+
});
|
|
156
|
+
const stagedBytes = await totalArtifactRevisionStagingBytes(input.appData);
|
|
157
|
+
const quota = input.quotaBytes ?? ARTIFACT_REVISION_STAGING_QUOTA_BYTES;
|
|
158
|
+
if (stagedBytes + input.candidateBytes.byteLength > quota) {
|
|
159
|
+
throw new Error("ARTIFACT_REVISION_QUOTA_EXCEEDED: repo staging quota exhausted");
|
|
160
|
+
}
|
|
161
|
+
ensureSecureDir(path.dirname(uploadPath));
|
|
162
|
+
await writeFile(uploadPath, input.candidateBytes, { flag: "wx", mode: 0o600 });
|
|
163
|
+
return uploadPath;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
export async function createArtifactRevision(input) {
|
|
167
|
+
return withRepoStagingLock(input.appData, async () => {
|
|
168
|
+
const revisionId = randomBytes(16).toString("hex");
|
|
169
|
+
const revisionDir = dir(input.appData, revisionId);
|
|
170
|
+
ensureSecureDir(revisionDir);
|
|
171
|
+
const now = input.now ?? new Date();
|
|
172
|
+
const ttlMs = input.ttlMs ?? DEFAULT_ARTIFACT_REVISION_TTL_MS;
|
|
173
|
+
if (!Number.isSafeInteger(ttlMs) || ttlMs < 1)
|
|
174
|
+
throw new Error("invalid revision TTL");
|
|
175
|
+
assertStagingQuota({
|
|
176
|
+
appData: input.appData,
|
|
177
|
+
incomingBytes: input.candidateBytes.byteLength,
|
|
178
|
+
quotaBytes: input.quotaBytes,
|
|
179
|
+
});
|
|
180
|
+
const stagedBytes = await totalArtifactRevisionStagingBytes(input.appData);
|
|
181
|
+
const quota = input.quotaBytes ?? ARTIFACT_REVISION_STAGING_QUOTA_BYTES;
|
|
182
|
+
if (stagedBytes + input.candidateBytes.byteLength > quota) {
|
|
183
|
+
throw new Error("ARTIFACT_REVISION_QUOTA_EXCEEDED: repo staging quota exhausted");
|
|
184
|
+
}
|
|
185
|
+
const stagingPath = `artifact-revisions/${revisionId}/candidate.bin`;
|
|
186
|
+
const described = validateAndDescribeArtifactRevision({
|
|
187
|
+
revisionId,
|
|
188
|
+
parentRunId: input.parentRunId,
|
|
189
|
+
producerNodeId: input.producerNodeId,
|
|
190
|
+
declaration: input.declaration,
|
|
191
|
+
parentSha256: input.parentSha256,
|
|
192
|
+
parentBytes: input.parentBytes,
|
|
193
|
+
candidateBytes: input.candidateBytes,
|
|
194
|
+
mediaType: input.mediaType,
|
|
195
|
+
stagingPath,
|
|
196
|
+
uploadedAt: now.toISOString(),
|
|
197
|
+
expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
|
|
198
|
+
...(input.artifactSchemaResolver
|
|
199
|
+
? { artifactSchemaResolver: input.artifactSchemaResolver }
|
|
200
|
+
: {}),
|
|
201
|
+
});
|
|
202
|
+
await writeFile(path.join(revisionDir, "candidate.bin"), input.candidateBytes, {
|
|
203
|
+
flag: "wx",
|
|
204
|
+
mode: 0o600,
|
|
205
|
+
});
|
|
206
|
+
if (described.diffPreview) {
|
|
207
|
+
await writeFile(path.join(revisionDir, "diff.txt"), described.diffPreview, {
|
|
208
|
+
flag: "wx",
|
|
209
|
+
mode: 0o600,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
await writeSecureJson(metadataPath(input.appData, revisionId), described.revision);
|
|
213
|
+
return described.revision;
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
export async function readArtifactRevision(appData, revisionId) {
|
|
217
|
+
const raw = await readJsonIfExists(metadataPath(appData, revisionId));
|
|
218
|
+
if (raw === undefined)
|
|
219
|
+
return undefined;
|
|
220
|
+
return artifactRevisionV1Schema.parse(raw);
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Explicit staging lease (M4c). A fresh lease held by another holder blocks
|
|
224
|
+
* conflicting mutations with LEASE_HELD; the same holder may renew (sliding
|
|
225
|
+
* TTL); a stale/expired lease is taken over. Recorded in metadata.json so a
|
|
226
|
+
* crashed holder stays visible until the TTL lapses.
|
|
227
|
+
*/
|
|
228
|
+
export async function acquireArtifactRevisionLease(input) {
|
|
229
|
+
return withRevisionMutationLock(input.appData, input.revisionId, async () => {
|
|
230
|
+
const current = await readArtifactRevision(input.appData, input.revisionId);
|
|
231
|
+
if (!current || current.state !== "validated") {
|
|
232
|
+
throw new Error("ARTIFACT_REVISION_EXPIRED");
|
|
233
|
+
}
|
|
234
|
+
const now = input.now ?? new Date();
|
|
235
|
+
const ttlMs = input.ttlMs ?? DEFAULT_REVISION_LEASE_TTL_MS;
|
|
236
|
+
if (!Number.isSafeInteger(ttlMs) || ttlMs < 1)
|
|
237
|
+
throw new Error("invalid lease TTL");
|
|
238
|
+
const existing = current.lease;
|
|
239
|
+
if (existing && Date.parse(existing.expiresAt) > now.getTime() && existing.holder !== input.holder) {
|
|
240
|
+
throw new Error("LEASE_HELD");
|
|
241
|
+
}
|
|
242
|
+
const leased = artifactRevisionV1Schema.parse({
|
|
243
|
+
...current,
|
|
244
|
+
lease: {
|
|
245
|
+
holder: input.holder,
|
|
246
|
+
acquiredAt: now.toISOString(),
|
|
247
|
+
expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
await writeSecureJson(metadataPath(input.appData, input.revisionId), leased);
|
|
251
|
+
return leased;
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
export async function releaseArtifactRevisionLease(input) {
|
|
255
|
+
return withRevisionMutationLock(input.appData, input.revisionId, async () => {
|
|
256
|
+
const current = await readArtifactRevision(input.appData, input.revisionId);
|
|
257
|
+
if (!current)
|
|
258
|
+
throw new Error("ARTIFACT_REVISION_EXPIRED");
|
|
259
|
+
if (!current.lease)
|
|
260
|
+
return current;
|
|
261
|
+
if (current.lease.holder !== input.holder)
|
|
262
|
+
throw new Error("LEASE_HELD");
|
|
263
|
+
const { lease: _released, ...withoutLease } = current;
|
|
264
|
+
const released = artifactRevisionV1Schema.parse(withoutLease);
|
|
265
|
+
await writeSecureJson(metadataPath(input.appData, input.revisionId), released);
|
|
266
|
+
return released;
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
export async function readArtifactRevisionCandidate(input) {
|
|
270
|
+
if (input.revision.state !== "validated")
|
|
271
|
+
throw new Error("ARTIFACT_REVISION_EXPIRED");
|
|
272
|
+
if (Date.parse(input.revision.expiresAt) <= (input.now ?? new Date()).getTime()) {
|
|
273
|
+
throw new Error("ARTIFACT_REVISION_EXPIRED");
|
|
274
|
+
}
|
|
275
|
+
const candidatePath = path.join(dir(input.appData, input.revision.revisionId), "candidate.bin");
|
|
276
|
+
// fd-based TOCTOU-safe read: open with O_NOFOLLOW and verify the opened fd
|
|
277
|
+
// (regular single-link) instead of lstat-then-readFile on the path. Map any
|
|
278
|
+
// path-safety rejection back to the stable ARTIFACT_REVISION_PATH_INVALID
|
|
279
|
+
// contract.
|
|
280
|
+
let bytes;
|
|
281
|
+
try {
|
|
282
|
+
bytes = await readFileBoundarySafe({
|
|
283
|
+
boundaryRoot: dir(input.appData, input.revision.revisionId),
|
|
284
|
+
targetPath: candidatePath,
|
|
285
|
+
label: `revision candidate ${input.revision.revisionId}`,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
290
|
+
if (/symlink|hardlink|single-link|regular|boundary|escap/i.test(message)) {
|
|
291
|
+
throw new Error("ARTIFACT_REVISION_PATH_INVALID");
|
|
292
|
+
}
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
const actual = await import("node:crypto").then(({ createHash }) => createHash("sha256").update(bytes).digest("hex"));
|
|
296
|
+
if (actual !== input.revision.candidate.sha256 || bytes.byteLength !== input.revision.candidate.bytes) {
|
|
297
|
+
throw new Error("ARTIFACT_REVISION_DRIFT");
|
|
298
|
+
}
|
|
299
|
+
return bytes;
|
|
300
|
+
}
|
|
301
|
+
export function buildArtifactOverrideSet(revision) {
|
|
302
|
+
const overrides = [{
|
|
303
|
+
revisionId: revision.revisionId,
|
|
304
|
+
parentArtifact: {
|
|
305
|
+
runId: revision.parent.runId,
|
|
306
|
+
nodeId: revision.parent.nodeId,
|
|
307
|
+
path: revision.parent.artifactPath,
|
|
308
|
+
sha256: revision.parent.artifactSha256,
|
|
309
|
+
},
|
|
310
|
+
replacement: {
|
|
311
|
+
sha256: revision.candidate.sha256,
|
|
312
|
+
bytes: revision.candidate.bytes,
|
|
313
|
+
mediaType: revision.candidate.mediaType,
|
|
314
|
+
},
|
|
315
|
+
supersedes: revision.parent.artifactSha256,
|
|
316
|
+
}];
|
|
317
|
+
return { schemaVersion: 1, overrides, overrideSetHash: computeOverrideSetHash(overrides) };
|
|
318
|
+
}
|
|
319
|
+
export async function consumeArtifactRevision(input) {
|
|
320
|
+
return withRevisionMutationLock(input.appData, input.revisionId, async () => {
|
|
321
|
+
const current = await readArtifactRevision(input.appData, input.revisionId);
|
|
322
|
+
if (!current || current.state !== "validated")
|
|
323
|
+
throw new Error("ARTIFACT_REVISION_EXPIRED");
|
|
324
|
+
const now = input.now ?? new Date();
|
|
325
|
+
const existing = current.lease;
|
|
326
|
+
if (existing &&
|
|
327
|
+
Date.parse(existing.expiresAt) > now.getTime() &&
|
|
328
|
+
existing.holder !== (input.holder ?? "cli-mutation")) {
|
|
329
|
+
throw new Error("LEASE_HELD");
|
|
330
|
+
}
|
|
331
|
+
if (current.candidate.sha256 !== input.expectedSha256)
|
|
332
|
+
throw new Error("ARTIFACT_REVISION_DRIFT");
|
|
333
|
+
// Terminal states must never keep a live staging lock.
|
|
334
|
+
const consumed = artifactRevisionV1Schema.parse({
|
|
335
|
+
...current,
|
|
336
|
+
state: "consumed",
|
|
337
|
+
lease: undefined,
|
|
338
|
+
});
|
|
339
|
+
await writeSecureJson(metadataPath(input.appData, input.revisionId), consumed);
|
|
340
|
+
return consumed;
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
export async function discardArtifactRevision(input) {
|
|
344
|
+
return withRevisionMutationLock(input.appData, input.revisionId, async () => {
|
|
345
|
+
const current = await readArtifactRevision(input.appData, input.revisionId);
|
|
346
|
+
if (!current)
|
|
347
|
+
throw new Error("ARTIFACT_REVISION_EXPIRED");
|
|
348
|
+
const now = input.now ?? new Date();
|
|
349
|
+
if (current.state === "discarded")
|
|
350
|
+
return current;
|
|
351
|
+
if (current.state === "consumed")
|
|
352
|
+
throw new Error("ARTIFACT_REVISION_ALREADY_CONSUMED");
|
|
353
|
+
const existing = current.lease;
|
|
354
|
+
if (existing &&
|
|
355
|
+
Date.parse(existing.expiresAt) > now.getTime() &&
|
|
356
|
+
existing.holder !== (input.holder ?? "cli-mutation")) {
|
|
357
|
+
throw new Error("LEASE_HELD");
|
|
358
|
+
}
|
|
359
|
+
const discarded = artifactRevisionV1Schema.parse({
|
|
360
|
+
...current,
|
|
361
|
+
state: "discarded",
|
|
362
|
+
lease: undefined,
|
|
363
|
+
});
|
|
364
|
+
await writeSecureJson(metadataPath(input.appData, input.revisionId), discarded);
|
|
365
|
+
await Promise.all([
|
|
366
|
+
rm(path.join(dir(input.appData, input.revisionId), "candidate.bin"), { force: true }),
|
|
367
|
+
rm(path.join(dir(input.appData, input.revisionId), "diff.txt"), { force: true }),
|
|
368
|
+
]);
|
|
369
|
+
return discarded;
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
export async function reconcileArtifactRevisions(input) {
|
|
373
|
+
ensureSecureDir(root(input.appData));
|
|
374
|
+
const now = input.now ?? new Date();
|
|
375
|
+
const removed = [];
|
|
376
|
+
for (const name of await readdir(root(input.appData))) {
|
|
377
|
+
if (!/^[a-f0-9]{32}$/.test(name))
|
|
378
|
+
continue;
|
|
379
|
+
const revision = await readArtifactRevision(input.appData, name).catch(() => undefined);
|
|
380
|
+
if (!revision || Date.parse(revision.expiresAt) <= now.getTime()) {
|
|
381
|
+
await rm(dir(input.appData, name), { recursive: true, force: true });
|
|
382
|
+
removed.push(name);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const uploadsDir = path.join(root(input.appData), "uploads");
|
|
386
|
+
const uploadsBefore = await lstat(uploadsDir).catch(() => undefined);
|
|
387
|
+
if (uploadsBefore?.isDirectory()) {
|
|
388
|
+
const cutoff = now.getTime() - DEFAULT_ARTIFACT_REVISION_TTL_MS;
|
|
389
|
+
for (const entry of await readdir(uploadsDir)) {
|
|
390
|
+
if (!/^[a-f0-9]{64}\.bin$/.test(entry))
|
|
391
|
+
continue;
|
|
392
|
+
const stat = await lstat(path.join(uploadsDir, entry)).catch(() => undefined);
|
|
393
|
+
if (!stat?.isFile() || stat.mtimeMs > cutoff)
|
|
394
|
+
continue;
|
|
395
|
+
await rm(path.join(uploadsDir, entry), { force: true });
|
|
396
|
+
removed.push(`uploads/${entry}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
// Sweep orphaned staging-lock tombstones (takeover `.tomb` and release
|
|
400
|
+
// `.release` dirs left behind by a race) once they are older than the
|
|
401
|
+
// default TTL. Tombstones are never the live lock path.
|
|
402
|
+
const cutoff = now.getTime() - DEFAULT_ARTIFACT_REVISION_TTL_MS;
|
|
403
|
+
for (const entry of await readdir(root(input.appData))) {
|
|
404
|
+
if (!/^\.staging-lock\..+\.(?:tomb|release)$/.test(entry))
|
|
405
|
+
continue;
|
|
406
|
+
const stat = await lstat(path.join(root(input.appData), entry)).catch(() => undefined);
|
|
407
|
+
if (!stat?.isDirectory() || stat.mtimeMs > cutoff)
|
|
408
|
+
continue;
|
|
409
|
+
await rm(path.join(root(input.appData), entry), { recursive: true, force: true });
|
|
410
|
+
removed.push(`lock/${entry}`);
|
|
411
|
+
}
|
|
412
|
+
// Sweep orphaned per-revision mutation lock dirs (crashed holder) once they
|
|
413
|
+
// are older than the default TTL. The per-revision lock path is stable
|
|
414
|
+
// (`locks/<revisionId>.lock`); a fresh lock is never swept.
|
|
415
|
+
const locksDir = path.join(root(input.appData), "locks");
|
|
416
|
+
const locksStat = await lstat(locksDir).catch(() => undefined);
|
|
417
|
+
if (locksStat?.isDirectory()) {
|
|
418
|
+
for (const entry of await readdir(locksDir)) {
|
|
419
|
+
if (!/^[a-f0-9]{32}\.lock$/.test(entry))
|
|
420
|
+
continue;
|
|
421
|
+
const lockPath = path.join(locksDir, entry);
|
|
422
|
+
const stat = await lstat(lockPath).catch(() => undefined);
|
|
423
|
+
if (!stat?.isDirectory() || !await isDirLockStale(lockPath, DEFAULT_ARTIFACT_REVISION_TTL_MS, now.getTime()))
|
|
424
|
+
continue;
|
|
425
|
+
await rm(lockPath, { recursive: true, force: true });
|
|
426
|
+
removed.push(`lock/${entry}`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return removed;
|
|
430
|
+
}
|