clinkx 0.2.3 → 1.0.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 +33 -0
- package/LICENSE +10 -0
- package/README.md +113 -18
- package/clinkx-workflows/dist/allowed-roots.d.ts +1 -0
- package/clinkx-workflows/dist/allowed-roots.js +5 -0
- package/clinkx-workflows/dist/allowed-roots.js.map +1 -0
- package/clinkx-workflows/dist/artifacts.d.ts +20 -1
- package/clinkx-workflows/dist/artifacts.js +38 -9
- package/clinkx-workflows/dist/artifacts.js.map +1 -1
- package/clinkx-workflows/dist/backend.d.ts +4 -0
- package/clinkx-workflows/dist/citation-check.d.ts +40 -0
- package/clinkx-workflows/dist/citation-check.js +348 -0
- package/clinkx-workflows/dist/citation-check.js.map +1 -0
- package/clinkx-workflows/dist/clink-client.d.ts +2 -1
- package/clinkx-workflows/dist/clink-client.js +17 -4
- package/clinkx-workflows/dist/clink-client.js.map +1 -1
- package/clinkx-workflows/dist/config.d.ts +145 -16
- package/clinkx-workflows/dist/config.js +384 -26
- package/clinkx-workflows/dist/config.js.map +1 -1
- package/clinkx-workflows/dist/contained-file.d.ts +172 -0
- package/clinkx-workflows/dist/contained-file.js +483 -0
- package/clinkx-workflows/dist/contained-file.js.map +1 -0
- package/clinkx-workflows/dist/context-builder-lifecycle-contract.d.ts +416 -0
- package/clinkx-workflows/dist/context-builder-lifecycle-contract.js +328 -0
- package/clinkx-workflows/dist/context-builder-lifecycle-contract.js.map +1 -0
- package/clinkx-workflows/dist/context-builder-lifecycle-router.d.ts +24 -0
- package/clinkx-workflows/dist/context-builder-lifecycle-router.js +39 -0
- package/clinkx-workflows/dist/context-builder-lifecycle-router.js.map +1 -0
- package/clinkx-workflows/dist/context-cache-key.d.ts +53 -0
- package/clinkx-workflows/dist/context-cache-key.js +91 -0
- package/clinkx-workflows/dist/context-cache-key.js.map +1 -0
- package/clinkx-workflows/dist/context-mode-resolver.d.ts +27 -0
- package/clinkx-workflows/dist/context-mode-resolver.js +126 -0
- package/clinkx-workflows/dist/context-mode-resolver.js.map +1 -0
- package/clinkx-workflows/dist/context-preflight.d.ts +184 -0
- package/clinkx-workflows/dist/context-preflight.js +91 -0
- package/clinkx-workflows/dist/context-preflight.js.map +1 -0
- package/clinkx-workflows/dist/council-expander.d.ts +1 -1
- package/clinkx-workflows/dist/council-expander.js +2 -1
- package/clinkx-workflows/dist/council-expander.js.map +1 -1
- package/clinkx-workflows/dist/dashboard/activity-contract.d.ts +165 -0
- package/clinkx-workflows/dist/dashboard/activity-contract.js +105 -0
- package/clinkx-workflows/dist/dashboard/activity-contract.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/activity-lease.d.ts +25 -0
- package/clinkx-workflows/dist/dashboard/activity-lease.js +34 -0
- package/clinkx-workflows/dist/dashboard/activity-lease.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/activity-reader.d.ts +86 -0
- package/clinkx-workflows/dist/dashboard/activity-reader.js +480 -0
- package/clinkx-workflows/dist/dashboard/activity-reader.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/api-routes.d.ts +36 -0
- package/clinkx-workflows/dist/dashboard/api-routes.js +1047 -0
- package/clinkx-workflows/dist/dashboard/api-routes.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/artifact-index.d.ts +37 -0
- package/clinkx-workflows/dist/dashboard/artifact-index.js +145 -0
- package/clinkx-workflows/dist/dashboard/artifact-index.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/auth/challenge-store.d.ts +31 -0
- package/clinkx-workflows/dist/dashboard/auth/challenge-store.js +65 -0
- package/clinkx-workflows/dist/dashboard/auth/challenge-store.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/auth/controller.d.ts +26 -0
- package/clinkx-workflows/dist/dashboard/auth/controller.js +596 -0
- package/clinkx-workflows/dist/dashboard/auth/controller.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/auth/credential-store.d.ts +60 -0
- package/clinkx-workflows/dist/dashboard/auth/credential-store.js +776 -0
- package/clinkx-workflows/dist/dashboard/auth/credential-store.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/auth/enrollment-gate.d.ts +9 -0
- package/clinkx-workflows/dist/dashboard/auth/enrollment-gate.js +44 -0
- package/clinkx-workflows/dist/dashboard/auth/enrollment-gate.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/auth/session-store.d.ts +17 -0
- package/clinkx-workflows/dist/dashboard/auth/session-store.js +77 -0
- package/clinkx-workflows/dist/dashboard/auth/session-store.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/auth/webauthn-service.d.ts +25 -0
- package/clinkx-workflows/dist/dashboard/auth/webauthn-service.js +71 -0
- package/clinkx-workflows/dist/dashboard/auth/webauthn-service.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/context-run-liveness.d.ts +37 -0
- package/clinkx-workflows/dist/dashboard/context-run-liveness.js +49 -0
- package/clinkx-workflows/dist/dashboard/context-run-liveness.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/context-run-reader.d.ts +222 -0
- package/clinkx-workflows/dist/dashboard/context-run-reader.js +1178 -0
- package/clinkx-workflows/dist/dashboard/context-run-reader.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/contracts.d.ts +8936 -0
- package/clinkx-workflows/dist/dashboard/contracts.js +1157 -0
- package/clinkx-workflows/dist/dashboard/contracts.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/generation.d.ts +14 -0
- package/clinkx-workflows/dist/dashboard/generation.js +41 -0
- package/clinkx-workflows/dist/dashboard/generation.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/index.d.ts +28 -0
- package/clinkx-workflows/dist/dashboard/index.js +16 -0
- package/clinkx-workflows/dist/dashboard/index.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/lazy-content.d.ts +101 -0
- package/clinkx-workflows/dist/dashboard/lazy-content.js +748 -0
- package/clinkx-workflows/dist/dashboard/lazy-content.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/progress-report-contract.d.ts +303 -0
- package/clinkx-workflows/dist/dashboard/progress-report-contract.js +221 -0
- package/clinkx-workflows/dist/dashboard/progress-report-contract.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/progress-report-reader.d.ts +50 -0
- package/clinkx-workflows/dist/dashboard/progress-report-reader.js +581 -0
- package/clinkx-workflows/dist/dashboard/progress-report-reader.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/progress-report-status-overlay.d.ts +89 -0
- package/clinkx-workflows/dist/dashboard/progress-report-status-overlay.js +71 -0
- package/clinkx-workflows/dist/dashboard/progress-report-status-overlay.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/progress-report-status-store.d.ts +28 -0
- package/clinkx-workflows/dist/dashboard/progress-report-status-store.js +110 -0
- package/clinkx-workflows/dist/dashboard/progress-report-status-store.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/read-model.d.ts +86 -0
- package/clinkx-workflows/dist/dashboard/read-model.js +440 -0
- package/clinkx-workflows/dist/dashboard/read-model.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/request-admission.d.ts +31 -0
- package/clinkx-workflows/dist/dashboard/request-admission.js +126 -0
- package/clinkx-workflows/dist/dashboard/request-admission.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/scanner.d.ts +75 -0
- package/clinkx-workflows/dist/dashboard/scanner.js +1110 -0
- package/clinkx-workflows/dist/dashboard/scanner.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/server-factory.d.ts +50 -0
- package/clinkx-workflows/dist/dashboard/server-factory.js +431 -0
- package/clinkx-workflows/dist/dashboard/server-factory.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/session-log-contract.d.ts +2611 -0
- package/clinkx-workflows/dist/dashboard/session-log-contract.js +480 -0
- package/clinkx-workflows/dist/dashboard/session-log-contract.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/session-log-identity.d.ts +21 -0
- package/clinkx-workflows/dist/dashboard/session-log-identity.js +101 -0
- package/clinkx-workflows/dist/dashboard/session-log-identity.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/session-log-journal.d.ts +43 -0
- package/clinkx-workflows/dist/dashboard/session-log-journal.js +522 -0
- package/clinkx-workflows/dist/dashboard/session-log-journal.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/session-log-reader.d.ts +61 -0
- package/clinkx-workflows/dist/dashboard/session-log-reader.js +378 -0
- package/clinkx-workflows/dist/dashboard/session-log-reader.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/session-log-redaction.d.ts +6 -0
- package/clinkx-workflows/dist/dashboard/session-log-redaction.js +367 -0
- package/clinkx-workflows/dist/dashboard/session-log-redaction.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/session-log-retention.d.ts +23 -0
- package/clinkx-workflows/dist/dashboard/session-log-retention.js +212 -0
- package/clinkx-workflows/dist/dashboard/session-log-retention.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/static-responses.d.ts +10 -0
- package/clinkx-workflows/dist/dashboard/static-responses.js +94 -0
- package/clinkx-workflows/dist/dashboard/static-responses.js.map +1 -0
- package/clinkx-workflows/dist/dashboard/worker-lock-observer.d.ts +28 -0
- package/clinkx-workflows/dist/dashboard/worker-lock-observer.js +241 -0
- package/clinkx-workflows/dist/dashboard/worker-lock-observer.js.map +1 -0
- package/clinkx-workflows/dist/dashboard-ui/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/clinkx-workflows/dist/dashboard-ui/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/clinkx-workflows/dist/dashboard-ui/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/clinkx-workflows/dist/dashboard-ui/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/clinkx-workflows/dist/dashboard-ui/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/clinkx-workflows/dist/dashboard-ui/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- package/clinkx-workflows/dist/dashboard-ui/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- package/clinkx-workflows/dist/dashboard-ui/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- package/clinkx-workflows/dist/dashboard-ui/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- package/clinkx-workflows/dist/dashboard-ui/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/clinkx-workflows/dist/dashboard-ui/assets/index-DOkMXTfB.js +25 -0
- package/clinkx-workflows/dist/dashboard-ui/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
- package/clinkx-workflows/dist/dashboard-ui/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/clinkx-workflows/dist/dashboard-ui/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
- package/clinkx-workflows/dist/dashboard-ui/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
- package/clinkx-workflows/dist/dashboard-ui/assets/style-ChJsn0KD.css +1 -0
- package/clinkx-workflows/dist/dashboard-ui/contracts/council-plan-review-input-v1.json +158 -0
- package/clinkx-workflows/dist/dashboard-ui/index.html +15 -0
- package/clinkx-workflows/dist/dashboard-ui/licenses/IBM-Plex-Mono-OFL.txt +93 -0
- package/clinkx-workflows/dist/dashboard-ui/licenses/IBM-Plex-Sans-OFL.txt +93 -0
- package/clinkx-workflows/dist/dashboard-ui/licenses/JetBrains-Mono-OFL.txt +93 -0
- package/clinkx-workflows/dist/dashboard-ui/licenses/NOTICE.md +32 -0
- package/clinkx-workflows/dist/dashboard-ui/theme-init.js +8 -0
- package/clinkx-workflows/dist/definition-normalizer.d.ts +19 -0
- package/clinkx-workflows/dist/definition-normalizer.js.map +1 -1
- package/clinkx-workflows/dist/delegate-validate.d.ts +78 -0
- package/clinkx-workflows/dist/delegate-validate.js +97 -0
- package/clinkx-workflows/dist/delegate-validate.js.map +1 -0
- package/clinkx-workflows/dist/engine.d.ts +141 -0
- package/clinkx-workflows/dist/engine.js +882 -28
- package/clinkx-workflows/dist/engine.js.map +1 -1
- package/clinkx-workflows/dist/errors.d.ts +2 -0
- package/clinkx-workflows/dist/errors.js.map +1 -1
- package/clinkx-workflows/dist/fingerprint.d.ts +1 -1
- package/clinkx-workflows/dist/fingerprint.js +1 -1
- package/clinkx-workflows/dist/index.d.ts +19 -0
- package/clinkx-workflows/dist/index.js +15 -0
- package/clinkx-workflows/dist/index.js.map +1 -1
- package/clinkx-workflows/dist/loader.d.ts +9 -3
- package/clinkx-workflows/dist/loader.js +92 -21
- package/clinkx-workflows/dist/loader.js.map +1 -1
- package/clinkx-workflows/dist/plan-review/admission.d.ts +160 -0
- package/clinkx-workflows/dist/plan-review/admission.js +290 -0
- package/clinkx-workflows/dist/plan-review/admission.js.map +1 -0
- package/clinkx-workflows/dist/plan-review/context-policy.d.ts +55 -0
- package/clinkx-workflows/dist/plan-review/context-policy.js +146 -0
- package/clinkx-workflows/dist/plan-review/context-policy.js.map +1 -0
- package/clinkx-workflows/dist/plan-review/contract.d.ts +763 -0
- package/clinkx-workflows/dist/plan-review/contract.js +289 -0
- package/clinkx-workflows/dist/plan-review/contract.js.map +1 -0
- package/clinkx-workflows/dist/plan-review/digest.d.ts +68 -0
- package/clinkx-workflows/dist/plan-review/digest.js +241 -0
- package/clinkx-workflows/dist/plan-review/digest.js.map +1 -0
- package/clinkx-workflows/dist/plan-review/discovery.d.ts +45 -0
- package/clinkx-workflows/dist/plan-review/discovery.js +36 -0
- package/clinkx-workflows/dist/plan-review/discovery.js.map +1 -0
- package/clinkx-workflows/dist/plan-review/execution-contract.d.ts +298 -0
- package/clinkx-workflows/dist/plan-review/execution-contract.js +194 -0
- package/clinkx-workflows/dist/plan-review/execution-contract.js.map +1 -0
- package/clinkx-workflows/dist/plan-review/expander.d.ts +48 -0
- package/clinkx-workflows/dist/plan-review/expander.js +243 -0
- package/clinkx-workflows/dist/plan-review/expander.js.map +1 -0
- package/clinkx-workflows/dist/plan-review/projector.d.ts +126 -0
- package/clinkx-workflows/dist/plan-review/projector.js +437 -0
- package/clinkx-workflows/dist/plan-review/projector.js.map +1 -0
- package/clinkx-workflows/dist/plan-review/publication.d.ts +95 -0
- package/clinkx-workflows/dist/plan-review/publication.js +182 -0
- package/clinkx-workflows/dist/plan-review/publication.js.map +1 -0
- package/clinkx-workflows/dist/plan-review/ranking-reducer.d.ts +34 -0
- package/clinkx-workflows/dist/plan-review/ranking-reducer.js +139 -0
- package/clinkx-workflows/dist/plan-review/ranking-reducer.js.map +1 -0
- package/clinkx-workflows/dist/plan-review/resume.d.ts +38 -0
- package/clinkx-workflows/dist/plan-review/resume.js +122 -0
- package/clinkx-workflows/dist/plan-review/resume.js.map +1 -0
- package/clinkx-workflows/dist/plan-review/rubric.d.ts +7 -0
- package/clinkx-workflows/dist/plan-review/rubric.js +24 -0
- package/clinkx-workflows/dist/plan-review/rubric.js.map +1 -0
- package/clinkx-workflows/dist/plan-review/run-state.d.ts +398 -0
- package/clinkx-workflows/dist/plan-review/run-state.js +183 -0
- package/clinkx-workflows/dist/plan-review/run-state.js.map +1 -0
- package/clinkx-workflows/dist/plan-review/runtime-policy.d.ts +74 -0
- package/clinkx-workflows/dist/plan-review/runtime-policy.js +186 -0
- package/clinkx-workflows/dist/plan-review/runtime-policy.js.map +1 -0
- package/clinkx-workflows/dist/plan-review/snapshot.d.ts +81 -0
- package/clinkx-workflows/dist/plan-review/snapshot.js +170 -0
- package/clinkx-workflows/dist/plan-review/snapshot.js.map +1 -0
- package/clinkx-workflows/dist/plan-review/stage-error.d.ts +19 -0
- package/clinkx-workflows/dist/plan-review/stage-error.js +20 -0
- package/clinkx-workflows/dist/plan-review/stage-error.js.map +1 -0
- package/clinkx-workflows/dist/post-processors/index.d.ts +20 -0
- package/clinkx-workflows/dist/post-processors/index.js +21 -0
- package/clinkx-workflows/dist/post-processors/index.js.map +1 -0
- package/clinkx-workflows/dist/prompt-budget.d.ts +7 -0
- package/clinkx-workflows/dist/prompt-budget.js +7 -1
- package/clinkx-workflows/dist/prompt-budget.js.map +1 -1
- package/clinkx-workflows/dist/reducers/index.js +2 -0
- package/clinkx-workflows/dist/reducers/index.js.map +1 -1
- package/clinkx-workflows/dist/run-lock-heartbeat.d.ts +2 -0
- package/clinkx-workflows/dist/run-lock-heartbeat.js +3 -0
- package/clinkx-workflows/dist/run-lock-heartbeat.js.map +1 -0
- package/clinkx-workflows/dist/run-summary/cards/council-plan-review.d.ts +34 -0
- package/clinkx-workflows/dist/run-summary/cards/council-plan-review.js +74 -0
- package/clinkx-workflows/dist/run-summary/cards/council-plan-review.js.map +1 -0
- package/clinkx-workflows/dist/run-summary/cards/index.js +5 -0
- package/clinkx-workflows/dist/run-summary/cards/index.js.map +1 -1
- package/clinkx-workflows/dist/run-summary.d.ts +13 -0
- package/clinkx-workflows/dist/run-summary.js +29 -1
- package/clinkx-workflows/dist/run-summary.js.map +1 -1
- package/clinkx-workflows/dist/schema.d.ts +76 -21
- package/clinkx-workflows/dist/schema.js +10 -1
- package/clinkx-workflows/dist/schema.js.map +1 -1
- package/clinkx-workflows/dist/seed-terms.d.ts +30 -0
- package/clinkx-workflows/dist/seed-terms.js +176 -0
- package/clinkx-workflows/dist/seed-terms.js.map +1 -0
- package/clinkx-workflows/dist/server.d.ts +5 -2
- package/clinkx-workflows/dist/server.js +82 -52
- package/clinkx-workflows/dist/server.js.map +1 -1
- package/clinkx-workflows/dist/shutdown.d.ts +18 -20
- package/clinkx-workflows/dist/shutdown.js +43 -61
- package/clinkx-workflows/dist/shutdown.js.map +1 -1
- package/clinkx-workflows/dist/state-schema.d.ts +38 -0
- package/clinkx-workflows/dist/state.d.ts +170 -3
- package/clinkx-workflows/dist/state.js +1275 -28
- package/clinkx-workflows/dist/state.js.map +1 -1
- package/clinkx-workflows/dist/transport.d.ts +15 -0
- package/clinkx-workflows/dist/transport.js +19 -10
- package/clinkx-workflows/dist/transport.js.map +1 -1
- package/clinkx-workflows/dist/types.d.ts +14 -2
- package/clinkx-workflows/dist/validators/council.d.ts +318 -85
- package/clinkx-workflows/dist/validators/council.js +52 -13
- package/clinkx-workflows/dist/validators/council.js.map +1 -1
- package/clinkx-workflows/dist/validators/index.d.ts +1 -0
- package/clinkx-workflows/dist/validators/index.js +3 -0
- package/clinkx-workflows/dist/validators/index.js.map +1 -1
- package/clinkx-workflows/dist/validators/plan-review.d.ts +398 -0
- package/clinkx-workflows/dist/validators/plan-review.js +316 -0
- package/clinkx-workflows/dist/validators/plan-review.js.map +1 -0
- package/clinkx-workflows/dist/workflow-receipt.js +188 -4
- package/clinkx-workflows/dist/workflow-receipt.js.map +1 -1
- package/clinkx-workflows/dist/workflow-tools.d.ts +48 -3
- package/clinkx-workflows/dist/workflow-tools.js +1402 -59
- package/clinkx-workflows/dist/workflow-tools.js.map +1 -1
- package/clinkx-workflows/dist/workspace-signature.d.ts +16 -0
- package/clinkx-workflows/dist/workspace-signature.js +388 -0
- package/clinkx-workflows/dist/workspace-signature.js.map +1 -0
- package/clinkx-workflows/templates/council/answer-specialist.txt +2 -1
- package/clinkx-workflows/templates/council/code-review-chairman.txt +13 -0
- package/clinkx-workflows/templates/council/code-review-cross-reviewer.txt +4 -1
- package/clinkx-workflows/templates/council/code-review-reviewer.txt +19 -3
- package/clinkx-workflows/templates/council/debug-debugger.txt +2 -1
- package/clinkx-workflows/templates/council/discover-analyst.txt +2 -1
- package/clinkx-workflows/templates/council/member.txt +2 -1
- package/clinkx-workflows/templates/council/plan-review-chairman.txt +68 -0
- package/clinkx-workflows/templates/council/plan-review-cross-reviewer.txt +51 -0
- package/clinkx-workflows/templates/council/plan-review-reviewer.txt +60 -0
- package/clinkx-workflows/templates/plan-review/chairman.txt +48 -0
- package/clinkx-workflows/templates/plan-review/cross-review.txt +52 -0
- package/clinkx-workflows/templates/plan-review/ranking.txt +16 -0
- package/clinkx-workflows/templates/plan-review/reviewer.txt +57 -0
- package/clinkx-workflows/workflows/council-answer.yaml +97 -8
- package/clinkx-workflows/workflows/council-code-review.yaml +103 -8
- package/clinkx-workflows/workflows/council-debug.yaml +97 -8
- package/clinkx-workflows/workflows/council-default.yaml +97 -8
- package/clinkx-workflows/workflows/council-discover.yaml +97 -8
- package/clinkx-workflows/workflows/council-plan-review.yaml +250 -0
- package/clinkx-workflows/workflows/plan-review-receipts.yaml +230 -0
- package/conf/adapters/agy.json +49 -0
- package/conf/adapters/claude.json +5 -0
- package/conf/adapters/codex-app-server.json +44 -0
- package/conf/adapters/codex.json +7 -2
- package/conf/adapters/glm.json +5 -0
- package/conf/adapters/grok.json +60 -0
- package/conf/adapters/hapi/agy.json +52 -0
- package/conf/adapters/hapi/claude.json +5 -0
- package/conf/adapters/hapi/codex.json +7 -2
- package/conf/adapters/hapi/glm.json +5 -0
- package/conf/prompts/json-codereviewer.txt +1 -0
- package/conf/prompts/json.txt +1 -0
- package/dist/activity-journal.d.ts +42 -0
- package/dist/activity-journal.js +381 -0
- package/dist/activity-journal.js.map +1 -0
- package/dist/allowed-roots.d.ts +53 -0
- package/dist/allowed-roots.js +216 -0
- package/dist/allowed-roots.js.map +1 -0
- package/dist/codex/activity-session-log.d.ts +14 -0
- package/dist/codex/activity-session-log.js +206 -0
- package/dist/codex/activity-session-log.js.map +1 -0
- package/dist/codex/agent-tools.d.ts +23 -0
- package/dist/codex/agent-tools.js +1270 -0
- package/dist/codex/agent-tools.js.map +1 -0
- package/dist/codex/app-server-client.d.ts +76 -0
- package/dist/codex/app-server-client.js +582 -0
- package/dist/codex/app-server-client.js.map +1 -0
- package/dist/codex/app-server-registry.d.ts +389 -0
- package/dist/codex/app-server-registry.js +1407 -0
- package/dist/codex/app-server-registry.js.map +1 -0
- package/dist/codex/client-types.d.ts +109 -0
- package/dist/codex/client-types.js +2 -0
- package/dist/codex/client-types.js.map +1 -0
- package/dist/codex/protocol.d.ts +5375 -0
- package/dist/codex/protocol.js +614 -0
- package/dist/codex/protocol.js.map +1 -0
- package/dist/codex/session.d.ts +263 -0
- package/dist/codex/session.js +466 -0
- package/dist/codex/session.js.map +1 -0
- package/dist/config.d.ts +185 -19
- package/dist/config.js +82 -16
- package/dist/config.js.map +1 -1
- package/dist/context-builder/anchors.d.ts +12 -0
- package/dist/context-builder/anchors.js +22 -0
- package/dist/context-builder/anchors.js.map +1 -0
- package/dist/context-builder/bundle.d.ts +109 -0
- package/dist/context-builder/bundle.js +1498 -0
- package/dist/context-builder/bundle.js.map +1 -0
- package/dist/context-builder/codemap.d.ts +22 -0
- package/dist/context-builder/codemap.js +675 -0
- package/dist/context-builder/codemap.js.map +1 -0
- package/dist/context-builder/config.d.ts +61 -0
- package/dist/context-builder/config.js +216 -0
- package/dist/context-builder/config.js.map +1 -0
- package/dist/context-builder/context-run-catalog.d.ts +17 -0
- package/dist/context-builder/context-run-catalog.js +99 -0
- package/dist/context-builder/context-run-catalog.js.map +1 -0
- package/dist/context-builder/delegate-packet.d.ts +219 -0
- package/dist/context-builder/delegate-packet.js +1190 -0
- package/dist/context-builder/delegate-packet.js.map +1 -0
- package/dist/context-builder/delegate-reconstruction.d.ts +13 -0
- package/dist/context-builder/delegate-reconstruction.js +220 -0
- package/dist/context-builder/delegate-reconstruction.js.map +1 -0
- package/dist/context-builder/delegate-validation.d.ts +27 -0
- package/dist/context-builder/delegate-validation.js +479 -0
- package/dist/context-builder/delegate-validation.js.map +1 -0
- package/dist/context-builder/delegate.d.ts +26 -0
- package/dist/context-builder/delegate.js +134 -0
- package/dist/context-builder/delegate.js.map +1 -0
- package/dist/context-builder/diagnostics.d.ts +36 -0
- package/dist/context-builder/diagnostics.js +107 -0
- package/dist/context-builder/diagnostics.js.map +1 -0
- package/dist/context-builder/discovery.d.ts +146 -0
- package/dist/context-builder/discovery.js +2129 -0
- package/dist/context-builder/discovery.js.map +1 -0
- package/dist/context-builder/globs.d.ts +14 -0
- package/dist/context-builder/globs.js +175 -0
- package/dist/context-builder/globs.js.map +1 -0
- package/dist/context-builder/handler.d.ts +41 -0
- package/dist/context-builder/handler.js +2590 -0
- package/dist/context-builder/handler.js.map +1 -0
- package/dist/context-builder/lifecycle-contract.d.ts +424 -0
- package/dist/context-builder/lifecycle-contract.js +523 -0
- package/dist/context-builder/lifecycle-contract.js.map +1 -0
- package/dist/context-builder/lifecycle-journal.d.ts +62 -0
- package/dist/context-builder/lifecycle-journal.js +88 -0
- package/dist/context-builder/lifecycle-journal.js.map +1 -0
- package/dist/context-builder/lifecycle-observer.d.ts +102 -0
- package/dist/context-builder/lifecycle-observer.js +258 -0
- package/dist/context-builder/lifecycle-observer.js.map +1 -0
- package/dist/context-builder/lifecycle-reconcile.d.ts +32 -0
- package/dist/context-builder/lifecycle-reconcile.js +191 -0
- package/dist/context-builder/lifecycle-reconcile.js.map +1 -0
- package/dist/context-builder/lifecycle-recovery.d.ts +2 -0
- package/dist/context-builder/lifecycle-recovery.js +14 -0
- package/dist/context-builder/lifecycle-recovery.js.map +1 -0
- package/dist/context-builder/path-signals.d.ts +7 -0
- package/dist/context-builder/path-signals.js +70 -0
- package/dist/context-builder/path-signals.js.map +1 -0
- package/dist/context-builder/paths.d.ts +30 -0
- package/dist/context-builder/paths.js +153 -0
- package/dist/context-builder/paths.js.map +1 -0
- package/dist/context-builder/progress.d.ts +17 -0
- package/dist/context-builder/progress.js +93 -0
- package/dist/context-builder/progress.js.map +1 -0
- package/dist/context-builder/receipt.d.ts +91 -0
- package/dist/context-builder/receipt.js +180 -0
- package/dist/context-builder/receipt.js.map +1 -0
- package/dist/context-builder/refine-handler.d.ts +14 -0
- package/dist/context-builder/refine-handler.js +790 -0
- package/dist/context-builder/refine-handler.js.map +1 -0
- package/dist/context-builder/review-coverage.d.ts +84 -0
- package/dist/context-builder/review-coverage.js +437 -0
- package/dist/context-builder/review-coverage.js.map +1 -0
- package/dist/context-builder/run-id.d.ts +7 -0
- package/dist/context-builder/run-id.js +31 -0
- package/dist/context-builder/run-id.js.map +1 -0
- package/dist/context-builder/schema.d.ts +669 -0
- package/dist/context-builder/schema.js +399 -0
- package/dist/context-builder/schema.js.map +1 -0
- package/dist/context-builder/secrets.d.ts +41 -0
- package/dist/context-builder/secrets.js +1651 -0
- package/dist/context-builder/secrets.js.map +1 -0
- package/dist/context-builder/selection.d.ts +56 -0
- package/dist/context-builder/selection.js +1111 -0
- package/dist/context-builder/selection.js.map +1 -0
- package/dist/context-builder/session-log-identity.d.ts +1 -0
- package/dist/context-builder/session-log-identity.js +66 -0
- package/dist/context-builder/session-log-identity.js.map +1 -0
- package/dist/context-builder/slice-range-math.d.ts +16 -0
- package/dist/context-builder/slice-range-math.js +137 -0
- package/dist/context-builder/slice-range-math.js.map +1 -0
- package/dist/context-builder/status.d.ts +66 -0
- package/dist/context-builder/status.js +160 -0
- package/dist/context-builder/status.js.map +1 -0
- package/dist/context-builder/storage.d.ts +118 -0
- package/dist/context-builder/storage.js +1003 -0
- package/dist/context-builder/storage.js.map +1 -0
- package/dist/context-builder/token-estimate.d.ts +19 -0
- package/dist/context-builder/token-estimate.js +90 -0
- package/dist/context-builder/token-estimate.js.map +1 -0
- package/dist/context-builder/versions.d.ts +29 -0
- package/dist/context-builder/versions.js +30 -0
- package/dist/context-builder/versions.js.map +1 -0
- package/dist/context-builder/workflow-runner.d.ts +69 -0
- package/dist/context-builder/workflow-runner.js +1051 -0
- package/dist/context-builder/workflow-runner.js.map +1 -0
- package/dist/context-builder/write-watchdog.d.ts +14 -0
- package/dist/context-builder/write-watchdog.js +38 -0
- package/dist/context-builder/write-watchdog.js.map +1 -0
- package/dist/dashboard-adapter-catalog.d.ts +9 -0
- package/dist/dashboard-adapter-catalog.js +46 -0
- package/dist/dashboard-adapter-catalog.js.map +1 -0
- package/dist/dashboard-cli.d.ts +79 -0
- package/dist/dashboard-cli.js +585 -0
- package/dist/dashboard-cli.js.map +1 -0
- package/dist/dashboard-companion/client.d.ts +36 -0
- package/dist/dashboard-companion/client.js +210 -0
- package/dist/dashboard-companion/client.js.map +1 -0
- package/dist/dashboard-companion/config.d.ts +8 -0
- package/dist/dashboard-companion/config.js +52 -0
- package/dist/dashboard-companion/config.js.map +1 -0
- package/dist/dashboard-companion/http-proxy.d.ts +10 -0
- package/dist/dashboard-companion/http-proxy.js +234 -0
- package/dist/dashboard-companion/http-proxy.js.map +1 -0
- package/dist/dashboard-companion/protocol.d.ts +80 -0
- package/dist/dashboard-companion/protocol.js +455 -0
- package/dist/dashboard-companion/protocol.js.map +1 -0
- package/dist/dashboard-companion/standalone-assets.d.ts +12 -0
- package/dist/dashboard-companion/standalone-assets.js +72 -0
- package/dist/dashboard-companion/standalone-assets.js.map +1 -0
- package/dist/dashboard-companion/standalone-entry.d.ts +1 -0
- package/dist/dashboard-companion/standalone-entry.js +17 -0
- package/dist/dashboard-companion/standalone-entry.js.map +1 -0
- package/dist/dashboard-gateway/config.d.ts +9 -0
- package/dist/dashboard-gateway/config.js +44 -0
- package/dist/dashboard-gateway/config.js.map +1 -0
- package/dist/dashboard-gateway/index.d.ts +11 -0
- package/dist/dashboard-gateway/index.js +755 -0
- package/dist/dashboard-gateway/index.js.map +1 -0
- package/dist/dashboard-gateway/main.d.ts +1 -0
- package/dist/dashboard-gateway/main.js +25 -0
- package/dist/dashboard-gateway/main.js.map +1 -0
- package/dist/dashboard-pairing-token.d.ts +1 -0
- package/dist/dashboard-pairing-token.js +5 -0
- package/dist/dashboard-pairing-token.js.map +1 -0
- package/dist/elaris/client.d.ts +73 -0
- package/dist/elaris/client.js +825 -0
- package/dist/elaris/client.js.map +1 -0
- package/dist/elaris/config.d.ts +18 -0
- package/dist/elaris/config.js +75 -0
- package/dist/elaris/config.js.map +1 -0
- package/dist/elaris/errors.d.ts +41 -0
- package/dist/elaris/errors.js +205 -0
- package/dist/elaris/errors.js.map +1 -0
- package/dist/elaris/github-repo.d.ts +18 -0
- package/dist/elaris/github-repo.js +89 -0
- package/dist/elaris/github-repo.js.map +1 -0
- package/dist/elaris/index.d.ts +10 -0
- package/dist/elaris/index.js +11 -0
- package/dist/elaris/index.js.map +1 -0
- package/dist/elaris/redact.d.ts +89 -0
- package/dist/elaris/redact.js +423 -0
- package/dist/elaris/redact.js.map +1 -0
- package/dist/elaris/run-id.d.ts +5 -0
- package/dist/elaris/run-id.js +20 -0
- package/dist/elaris/run-id.js.map +1 -0
- package/dist/elaris/status.d.ts +45 -0
- package/dist/elaris/status.js +122 -0
- package/dist/elaris/status.js.map +1 -0
- package/dist/elaris/tool-schemas.d.ts +314 -0
- package/dist/elaris/tool-schemas.js +208 -0
- package/dist/elaris/tool-schemas.js.map +1 -0
- package/dist/elaris/tools.d.ts +73 -0
- package/dist/elaris/tools.js +1065 -0
- package/dist/elaris/tools.js.map +1 -0
- package/dist/elaris/types.d.ts +1541 -0
- package/dist/elaris/types.js +252 -0
- package/dist/elaris/types.js.map +1 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.js +19 -0
- package/dist/errors.js.map +1 -1
- package/dist/handler.d.ts +4 -0
- package/dist/handler.js +10 -1
- package/dist/handler.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +13 -3
- package/dist/index.js.map +1 -1
- package/dist/local-clink-backend.d.ts +3 -0
- package/dist/local-clink-backend.js +4 -0
- package/dist/local-clink-backend.js.map +1 -1
- package/dist/package-info.d.ts +2 -0
- package/dist/package-info.js +18 -0
- package/dist/package-info.js.map +1 -0
- package/dist/parsers/codex-jsonl.js +48 -3
- package/dist/parsers/codex-jsonl.js.map +1 -1
- package/dist/parsers/extract.d.ts +10 -1
- package/dist/parsers/extract.js +11 -3
- package/dist/parsers/extract.js.map +1 -1
- package/dist/paths.d.ts +8 -0
- package/dist/paths.js +31 -43
- package/dist/paths.js.map +1 -1
- package/dist/pipeline.d.ts +51 -3
- package/dist/pipeline.js +264 -47
- package/dist/pipeline.js.map +1 -1
- package/dist/run-dir.d.ts +24 -0
- package/dist/run-dir.js +201 -2
- package/dist/run-dir.js.map +1 -1
- package/dist/runner.d.ts +13 -0
- package/dist/runner.js +184 -15
- package/dist/runner.js.map +1 -1
- package/dist/safety.d.ts +4 -4
- package/dist/safety.js +4 -4
- package/dist/schema.d.ts +7 -7
- package/dist/server.d.ts +2 -0
- package/dist/server.js +396 -24
- package/dist/server.js.map +1 -1
- package/dist/session-log-stream.d.ts +18 -0
- package/dist/session-log-stream.js +319 -0
- package/dist/session-log-stream.js.map +1 -0
- package/package.json +47 -5
- package/conf/adapters/gemini.json +0 -44
- package/conf/adapters/hapi/gemini.json +0 -47
- package/dist/artifacts.d.ts +0 -9
- package/dist/artifacts.js +0 -24
- package/dist/artifacts.js.map +0 -1
- package/dist/parsers/gemini-json.d.ts +0 -2
- package/dist/parsers/gemini-json.js +0 -72
- package/dist/parsers/gemini-json.js.map +0 -1
|
@@ -1,15 +1,27 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
4
|
-
import { mkdir, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { constants as fsConstants, readFileSync } from "node:fs";
|
|
4
|
+
import { lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
5
5
|
import { dirname, basename, isAbsolute, join, relative, resolve } from "node:path";
|
|
6
6
|
import { hostname } from "node:os";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { WorkflowError } from "./errors.js";
|
|
9
9
|
import { buildFidelityMetadata, } from "./fidelity.js";
|
|
10
10
|
import { fingerprintsMatch, generateFingerprint, generateWorkflowFingerprint, } from "./fingerprint.js";
|
|
11
|
-
import {
|
|
11
|
+
import { parseWorkflowContext, } from "./context-preflight.js";
|
|
12
|
+
import { isLegalContextBuilderLifecycleTransition, parseContextBuilderLifecycleStateV1, } from "./context-builder-lifecycle-contract.js";
|
|
13
|
+
import { PlanReviewRunStateSchema, } from "./plan-review/run-state.js";
|
|
14
|
+
import { CouncilPlanReviewErrorSchema, createPlanReviewPublicationError, serializePlanReviewResult, writeSerializedPlanReviewResultSidecar, } from "./plan-review/publication.js";
|
|
15
|
+
import { isPlanReviewPublicationEligible, projectPlanReviewRun, } from "./plan-review/projector.js";
|
|
16
|
+
import { isPlanReviewRunState } from "./plan-review/admission.js";
|
|
17
|
+
import { restorePlanReviewRun } from "./plan-review/resume.js";
|
|
18
|
+
import { PlanReviewStageError } from "./plan-review/stage-error.js";
|
|
19
|
+
import { DEFAULT_RETAIN_ASYNC_DAYS, DEFAULT_RETAIN_CANCELLED_DAYS, DEFAULT_RETAIN_DEBUG_DAYS, DEFAULT_RETAIN_FAILED_DAYS, DEFAULT_RETAIN_SUCCEEDED_DAYS, deriveAllowedRoots, getArtifactsDir, getChildConfigPath, getChildMaxConcurrent, getChildMaxOutputBytes, getChildMaxPromptBytes, getChildMaxResponseChars, getLogLevel, getRetentionConfig, getRunsDir, getRunStateDir, getStateDir, resolveWorkspaceRoot, } from "./config.js";
|
|
12
20
|
import { logger } from "./logger.js";
|
|
21
|
+
import { resolveOpenedDescriptorPath } from "./contained-file.js";
|
|
22
|
+
import { deriveWorkflowRunGeneration } from "./dashboard/generation.js";
|
|
23
|
+
import { removeSessionLogGeneration } from "./dashboard/session-log-retention.js";
|
|
24
|
+
import { createSessionLogGeneration, readSessionLogGeneration, } from "./dashboard/session-log-identity.js";
|
|
13
25
|
const STATE_FILENAME = "state.json";
|
|
14
26
|
const STATE_TMP_FILENAME = "state.json.tmp";
|
|
15
27
|
const WORKFLOW_SNAPSHOT_FILENAME = "workflow.snapshot.json";
|
|
@@ -17,8 +29,162 @@ const INPUTS_SNAPSHOT_FILENAME = "inputs.snapshot.json";
|
|
|
17
29
|
const CHILD_CONFIG_DIRNAME = "child-config";
|
|
18
30
|
const CHILD_CONFIG_SNAPSHOT_FILENAME = "snapshot.json";
|
|
19
31
|
const RUN_LOCK_FILENAME = "run.lock";
|
|
32
|
+
/**
|
|
33
|
+
* Producer-side refresh cadence for a held `run.lock`'s mtime. Mirrors
|
|
34
|
+
* `dashboard/auth/credential-store.ts`'s LOCK_HEARTBEAT_MS.
|
|
35
|
+
*/
|
|
36
|
+
const RUN_LOCK_HEARTBEAT_MS = 10_000;
|
|
37
|
+
/**
|
|
38
|
+
* Reader-side staleness floor the shipped cadence is designed against; the
|
|
39
|
+
* dashboard observer derives its own threshold from the declared interval.
|
|
40
|
+
* Re-exported for tests and cross-module documentation only — nothing in this
|
|
41
|
+
* module consults it, because run-lock CONTROL decisions
|
|
42
|
+
* (`classifyRunLockSync`, `reapRunCancelled`, pruning) must never depend on
|
|
43
|
+
* mtime freshness.
|
|
44
|
+
*/
|
|
45
|
+
export { RUN_LOCK_HEARTBEAT_STALE_FLOOR_MS } from "./run-lock-heartbeat.js";
|
|
46
|
+
/** Same cap as the dashboard observer's contained-read `maxBytes`. */
|
|
47
|
+
const RUN_LOCK_MAX_BYTES = 4_096;
|
|
20
48
|
const OUTPUT_INLINE_THRESHOLD_BYTES = 10 * 1024;
|
|
49
|
+
/**
|
|
50
|
+
* Cap for error strings persisted inline in state.json (run-level `error` and
|
|
51
|
+
* per-call `errorMessage`). Unlike call outputs (which spill to sidecar files
|
|
52
|
+
* via persistOutput), errors always live inline, and provider stderr/exception
|
|
53
|
+
* chains can be arbitrarily large — keep a bounded head plus an explicit
|
|
54
|
+
* truncation marker recording how many bytes were cut.
|
|
55
|
+
*/
|
|
56
|
+
const MAX_PERSISTED_ERROR_BYTES = 16 * 1024;
|
|
21
57
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
58
|
+
const STALE_RUNNING_RECOVERY_ERROR = "Recovered stale running run during resume";
|
|
59
|
+
/**
|
|
60
|
+
* Run-level error written by reconcileStaleRunDirectories. Deliberately
|
|
61
|
+
* distinct from STALE_RUNNING_RECOVERY_ERROR (the resume-path recovery string)
|
|
62
|
+
* so the two reconcile paths remain distinguishable in persisted state.
|
|
63
|
+
*/
|
|
64
|
+
export const STALE_RUN_RECONCILE_ERROR = "Reconciled stale running run: no live worker process";
|
|
65
|
+
const STALE_RUN_RECONCILE_CALL_ERROR = "Reconciled: worker terminated before call completion";
|
|
66
|
+
function resolvePlanReviewChairmanCallId(definition) {
|
|
67
|
+
const stage = definition.stages.find((entry) => entry.id === "chairman");
|
|
68
|
+
const callId = stage?.calls[0]?.id;
|
|
69
|
+
if (callId === undefined || stage?.calls.length !== 1) {
|
|
70
|
+
throw new Error("Persisted plan-review workflow definition must have exactly one chairman call");
|
|
71
|
+
}
|
|
72
|
+
return callId;
|
|
73
|
+
}
|
|
74
|
+
async function readPersistedPlanReviewChairmanCallId(runDir, state) {
|
|
75
|
+
const workflowSnapshotPath = join(runDir, state.workflowSnapshotPath ?? WORKFLOW_SNAPSHOT_FILENAME);
|
|
76
|
+
await verifySnapshotHash(workflowSnapshotPath, state.workflowSnapshotHash, "workflow snapshot");
|
|
77
|
+
const definition = await readJsonFile(workflowSnapshotPath);
|
|
78
|
+
return resolvePlanReviewChairmanCallId(definition);
|
|
79
|
+
}
|
|
80
|
+
function withPlanReviewPublication(state, publication) {
|
|
81
|
+
const nextState = structuredClone(state);
|
|
82
|
+
nextState.plan_review_state = PlanReviewRunStateSchema.parse({
|
|
83
|
+
...nextState.plan_review_state,
|
|
84
|
+
publication,
|
|
85
|
+
});
|
|
86
|
+
return nextState;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* C-9 chain-internal finalizer. It never calls a public store writer or
|
|
90
|
+
* enqueueWrite. The committed marker is persisted on the pre-terminal state,
|
|
91
|
+
* after the sidecar and before the caller commits the terminal state.
|
|
92
|
+
*/
|
|
93
|
+
const finalizePlanReviewTerminalState = async ({ runDir, previousState, intendedState, intent, chairmanCallId, serializedResult, }) => {
|
|
94
|
+
const existing = previousState.plan_review_state?.publication;
|
|
95
|
+
if (existing?.committed === true) {
|
|
96
|
+
return { state: withPlanReviewPublication(intendedState, existing) };
|
|
97
|
+
}
|
|
98
|
+
if (!isPlanReviewPublicationEligible(previousState)) {
|
|
99
|
+
return { state: intendedState };
|
|
100
|
+
}
|
|
101
|
+
let restored;
|
|
102
|
+
try {
|
|
103
|
+
const resolvedChairmanCallId = chairmanCallId ??
|
|
104
|
+
await readPersistedPlanReviewChairmanCallId(runDir, previousState);
|
|
105
|
+
restored = await restorePlanReviewRun(runDir, previousState.plan_review_state, resolvedChairmanCallId);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
109
|
+
const stageError = err instanceof PlanReviewStageError
|
|
110
|
+
? err
|
|
111
|
+
: new PlanReviewStageError("execution_contract", detail, "internal_error");
|
|
112
|
+
const finalStatus = intent.status === "succeeded" ? "failed" : intent.status;
|
|
113
|
+
const failed = structuredClone(intendedState);
|
|
114
|
+
failed.status = finalStatus;
|
|
115
|
+
if (intent.status === "succeeded") {
|
|
116
|
+
failed.error = `Plan-review persisted-state verification failed: ${detail}`;
|
|
117
|
+
}
|
|
118
|
+
failed.plan_review_error = intent.planReviewError ??
|
|
119
|
+
CouncilPlanReviewErrorSchema.parse({
|
|
120
|
+
schema_version: "council-plan-review-error-v1",
|
|
121
|
+
run_id: failed.runId,
|
|
122
|
+
execution_status: finalStatus,
|
|
123
|
+
stage: stageError.stage,
|
|
124
|
+
error: {
|
|
125
|
+
reason: stageError.reason,
|
|
126
|
+
detail: detail.slice(0, 2_048),
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
return {
|
|
130
|
+
state: failed,
|
|
131
|
+
...(intent.status === "succeeded"
|
|
132
|
+
? { throwAfterCommit: stageError }
|
|
133
|
+
: {}),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const projected = serializedResult ?? serializePlanReviewResult(await projectPlanReviewRun({
|
|
138
|
+
runDir,
|
|
139
|
+
state: previousState,
|
|
140
|
+
contract: restored.contract,
|
|
141
|
+
execution_contract_digest: restored.execution_contract_digest,
|
|
142
|
+
}));
|
|
143
|
+
const marker = await writeSerializedPlanReviewResultSidecar(runDir, projected);
|
|
144
|
+
// Durable crash boundary: a process death after this write still leaves a
|
|
145
|
+
// contained, marker-addressable result even though terminal intent has not
|
|
146
|
+
// yet been committed.
|
|
147
|
+
const intermediate = withPlanReviewPublication(previousState, marker);
|
|
148
|
+
validateStrictStateSubtrees(intermediate, join(runDir, STATE_FILENAME));
|
|
149
|
+
await writeJsonAtomic(join(runDir, STATE_FILENAME), intermediate);
|
|
150
|
+
return { state: withPlanReviewPublication(intendedState, marker) };
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
154
|
+
const finalStatus = intent.status === "succeeded" ? "failed" : intent.status;
|
|
155
|
+
const failed = withPlanReviewPublication(intendedState, { committed: false });
|
|
156
|
+
failed.status = finalStatus;
|
|
157
|
+
if (intent.status === "succeeded") {
|
|
158
|
+
failed.error = `Plan-review publication failed: ${detail}`;
|
|
159
|
+
}
|
|
160
|
+
failed.plan_review_error = createPlanReviewPublicationError(failed.runId, finalStatus, err instanceof PlanReviewStageError ? err.reason : "internal_error", detail);
|
|
161
|
+
return {
|
|
162
|
+
state: failed,
|
|
163
|
+
...(intent.status === "succeeded"
|
|
164
|
+
? {
|
|
165
|
+
throwAfterCommit: new PlanReviewStageError("publication", failed.error, failed.plan_review_error.error.reason),
|
|
166
|
+
}
|
|
167
|
+
: {}),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
class RunLockOwnershipUnknownError extends Error {
|
|
172
|
+
constructor(lockPath, detail) {
|
|
173
|
+
super(`Run lock ownership is unknown at ${lockPath}; refusing to reclaim it: ${detail}`);
|
|
174
|
+
this.name = "RunLockOwnershipUnknownError";
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Constructed ONLY after a complete, valid lock record has been read and its
|
|
179
|
+
* token proves the lock is no longer ours. Never for open/stat/read/parse/type/
|
|
180
|
+
* size errors — those stay "ordinary" and must not revoke unlink authority.
|
|
181
|
+
*/
|
|
182
|
+
class RunLockOwnershipLostError extends Error {
|
|
183
|
+
constructor(lockPath, detail) {
|
|
184
|
+
super(`Run lock ownership was lost at ${lockPath}: ${detail}`);
|
|
185
|
+
this.name = "RunLockOwnershipLostError";
|
|
186
|
+
}
|
|
187
|
+
}
|
|
22
188
|
export function classifyRunDirectory(runDirPath) {
|
|
23
189
|
try {
|
|
24
190
|
const state = readRunStateSync(runDirPath);
|
|
@@ -34,8 +200,15 @@ export function classifyRunDirectory(runDirPath) {
|
|
|
34
200
|
return "failed";
|
|
35
201
|
case "cancelled":
|
|
36
202
|
return "cancelled";
|
|
37
|
-
case "succeeded":
|
|
38
|
-
|
|
203
|
+
case "succeeded": {
|
|
204
|
+
if ((state.debugKeepArtifacts !== undefined && typeof state.debugKeepArtifacts !== "boolean") ||
|
|
205
|
+
(state.async !== undefined && typeof state.async !== "boolean")) {
|
|
206
|
+
return "unknown";
|
|
207
|
+
}
|
|
208
|
+
return state.debugKeepArtifacts === true
|
|
209
|
+
? "debug_kept"
|
|
210
|
+
: (state.async === true ? "succeeded_async" : "succeeded_sync");
|
|
211
|
+
}
|
|
39
212
|
case "running":
|
|
40
213
|
case "pending":
|
|
41
214
|
return "running_or_pending";
|
|
@@ -91,9 +264,40 @@ export async function pruneRunDirectories(runsRootDir, options) {
|
|
|
91
264
|
result.skipped += 1;
|
|
92
265
|
continue;
|
|
93
266
|
}
|
|
267
|
+
const identity = await readSessionLogGeneration(runDirPath);
|
|
268
|
+
let sessionLogGeneration;
|
|
269
|
+
if (identity.kind === "available") {
|
|
270
|
+
sessionLogGeneration = identity.sessionLogGeneration;
|
|
271
|
+
}
|
|
272
|
+
else if (identity.kind === "legacy") {
|
|
273
|
+
try {
|
|
274
|
+
sessionLogGeneration = await admitWorkflowRunGeneration(runsRootDir, runDirPath, entry.name);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
logger.warn({ run_id: entry.name }, "legacy workflow session-log generation could not be admitted before parent pruning");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
logger.warn({ run_id: entry.name }, "workflow session-log identity was unsafe before parent pruning");
|
|
282
|
+
}
|
|
94
283
|
await rm(runDirPath, { recursive: true, force: true });
|
|
95
284
|
pruned = true;
|
|
96
285
|
result.pruned += 1;
|
|
286
|
+
if (sessionLogGeneration !== undefined) {
|
|
287
|
+
try {
|
|
288
|
+
const cleanup = await removeSessionLogGeneration({
|
|
289
|
+
rootDir: join(dirname(resolve(runsRootDir)), "session-logs"),
|
|
290
|
+
family: "workflow",
|
|
291
|
+
generation: sessionLogGeneration,
|
|
292
|
+
});
|
|
293
|
+
if (cleanup.kind === "unsafe" || cleanup.kind === "unavailable") {
|
|
294
|
+
logger.warn({ run_id: entry.name, cleanup: cleanup.kind }, "workflow parent pruned but exact session-log cleanup was unavailable");
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
logger.warn({ run_id: entry.name }, "workflow parent pruned but exact session-log cleanup failed");
|
|
299
|
+
}
|
|
300
|
+
}
|
|
97
301
|
logger.info({
|
|
98
302
|
run_dir: runDirPath,
|
|
99
303
|
classification,
|
|
@@ -115,8 +319,64 @@ export async function pruneRunDirectories(runsRootDir, options) {
|
|
|
115
319
|
logger.info({ runs_root_dir: runsRootDir, ...result }, "workflow run directory pruning complete");
|
|
116
320
|
return result;
|
|
117
321
|
}
|
|
322
|
+
function isTerminalRunStatus(status) {
|
|
323
|
+
return status === "succeeded" || status === "failed" || status === "cancelled";
|
|
324
|
+
}
|
|
325
|
+
function isPlanReviewRun(state) {
|
|
326
|
+
// Presence-based typed detection (see isPlanReviewRunState): legacy runs
|
|
327
|
+
// persisted under the pre-rename name "council-plan-review" keep routing
|
|
328
|
+
// through the plan-review terminal-intent protocol, while runs of the
|
|
329
|
+
// GENERIC council-plan-review workflow never do.
|
|
330
|
+
return isPlanReviewRunState(state);
|
|
331
|
+
}
|
|
332
|
+
function isStaleRunningRecoveryCheckpoint(state) {
|
|
333
|
+
return state.status === "failed" &&
|
|
334
|
+
state.failureClass === "transient" &&
|
|
335
|
+
state.error === STALE_RUNNING_RECOVERY_ERROR &&
|
|
336
|
+
state.plan_review_error === undefined &&
|
|
337
|
+
state.stages.every((stage) => stage.calls.every((call) => call.state !== "running"));
|
|
338
|
+
}
|
|
339
|
+
function canReplaceStaleRunningRecovery(state, intent) {
|
|
340
|
+
return isStaleRunningRecoveryCheckpoint(state) &&
|
|
341
|
+
intent.status === "failed" &&
|
|
342
|
+
intent.planReviewError?.stage === "execution_contract";
|
|
343
|
+
}
|
|
344
|
+
function applyPlanReviewTerminalIntent(state, intent) {
|
|
345
|
+
const replacingRecovery = canReplaceStaleRunningRecovery(state, intent);
|
|
346
|
+
if (isTerminalRunStatus(state.status) && !replacingRecovery) {
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
const nextState = structuredClone(state);
|
|
350
|
+
nextState.status = intent.status;
|
|
351
|
+
nextState.completedAt = intent.completedAt;
|
|
352
|
+
nextState.error = intent.error;
|
|
353
|
+
if (intent.replaceFailureClass || replacingRecovery) {
|
|
354
|
+
nextState.failureClass = intent.failureClass;
|
|
355
|
+
}
|
|
356
|
+
if (intent.planReviewError === undefined) {
|
|
357
|
+
nextState.plan_review_error = undefined;
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
const planReviewError = CouncilPlanReviewErrorSchema.parse(intent.planReviewError);
|
|
361
|
+
if (planReviewError.run_id !== state.runId) {
|
|
362
|
+
throw new Error(`plan_review_error.run_id ${planReviewError.run_id} does not match run ${state.runId}`);
|
|
363
|
+
}
|
|
364
|
+
if (planReviewError.execution_status !== intent.status) {
|
|
365
|
+
throw new Error(`plan_review_error.execution_status ${planReviewError.execution_status} does not match terminal intent ${intent.status}`);
|
|
366
|
+
}
|
|
367
|
+
nextState.plan_review_error = planReviewError;
|
|
368
|
+
}
|
|
369
|
+
return nextState;
|
|
370
|
+
}
|
|
118
371
|
export class WorkflowStateStore {
|
|
119
372
|
runDir;
|
|
373
|
+
/** Portable journal identity; absent only for a legacy or failed-open run. */
|
|
374
|
+
sessionLogGeneration;
|
|
375
|
+
/**
|
|
376
|
+
* Warn-only duplicate-replay signal detected at create(); undefined on
|
|
377
|
+
* resume and when no permanently-failed identical sibling was found.
|
|
378
|
+
*/
|
|
379
|
+
duplicateOfFailedRun;
|
|
120
380
|
definition;
|
|
121
381
|
variables;
|
|
122
382
|
hooks;
|
|
@@ -126,22 +386,49 @@ export class WorkflowStateStore {
|
|
|
126
386
|
debugKeepArtifacts;
|
|
127
387
|
writeChain = Promise.resolve();
|
|
128
388
|
lockHeld = true;
|
|
389
|
+
planReviewTerminalResult;
|
|
390
|
+
lockToken;
|
|
391
|
+
heartbeat;
|
|
392
|
+
heartbeatInFlight;
|
|
393
|
+
heartbeatError;
|
|
129
394
|
constructor(options) {
|
|
130
395
|
this.runDir = options.runDir;
|
|
396
|
+
this.sessionLogGeneration = options.sessionLogGeneration;
|
|
397
|
+
this.duplicateOfFailedRun = options.duplicateOfFailedRun;
|
|
131
398
|
this.definition = options.definition;
|
|
132
399
|
this.variables = options.variables;
|
|
133
400
|
this.state = options.state;
|
|
134
401
|
this.resume = options.resume;
|
|
135
402
|
this.debugKeepArtifacts = options.debugKeepArtifacts;
|
|
136
403
|
this.lockPath = join(options.runDir, RUN_LOCK_FILENAME);
|
|
404
|
+
this.lockToken = options.lockToken;
|
|
405
|
+
this.startHeartbeat();
|
|
137
406
|
this.hooks = {
|
|
138
407
|
onRunStateChange: async (event) => {
|
|
139
408
|
await this.enqueueWrite(async () => {
|
|
409
|
+
const cappedError = capPersistedError(event.error);
|
|
410
|
+
if (isPlanReviewRun(this.state) && isTerminalRunStatus(event.state)) {
|
|
411
|
+
await this.finalizePlanReviewTerminalIntent({
|
|
412
|
+
status: event.state,
|
|
413
|
+
completedAt: event.completedAt,
|
|
414
|
+
error: cappedError,
|
|
415
|
+
failureClass: event.failureClass,
|
|
416
|
+
replaceFailureClass: true,
|
|
417
|
+
planReviewError: event.planReviewError,
|
|
418
|
+
});
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (isPlanReviewRun(this.state) &&
|
|
422
|
+
isTerminalRunStatus(this.state.status) &&
|
|
423
|
+
!(event.state === "running" &&
|
|
424
|
+
isStaleRunningRecoveryCheckpoint(this.state))) {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
140
427
|
const nextState = structuredClone(this.state);
|
|
141
428
|
nextState.status = event.state;
|
|
142
429
|
nextState.startedAt = event.startedAt ?? nextState.startedAt;
|
|
143
430
|
nextState.completedAt = event.completedAt;
|
|
144
|
-
nextState.error =
|
|
431
|
+
nextState.error = cappedError;
|
|
145
432
|
nextState.failureClass = event.failureClass;
|
|
146
433
|
this.state = nextState;
|
|
147
434
|
await this.writeState();
|
|
@@ -166,10 +453,13 @@ export class WorkflowStateStore {
|
|
|
166
453
|
const callEntry = getCallEntry(nextState, event.stageId, event.callId);
|
|
167
454
|
callEntry.state = event.state;
|
|
168
455
|
callEntry.retryCount = event.retryCount;
|
|
456
|
+
if (event.state === "running") {
|
|
457
|
+
callEntry.attemptsConsumed = Math.max(callEntry.attemptsConsumed ?? 0, event.attempt);
|
|
458
|
+
}
|
|
169
459
|
callEntry.startedAt = event.startedAt;
|
|
170
460
|
callEntry.completedAt = event.completedAt;
|
|
171
461
|
callEntry.durationMs = event.durationMs;
|
|
172
|
-
callEntry.errorMessage = event.error;
|
|
462
|
+
callEntry.errorMessage = capPersistedError(event.error);
|
|
173
463
|
callEntry.failureClass = event.failureClass;
|
|
174
464
|
callEntry.childDebugMetadata = event.childDebugMetadata;
|
|
175
465
|
callEntry.contextProducing = callEntry.contextProducing ?? false;
|
|
@@ -205,10 +495,19 @@ export class WorkflowStateStore {
|
|
|
205
495
|
const recycled = await prepareRunDirForCreate(runDir, options.runId);
|
|
206
496
|
let lockHeld = false;
|
|
207
497
|
try {
|
|
208
|
-
await acquireRunLock(runDir);
|
|
498
|
+
const lock = await acquireRunLock(runDir, { declareHeartbeat: true });
|
|
209
499
|
lockHeld = true;
|
|
500
|
+
const sessionLogGeneration = await createSessionLogGeneration(runDir);
|
|
210
501
|
const workflowSnapshotHash = await writeJsonSnapshot(join(runDir, WORKFLOW_SNAPSHOT_FILENAME), options.definition);
|
|
211
502
|
const inputsSnapshotHash = await writeJsonSnapshot(join(runDir, INPUTS_SNAPSHOT_FILENAME), options.variables);
|
|
503
|
+
const duplicateOfFailedRun = await detectDuplicateReplay(runDir, workflowSnapshotHash, inputsSnapshotHash);
|
|
504
|
+
if (duplicateOfFailedRun !== undefined) {
|
|
505
|
+
logger.warn({
|
|
506
|
+
run_id: options.runId,
|
|
507
|
+
duplicate_of_run_id: duplicateOfFailedRun.runId,
|
|
508
|
+
duplicate_completed_at: duplicateOfFailedRun.completedAt,
|
|
509
|
+
}, "run replays workflow and inputs identical to a permanently-failed prior run");
|
|
510
|
+
}
|
|
212
511
|
const childConfigPath = getChildConfigPath();
|
|
213
512
|
const resumable = childConfigPath != null;
|
|
214
513
|
const fingerprintData = resumable
|
|
@@ -242,15 +541,21 @@ export class WorkflowStateStore {
|
|
|
242
541
|
callId: call.id,
|
|
243
542
|
state: "pending",
|
|
244
543
|
retryCount: 0,
|
|
544
|
+
attemptsConsumed: 0,
|
|
245
545
|
contextProducing: isContextProducing(options.definition, stage.id, call.id),
|
|
246
546
|
})),
|
|
247
547
|
})),
|
|
248
548
|
variables: options.variables,
|
|
549
|
+
initialContextPolicy: options.initialContextPolicy,
|
|
249
550
|
debugKeepArtifacts,
|
|
551
|
+
async: options.async ?? false,
|
|
250
552
|
};
|
|
251
553
|
await writeJsonAtomic(join(runDir, STATE_FILENAME), state);
|
|
252
|
-
if (recycled) {
|
|
554
|
+
if (recycled.recycled) {
|
|
253
555
|
await rm(`${runDir}.recycled`, { recursive: true, force: true }).catch(() => { });
|
|
556
|
+
if (recycled.retiredSessionLogGeneration !== undefined) {
|
|
557
|
+
await cleanupWorkflowSessionLogGeneration(dirname(runDir), options.runId, recycled.retiredSessionLogGeneration);
|
|
558
|
+
}
|
|
254
559
|
}
|
|
255
560
|
return new WorkflowStateStore({
|
|
256
561
|
runDir,
|
|
@@ -259,13 +564,16 @@ export class WorkflowStateStore {
|
|
|
259
564
|
state,
|
|
260
565
|
resume: {},
|
|
261
566
|
debugKeepArtifacts,
|
|
567
|
+
lockToken: lock.token,
|
|
568
|
+
sessionLogGeneration,
|
|
569
|
+
duplicateOfFailedRun,
|
|
262
570
|
});
|
|
263
571
|
}
|
|
264
572
|
catch (error) {
|
|
265
573
|
if (lockHeld) {
|
|
266
574
|
await safeUnlink(join(runDir, RUN_LOCK_FILENAME));
|
|
267
575
|
}
|
|
268
|
-
if (recycled) {
|
|
576
|
+
if (recycled.recycled) {
|
|
269
577
|
try {
|
|
270
578
|
await rm(runDir, { recursive: true, force: true });
|
|
271
579
|
await rename(`${runDir}.recycled`, runDir);
|
|
@@ -283,9 +591,19 @@ export class WorkflowStateStore {
|
|
|
283
591
|
static async resume(options) {
|
|
284
592
|
const runDir = options.runDir ?? getRunStateDir(options.runId);
|
|
285
593
|
const debugKeepArtifacts = options.debugKeepArtifacts ?? false;
|
|
286
|
-
await acquireRunLock(runDir);
|
|
594
|
+
const lock = await acquireRunLock(runDir, { declareHeartbeat: true });
|
|
287
595
|
try {
|
|
288
596
|
const state = await readRunState(runDir);
|
|
597
|
+
const identity = await readSessionLogGeneration(runDir);
|
|
598
|
+
if (identity.kind === "unavailable") {
|
|
599
|
+
throw new Error(`Run ${options.runId} has an unsafe session-log identity`);
|
|
600
|
+
}
|
|
601
|
+
if (isPlanReviewRun(state) &&
|
|
602
|
+
(state.status === "failed" ||
|
|
603
|
+
state.status === "cancelled" ||
|
|
604
|
+
state.status === "succeeded")) {
|
|
605
|
+
throw new Error(`Run ${options.runId} is terminal (${state.status}) and cannot be resumed; start a new run instead.`);
|
|
606
|
+
}
|
|
289
607
|
if (!state.resumable) {
|
|
290
608
|
throw new Error(`Run ${options.runId} is non-resumable because it was started from ambient child config`);
|
|
291
609
|
}
|
|
@@ -361,6 +679,10 @@ export class WorkflowStateStore {
|
|
|
361
679
|
state: recovered,
|
|
362
680
|
resume,
|
|
363
681
|
debugKeepArtifacts,
|
|
682
|
+
lockToken: lock.token,
|
|
683
|
+
...(identity.kind === "available"
|
|
684
|
+
? { sessionLogGeneration: identity.sessionLogGeneration }
|
|
685
|
+
: {}),
|
|
364
686
|
});
|
|
365
687
|
}
|
|
366
688
|
catch (error) {
|
|
@@ -371,8 +693,54 @@ export class WorkflowStateStore {
|
|
|
371
693
|
get runState() {
|
|
372
694
|
return structuredClone(this.state);
|
|
373
695
|
}
|
|
696
|
+
/**
|
|
697
|
+
* Open a new generic resume attempt before any context preflight or provider
|
|
698
|
+
* setup. Provider-call attempt counters remain untouched; only the run-level
|
|
699
|
+
* terminal envelope is cleared so a pre-dispatch failure/cancellation can be
|
|
700
|
+
* persisted as the outcome of this resume attempt.
|
|
701
|
+
*
|
|
702
|
+
* Plan-review stale-run recovery has its own terminal-intent protocol and is
|
|
703
|
+
* deliberately left unchanged here.
|
|
704
|
+
*/
|
|
705
|
+
async beginResumeAttempt() {
|
|
706
|
+
await this.enqueueWrite(async () => {
|
|
707
|
+
if (isPlanReviewRun(this.state))
|
|
708
|
+
return;
|
|
709
|
+
const nextState = structuredClone(this.state);
|
|
710
|
+
if (isTerminalRunStatus(this.state.status)) {
|
|
711
|
+
nextState.previousTerminal = {
|
|
712
|
+
status: this.state.status,
|
|
713
|
+
...(this.state.completedAt === undefined
|
|
714
|
+
? {}
|
|
715
|
+
: { completedAt: this.state.completedAt }),
|
|
716
|
+
...(this.state.error === undefined ? {} : { error: this.state.error }),
|
|
717
|
+
...(this.state.failureClass === undefined
|
|
718
|
+
? {}
|
|
719
|
+
: { failureClass: this.state.failureClass }),
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
nextState.status = "pending";
|
|
723
|
+
nextState.completedAt = undefined;
|
|
724
|
+
nextState.error = undefined;
|
|
725
|
+
nextState.failureClass = undefined;
|
|
726
|
+
this.state = nextState;
|
|
727
|
+
await this.writeState();
|
|
728
|
+
});
|
|
729
|
+
}
|
|
374
730
|
async markCancelled(reason = "Workflow cancelled") {
|
|
375
731
|
await this.enqueueWrite(async () => {
|
|
732
|
+
if (isPlanReviewRun(this.state)) {
|
|
733
|
+
await this.finalizePlanReviewTerminalIntent({
|
|
734
|
+
status: "cancelled",
|
|
735
|
+
completedAt: new Date().toISOString(),
|
|
736
|
+
error: reason,
|
|
737
|
+
replaceFailureClass: false,
|
|
738
|
+
});
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
if (this.state.status !== "pending" && this.state.status !== "running") {
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
376
744
|
const nextState = structuredClone(this.state);
|
|
377
745
|
nextState.status = "cancelled";
|
|
378
746
|
nextState.completedAt = new Date().toISOString();
|
|
@@ -381,14 +749,308 @@ export class WorkflowStateStore {
|
|
|
381
749
|
await this.writeState();
|
|
382
750
|
});
|
|
383
751
|
}
|
|
752
|
+
/**
|
|
753
|
+
* Idempotently mark the run failed. Only applies when the current status is
|
|
754
|
+
* non-terminal (pending/running) so it never clobbers a richer engine-emitted
|
|
755
|
+
* terminal status (succeeded/failed/cancelled).
|
|
756
|
+
*/
|
|
757
|
+
async markFailed(error, failureClass, planReviewError) {
|
|
758
|
+
const cappedError = capPersistedError(error);
|
|
759
|
+
await this.enqueueWrite(async () => {
|
|
760
|
+
if (isPlanReviewRun(this.state)) {
|
|
761
|
+
await this.finalizePlanReviewTerminalIntent({
|
|
762
|
+
status: "failed",
|
|
763
|
+
completedAt: new Date().toISOString(),
|
|
764
|
+
error: cappedError,
|
|
765
|
+
failureClass,
|
|
766
|
+
replaceFailureClass: failureClass !== undefined,
|
|
767
|
+
planReviewError,
|
|
768
|
+
});
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
if (this.state.status !== "pending" && this.state.status !== "running") {
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
const nextState = structuredClone(this.state);
|
|
775
|
+
nextState.status = "failed";
|
|
776
|
+
nextState.completedAt = new Date().toISOString();
|
|
777
|
+
nextState.error = cappedError;
|
|
778
|
+
if (failureClass !== undefined) {
|
|
779
|
+
nextState.failureClass = failureClass;
|
|
780
|
+
}
|
|
781
|
+
this.state = nextState;
|
|
782
|
+
await this.writeState();
|
|
783
|
+
});
|
|
784
|
+
}
|
|
384
785
|
async flush() {
|
|
385
786
|
await this.writeChain;
|
|
386
787
|
}
|
|
788
|
+
async commitPreflightContext({ variables, workflow_context, }) {
|
|
789
|
+
await this.enqueueWrite(async () => {
|
|
790
|
+
const lifecycle = this.state.context_builder_lifecycle;
|
|
791
|
+
if (lifecycle !== undefined) {
|
|
792
|
+
if ((lifecycle.status !== "succeeded" && lifecycle.status !== "partial") ||
|
|
793
|
+
lifecycle.phase !== "complete" ||
|
|
794
|
+
lifecycle.artifact_run_id !== workflow_context.context_builder.source_run_id) {
|
|
795
|
+
throw new Error("workflow_context cannot commit before a matching successful lifecycle completes");
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
const committedWorkflowContext = lifecycle === undefined
|
|
799
|
+
? workflow_context
|
|
800
|
+
: {
|
|
801
|
+
...workflow_context,
|
|
802
|
+
context_builder: {
|
|
803
|
+
...workflow_context.context_builder,
|
|
804
|
+
lifecycle: structuredClone(lifecycle),
|
|
805
|
+
},
|
|
806
|
+
};
|
|
807
|
+
const inputsSnapshotPath = this.state.inputsSnapshotPath ?? INPUTS_SNAPSHOT_FILENAME;
|
|
808
|
+
const inputsSnapshotHash = await writeJsonSnapshot(join(this.runDir, inputsSnapshotPath), variables);
|
|
809
|
+
const nextState = structuredClone(this.state);
|
|
810
|
+
nextState.inputsSnapshotPath = inputsSnapshotPath;
|
|
811
|
+
nextState.inputsSnapshotHash = inputsSnapshotHash;
|
|
812
|
+
nextState.variables = variables;
|
|
813
|
+
nextState.workflow_context = structuredClone(committedWorkflowContext);
|
|
814
|
+
this.variables = variables;
|
|
815
|
+
this.state = nextState;
|
|
816
|
+
await this.writeState();
|
|
817
|
+
try {
|
|
818
|
+
await writeJsonAtomic(join(getStateDir(), "workflow-contexts", `${this.state.runId}.json`), committedWorkflowContext);
|
|
819
|
+
}
|
|
820
|
+
catch (error) {
|
|
821
|
+
logger.warn({ runId: this.state.runId, err: error }, "failed to write workflow context provenance");
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
async observeContextBuilderLifecycleTransition(transition) {
|
|
826
|
+
const previous = transition.previous === null
|
|
827
|
+
? null
|
|
828
|
+
: parseContextBuilderLifecycleStateV1(transition.previous);
|
|
829
|
+
const snapshot = parseContextBuilderLifecycleStateV1(transition.snapshot);
|
|
830
|
+
if (snapshot.operation_kind !== "workflow_preflight") {
|
|
831
|
+
throw new Error("workflow state accepts only workflow_preflight lifecycle snapshots");
|
|
832
|
+
}
|
|
833
|
+
if (previous !== null && !isLegalContextBuilderLifecycleTransition(previous, snapshot)) {
|
|
834
|
+
throw new Error("illegal workflow context-builder lifecycle transition");
|
|
835
|
+
}
|
|
836
|
+
await this.enqueueWrite(async () => {
|
|
837
|
+
const current = this.state.context_builder_lifecycle;
|
|
838
|
+
if (previous !== null) {
|
|
839
|
+
if (current === undefined || !lifecycleSnapshotsEqual(current, previous)) {
|
|
840
|
+
throw new Error("workflow context-builder lifecycle predecessor mismatch");
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
const nextState = structuredClone(this.state);
|
|
844
|
+
nextState.context_builder_lifecycle = structuredClone(snapshot);
|
|
845
|
+
this.state = nextState;
|
|
846
|
+
await this.writeState();
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Persist the plan-review aux subtree (A-5).
|
|
851
|
+
*
|
|
852
|
+
* Called immediately after `create()` and BEFORE any seat can be dispatched.
|
|
853
|
+
* It cannot be a `create()` argument in the runtime path: the subtree binds
|
|
854
|
+
* the canonical snapshot's hash, and the snapshot can only be materialized
|
|
855
|
+
* into a run directory that `create()` itself makes — and the plan requires a
|
|
856
|
+
* snapshot/digest failure to be a POST-state `failed` run, not a pre-state
|
|
857
|
+
* reject. `create()` still validates+persists the subtree when a caller
|
|
858
|
+
* already has one (e.g. a rebuild), so both entry points are strict.
|
|
859
|
+
*/
|
|
860
|
+
async commitPlanReviewState(state) {
|
|
861
|
+
const validated = PlanReviewRunStateSchema.parse(state);
|
|
862
|
+
await this.enqueueWrite(async () => {
|
|
863
|
+
const nextState = structuredClone(this.state);
|
|
864
|
+
nextState.plan_review_state = validated;
|
|
865
|
+
this.state = nextState;
|
|
866
|
+
await this.writeState();
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Record that the plan-review context policy was actually enforced at the
|
|
871
|
+
* preflight seam, together with the post-filtered copied paths (A-7).
|
|
872
|
+
*
|
|
873
|
+
* Committed before stage dispatch so a resumed run replays the same enforced
|
|
874
|
+
* exclusion instead of re-deriving it from caller variables.
|
|
875
|
+
*/
|
|
876
|
+
async commitPlanReviewContextProvenance(provenance) {
|
|
877
|
+
await this.enqueueWrite(async () => {
|
|
878
|
+
const current = this.state.plan_review_state;
|
|
879
|
+
if (current === undefined) {
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
const nextState = structuredClone(this.state);
|
|
883
|
+
nextState.plan_review_state = PlanReviewRunStateSchema.parse({
|
|
884
|
+
...current,
|
|
885
|
+
context_provenance: provenance,
|
|
886
|
+
});
|
|
887
|
+
this.state = nextState;
|
|
888
|
+
await this.writeState();
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
/** Persisted-state terminal projection used only by the engine's opt-in policy. */
|
|
892
|
+
async projectPlanReviewTerminalResult() {
|
|
893
|
+
if (this.planReviewTerminalResult !== undefined) {
|
|
894
|
+
return this.planReviewTerminalResult;
|
|
895
|
+
}
|
|
896
|
+
const state = this.runState;
|
|
897
|
+
const restored = await restorePlanReviewRun(this.runDir, state.plan_review_state, resolvePlanReviewChairmanCallId(this.definition));
|
|
898
|
+
const envelope = await projectPlanReviewRun({
|
|
899
|
+
runDir: this.runDir,
|
|
900
|
+
state,
|
|
901
|
+
contract: restored.contract,
|
|
902
|
+
execution_contract_digest: restored.execution_contract_digest,
|
|
903
|
+
});
|
|
904
|
+
this.planReviewTerminalResult = serializePlanReviewResult(envelope);
|
|
905
|
+
return this.planReviewTerminalResult;
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Refresh this store's own `run.lock` mtime so a reader that cannot verify our
|
|
909
|
+
* PID namespace (a containerized or bind-mounted dashboard) has portable
|
|
910
|
+
* liveness evidence. Observation only — nothing in this module reads the
|
|
911
|
+
* mtime, and no control path consults it.
|
|
912
|
+
*/
|
|
913
|
+
startHeartbeat() {
|
|
914
|
+
this.heartbeat = setInterval(() => {
|
|
915
|
+
// Single-flight: never overlap beats. Skip this tick if we no longer hold
|
|
916
|
+
// the lock, a prior beat latched an error, or a beat is still running. The
|
|
917
|
+
// callback must NOT await — a slow fstat/read must not stall the interval.
|
|
918
|
+
if (!this.lockHeld ||
|
|
919
|
+
this.heartbeatError != null ||
|
|
920
|
+
this.heartbeatInFlight != null) {
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
const beat = this.beatRunLock()
|
|
924
|
+
.catch((error) => {
|
|
925
|
+
// Losing liveness REPORTING must never lose the run: latch and log, and
|
|
926
|
+
// never let this reach enqueueWrite. This deliberately diverges from
|
|
927
|
+
// credential-store's assertHealthy(), where the lock protects a shared
|
|
928
|
+
// file; here it only decorates a dashboard observation.
|
|
929
|
+
if (error instanceof RunLockOwnershipLostError) {
|
|
930
|
+
// PROVEN ownership loss (a complete valid record whose token is not
|
|
931
|
+
// ours): revoke unlink authority BEFORE latching/logging so close()
|
|
932
|
+
// cannot delete a lock a different owner now holds.
|
|
933
|
+
this.lockHeld = false;
|
|
934
|
+
}
|
|
935
|
+
// ENOENT / EACCES / EIO / ELOOP / non-regular-file / oversized /
|
|
936
|
+
// malformed JSON / invalid record / short read stay "ordinary" and DO
|
|
937
|
+
// NOT clear lockHeld. That is safe ONLY under the confirmed
|
|
938
|
+
// SINGLE-PRODUCER topology: no other writer holds this exact lock, so a
|
|
939
|
+
// transient read failure is not evidence of a foreign owner and ENOENT
|
|
940
|
+
// is our own recycled/removed path, not a takeover. A future
|
|
941
|
+
// multi-producer change MUST revisit this asymmetry.
|
|
942
|
+
//
|
|
943
|
+
// Latching is PERMANENT for this run: a single ordinary transient
|
|
944
|
+
// failure (e.g. a rejected utimes / EIO) halts THIS run's heartbeat
|
|
945
|
+
// with NO retry — retry was deliberately omitted as over-engineering.
|
|
946
|
+
// A foreign-host dashboard may therefore age a live-but-transiently-
|
|
947
|
+
// erroring run to `orphaned` and never self-correct until the run ends.
|
|
948
|
+
// That is fail-safe: it only stops a liveness DECORATION, never mutates
|
|
949
|
+
// run state and never fails the run.
|
|
950
|
+
this.heartbeatError = error instanceof Error ? error : new Error(String(error));
|
|
951
|
+
logger.warn({ run_dir: this.runDir, err: this.heartbeatError.message }, "workflow run lock heartbeat stopped");
|
|
952
|
+
})
|
|
953
|
+
.finally(() => {
|
|
954
|
+
// Identity guard: only an obsolete completion of the CURRENT in-flight
|
|
955
|
+
// beat clears the slot; a newer promise is never clobbered.
|
|
956
|
+
if (this.heartbeatInFlight === beat) {
|
|
957
|
+
this.heartbeatInFlight = undefined;
|
|
958
|
+
}
|
|
959
|
+
});
|
|
960
|
+
this.heartbeatInFlight = beat;
|
|
961
|
+
}, RUN_LOCK_HEARTBEAT_MS);
|
|
962
|
+
this.heartbeat.unref();
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* One descriptor-bound heartbeat beat: open the lock O_NOFOLLOW read-only,
|
|
966
|
+
* fstat + bounded positional read through the SAME handle, verify it is still
|
|
967
|
+
* our token, then utimes that still-open handle. Throws
|
|
968
|
+
* `RunLockOwnershipLostError` only on a complete valid record whose token
|
|
969
|
+
* differs; every other failure is an ordinary Error.
|
|
970
|
+
*/
|
|
971
|
+
async beatRunLock() {
|
|
972
|
+
if (!this.lockHeld || this.heartbeatError != null)
|
|
973
|
+
return;
|
|
974
|
+
const handle = await open(this.lockPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
975
|
+
let propagating;
|
|
976
|
+
try {
|
|
977
|
+
const stats = await handle.stat();
|
|
978
|
+
if (!stats.isFile()) {
|
|
979
|
+
throw new Error(`run.lock at ${this.lockPath} is not a regular file`);
|
|
980
|
+
}
|
|
981
|
+
if (stats.size > RUN_LOCK_MAX_BYTES) {
|
|
982
|
+
throw new Error(`run.lock at ${this.lockPath} exceeds ${RUN_LOCK_MAX_BYTES} bytes`);
|
|
983
|
+
}
|
|
984
|
+
const size = Number(stats.size);
|
|
985
|
+
const buffer = Buffer.allocUnsafe(size);
|
|
986
|
+
let offset = 0;
|
|
987
|
+
while (offset < size) {
|
|
988
|
+
const { bytesRead } = await handle.read(buffer, offset, size - offset, offset);
|
|
989
|
+
if (bytesRead === 0) {
|
|
990
|
+
throw new Error(`run.lock at ${this.lockPath} ended prematurely after ${offset} of ${size} bytes`);
|
|
991
|
+
}
|
|
992
|
+
offset += bytesRead;
|
|
993
|
+
}
|
|
994
|
+
const value = JSON.parse(buffer.toString("utf-8"));
|
|
995
|
+
if (!isRunLockRecord(value)) {
|
|
996
|
+
throw new Error(`run.lock at ${this.lockPath} is not a valid lock record`);
|
|
997
|
+
}
|
|
998
|
+
if (typeof value.token !== "string") {
|
|
999
|
+
// A present-but-mistyped token is a corrupt/invalid record, NOT proven
|
|
1000
|
+
// ownership loss: treat it as an ordinary heartbeat error so it does not
|
|
1001
|
+
// revoke unlink authority. Under single-producer this producer always
|
|
1002
|
+
// writes a string token; this only guards hostile/corrupt lock bodies.
|
|
1003
|
+
throw new Error(`run.lock token is not a string at ${this.lockPath}`);
|
|
1004
|
+
}
|
|
1005
|
+
if (value.token !== this.lockToken) {
|
|
1006
|
+
throw new RunLockOwnershipLostError(this.lockPath, "lock token no longer matches this store");
|
|
1007
|
+
}
|
|
1008
|
+
const now = new Date();
|
|
1009
|
+
await handle.utimes(now, now);
|
|
1010
|
+
}
|
|
1011
|
+
catch (error) {
|
|
1012
|
+
propagating = error;
|
|
1013
|
+
throw error;
|
|
1014
|
+
}
|
|
1015
|
+
finally {
|
|
1016
|
+
try {
|
|
1017
|
+
await handle.close();
|
|
1018
|
+
}
|
|
1019
|
+
catch (closeError) {
|
|
1020
|
+
// Swallow a close failure only when another error is already
|
|
1021
|
+
// propagating; otherwise surface it.
|
|
1022
|
+
if (propagating === undefined)
|
|
1023
|
+
throw closeError;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
async stopHeartbeat() {
|
|
1028
|
+
if (this.heartbeat !== undefined) {
|
|
1029
|
+
clearInterval(this.heartbeat);
|
|
1030
|
+
this.heartbeat = undefined;
|
|
1031
|
+
}
|
|
1032
|
+
// At most one beat is ever active. Await it, swallowing the already-latched
|
|
1033
|
+
// failure; do NOT loop.
|
|
1034
|
+
const inFlight = this.heartbeatInFlight;
|
|
1035
|
+
if (inFlight !== undefined) {
|
|
1036
|
+
await inFlight.catch(() => { });
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
387
1039
|
async close() {
|
|
388
1040
|
await this.flush();
|
|
1041
|
+
// Ordering is load-bearing in both directions. Not before flush(): draining
|
|
1042
|
+
// the write chain can take time, and a lock aging while the run is provably
|
|
1043
|
+
// alive reads `orphaned` to a foreign host. Not after the unlink: a beat
|
|
1044
|
+
// racing the unlink would utimes a deleted — or recycled — path.
|
|
1045
|
+
await this.stopHeartbeat();
|
|
389
1046
|
if (this.state.status === "succeeded" && !this.debugKeepArtifacts) {
|
|
390
|
-
|
|
391
|
-
|
|
1047
|
+
// Successful runs remain observable until their retention window expires.
|
|
1048
|
+
// Release ownership so classification/pruning and immediate run-ID recycling
|
|
1049
|
+
// can safely operate on both synchronous and asynchronous successes.
|
|
1050
|
+
if (this.lockHeld) {
|
|
1051
|
+
await safeUnlink(this.lockPath);
|
|
1052
|
+
this.lockHeld = false;
|
|
1053
|
+
}
|
|
392
1054
|
return;
|
|
393
1055
|
}
|
|
394
1056
|
if (this.lockHeld) {
|
|
@@ -401,12 +1063,34 @@ export class WorkflowStateStore {
|
|
|
401
1063
|
const nextState = structuredClone(this.state);
|
|
402
1064
|
const callEntry = getCallEntry(nextState, event.stageId, event.callId);
|
|
403
1065
|
callEntry.retryCount = event.nextRetryCount;
|
|
404
|
-
callEntry.errorMessage = event.error;
|
|
1066
|
+
callEntry.errorMessage = capPersistedError(event.error);
|
|
405
1067
|
callEntry.failureClass = event.failureClass;
|
|
406
1068
|
this.state = nextState;
|
|
407
1069
|
await this.writeState();
|
|
408
1070
|
});
|
|
409
1071
|
}
|
|
1072
|
+
/** Runs inside the caller's existing queue task; never enqueue from here. */
|
|
1073
|
+
async finalizePlanReviewTerminalIntent(intent) {
|
|
1074
|
+
const previousState = this.state;
|
|
1075
|
+
const intended = applyPlanReviewTerminalIntent(previousState, intent);
|
|
1076
|
+
if (intended === undefined) {
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
const finalized = await finalizePlanReviewTerminalState({
|
|
1080
|
+
runDir: this.runDir,
|
|
1081
|
+
previousState,
|
|
1082
|
+
intendedState: intended,
|
|
1083
|
+
intent,
|
|
1084
|
+
chairmanCallId: resolvePlanReviewChairmanCallId(this.definition),
|
|
1085
|
+
serializedResult: this.planReviewTerminalResult,
|
|
1086
|
+
});
|
|
1087
|
+
validateStrictStateSubtrees(finalized.state, join(this.runDir, STATE_FILENAME));
|
|
1088
|
+
this.state = finalized.state;
|
|
1089
|
+
await this.writeState();
|
|
1090
|
+
if (finalized.throwAfterCommit !== undefined) {
|
|
1091
|
+
throw finalized.throwAfterCommit;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
410
1094
|
async enqueueWrite(task) {
|
|
411
1095
|
const next = this.writeChain.then(task, task);
|
|
412
1096
|
this.writeChain = next.catch(() => { });
|
|
@@ -420,7 +1104,7 @@ async function prepareRunDirForCreate(runDir, runId) {
|
|
|
420
1104
|
await mkdir(dirname(runDir), { recursive: true });
|
|
421
1105
|
try {
|
|
422
1106
|
await mkdir(runDir);
|
|
423
|
-
return false;
|
|
1107
|
+
return { recycled: false };
|
|
424
1108
|
}
|
|
425
1109
|
catch (error) {
|
|
426
1110
|
if (!(error instanceof Error) || !("code" in error) || error.code !== "EEXIST") {
|
|
@@ -428,7 +1112,7 @@ async function prepareRunDirForCreate(runDir, runId) {
|
|
|
428
1112
|
}
|
|
429
1113
|
}
|
|
430
1114
|
const recycled = await recycleSucceededRunDir(runDir);
|
|
431
|
-
if (recycled) {
|
|
1115
|
+
if (recycled.recycled) {
|
|
432
1116
|
const recycledPath = `${runDir}.recycled`;
|
|
433
1117
|
try {
|
|
434
1118
|
await mkdir(runDir);
|
|
@@ -445,7 +1129,7 @@ async function prepareRunDirForCreate(runDir, runId) {
|
|
|
445
1129
|
}
|
|
446
1130
|
throw error;
|
|
447
1131
|
}
|
|
448
|
-
return
|
|
1132
|
+
return recycled;
|
|
449
1133
|
}
|
|
450
1134
|
throw duplicateRunDirError(runId);
|
|
451
1135
|
}
|
|
@@ -453,6 +1137,14 @@ async function pruneRunDirectoriesBeforeCreate(runDir) {
|
|
|
453
1137
|
const runsRoot = getRunsDir();
|
|
454
1138
|
if (dirname(runDir) !== runsRoot)
|
|
455
1139
|
return;
|
|
1140
|
+
// Reconcile before pruning so a stale running run terminalized here becomes
|
|
1141
|
+
// prunable by a later create once past its retention window.
|
|
1142
|
+
try {
|
|
1143
|
+
await reconcileStaleRunDirectories(runsRoot);
|
|
1144
|
+
}
|
|
1145
|
+
catch (error) {
|
|
1146
|
+
logger.warn({ runs_root_dir: runsRoot, err: error instanceof Error ? error.message : String(error) }, "stale workflow run reconcile failed before create");
|
|
1147
|
+
}
|
|
456
1148
|
try {
|
|
457
1149
|
await pruneRunDirectories(runsRoot, getRetentionConfig());
|
|
458
1150
|
}
|
|
@@ -460,11 +1152,89 @@ async function pruneRunDirectoriesBeforeCreate(runDir) {
|
|
|
460
1152
|
logger.warn({ runs_root_dir: runsRoot, err: error instanceof Error ? error.message : String(error) }, "workflow run directory pruning failed before create");
|
|
461
1153
|
}
|
|
462
1154
|
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Bounds the per-create duplicate-replay scan: at most this many sibling run
|
|
1157
|
+
* directories have their persisted state read. Best-effort by design — a runs
|
|
1158
|
+
* root larger than the cap simply yields a possibly-incomplete signal.
|
|
1159
|
+
*/
|
|
1160
|
+
const MAX_DUPLICATE_REPLAY_SCAN_DIRS = 1_000;
|
|
1161
|
+
/**
|
|
1162
|
+
* Warn-only duplicate-replay detection (failure investigation F6): find a
|
|
1163
|
+
* sibling run whose persisted `workflowSnapshotHash` AND `inputsSnapshotHash`
|
|
1164
|
+
* both equal the run being created, and whose status is terminal `failed`
|
|
1165
|
+
* with `failureClass:"permanent"`. Cancelled runs and transient failures are
|
|
1166
|
+
* deliberately excluded; with multiple matches the most recent `completedAt`
|
|
1167
|
+
* wins.
|
|
1168
|
+
*
|
|
1169
|
+
* WARN-ONLY, never a gate: identical hashes do NOT imply an identical outcome.
|
|
1170
|
+
* The hashes cover only the workflow definition and resolved input variables —
|
|
1171
|
+
* not workspace content, which may have changed since the prior run — and
|
|
1172
|
+
* providers are nondeterministic, so an exact replay can legitimately succeed.
|
|
1173
|
+
* This function therefore never throws and never blocks creation: any scan or
|
|
1174
|
+
* per-sibling read error degrades silently (or with a warn log) to "no
|
|
1175
|
+
* signal", and admission, mutex, cap, and state-creation behavior are
|
|
1176
|
+
* unchanged.
|
|
1177
|
+
*/
|
|
1178
|
+
async function detectDuplicateReplay(runDir, workflowSnapshotHash, inputsSnapshotHash) {
|
|
1179
|
+
try {
|
|
1180
|
+
const runsRoot = dirname(runDir);
|
|
1181
|
+
const selfName = basename(runDir);
|
|
1182
|
+
let entries;
|
|
1183
|
+
try {
|
|
1184
|
+
entries = await readdir(runsRoot, { withFileTypes: true });
|
|
1185
|
+
}
|
|
1186
|
+
catch {
|
|
1187
|
+
return undefined;
|
|
1188
|
+
}
|
|
1189
|
+
let best;
|
|
1190
|
+
let bestCompletedAtMs = Number.NEGATIVE_INFINITY;
|
|
1191
|
+
let examined = 0;
|
|
1192
|
+
for (const entry of entries) {
|
|
1193
|
+
if (!entry.isDirectory() || entry.name === selfName)
|
|
1194
|
+
continue;
|
|
1195
|
+
if (examined >= MAX_DUPLICATE_REPLAY_SCAN_DIRS)
|
|
1196
|
+
break;
|
|
1197
|
+
examined += 1;
|
|
1198
|
+
let state;
|
|
1199
|
+
try {
|
|
1200
|
+
state = readRunStateSync(join(runsRoot, entry.name));
|
|
1201
|
+
}
|
|
1202
|
+
catch {
|
|
1203
|
+
// Unreadable siblings are skipped silently — mid-write, corrupt, or
|
|
1204
|
+
// foreign directories must never affect the new run.
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1207
|
+
if (state.status !== "failed" ||
|
|
1208
|
+
state.failureClass !== "permanent" ||
|
|
1209
|
+
state.workflowSnapshotHash !== workflowSnapshotHash ||
|
|
1210
|
+
state.inputsSnapshotHash !== inputsSnapshotHash) {
|
|
1211
|
+
continue;
|
|
1212
|
+
}
|
|
1213
|
+
const parsedCompletedAt = state.completedAt !== undefined
|
|
1214
|
+
? Date.parse(state.completedAt)
|
|
1215
|
+
: Number.NaN;
|
|
1216
|
+
const orderKey = Number.isFinite(parsedCompletedAt)
|
|
1217
|
+
? parsedCompletedAt
|
|
1218
|
+
: Number.NEGATIVE_INFINITY;
|
|
1219
|
+
if (best === undefined || orderKey > bestCompletedAtMs) {
|
|
1220
|
+
best = { runId: state.runId, completedAt: state.completedAt };
|
|
1221
|
+
bestCompletedAtMs = orderKey;
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
return best;
|
|
1225
|
+
}
|
|
1226
|
+
catch (error) {
|
|
1227
|
+
logger.warn({ run_dir: runDir, err: error instanceof Error ? error.message : String(error) }, "duplicate-replay scan failed; proceeding without signal");
|
|
1228
|
+
return undefined;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
463
1231
|
function normalizePruneOptions(options) {
|
|
464
1232
|
return {
|
|
465
1233
|
retainFailedDays: normalizeRetentionDays(options?.retainFailedDays, DEFAULT_RETAIN_FAILED_DAYS),
|
|
466
1234
|
retainCancelledDays: normalizeRetentionDays(options?.retainCancelledDays, DEFAULT_RETAIN_CANCELLED_DAYS),
|
|
467
1235
|
retainDebugDays: normalizeRetentionDays(options?.retainDebugDays, DEFAULT_RETAIN_DEBUG_DAYS),
|
|
1236
|
+
retainAsyncDays: normalizeRetentionDays(options?.retainAsyncDays, DEFAULT_RETAIN_ASYNC_DAYS),
|
|
1237
|
+
retainSucceededDays: normalizeRetentionDays(options?.retainSucceededDays, DEFAULT_RETAIN_SUCCEEDED_DAYS),
|
|
468
1238
|
};
|
|
469
1239
|
}
|
|
470
1240
|
function normalizeRetentionDays(value, fallback) {
|
|
@@ -478,6 +1248,10 @@ function retentionDaysForClassification(classification, options) {
|
|
|
478
1248
|
return options.retainCancelledDays;
|
|
479
1249
|
case "debug_kept":
|
|
480
1250
|
return options.retainDebugDays;
|
|
1251
|
+
case "succeeded_async":
|
|
1252
|
+
return options.retainAsyncDays;
|
|
1253
|
+
case "succeeded_sync":
|
|
1254
|
+
return options.retainSucceededDays;
|
|
481
1255
|
case "active_locked":
|
|
482
1256
|
case "running_or_pending":
|
|
483
1257
|
case "unknown":
|
|
@@ -493,6 +1267,44 @@ async function tryAcquirePruneLock(runDirPath) {
|
|
|
493
1267
|
return false;
|
|
494
1268
|
}
|
|
495
1269
|
}
|
|
1270
|
+
async function admitWorkflowRunGeneration(runsRootDir, runDirPath, runId) {
|
|
1271
|
+
const requestedRoot = resolve(runsRootDir);
|
|
1272
|
+
const requestedRun = resolve(runDirPath);
|
|
1273
|
+
if (requestedRun !== join(requestedRoot, runId)) {
|
|
1274
|
+
throw new Error("workflow run identity is not contained");
|
|
1275
|
+
}
|
|
1276
|
+
const rootLinked = await lstat(requestedRoot);
|
|
1277
|
+
if (rootLinked.isSymbolicLink() || !rootLinked.isDirectory()) {
|
|
1278
|
+
throw new Error("workflow runs root identity is unsafe");
|
|
1279
|
+
}
|
|
1280
|
+
const canonicalRoot = await realpath(requestedRoot);
|
|
1281
|
+
const canonicalRun = join(canonicalRoot, runId);
|
|
1282
|
+
const flags = fsConstants.O_RDONLY |
|
|
1283
|
+
(fsConstants.O_DIRECTORY ?? 0) |
|
|
1284
|
+
(fsConstants.O_NOFOLLOW ?? 0);
|
|
1285
|
+
const rootHandle = await open(canonicalRoot, flags);
|
|
1286
|
+
let runHandle;
|
|
1287
|
+
try {
|
|
1288
|
+
runHandle = await open(canonicalRun, flags);
|
|
1289
|
+
const rootStats = await rootHandle.stat({ bigint: true });
|
|
1290
|
+
const runStats = await runHandle.stat({ bigint: true });
|
|
1291
|
+
if (!rootStats.isDirectory() ||
|
|
1292
|
+
!runStats.isDirectory() ||
|
|
1293
|
+
rootStats.nlink === 0n ||
|
|
1294
|
+
runStats.nlink === 0n ||
|
|
1295
|
+
BigInt(rootLinked.dev) !== rootStats.dev ||
|
|
1296
|
+
BigInt(rootLinked.ino) !== rootStats.ino ||
|
|
1297
|
+
await resolveOpenedDescriptorPath(rootHandle.fd, canonicalRoot) !== canonicalRoot ||
|
|
1298
|
+
await resolveOpenedDescriptorPath(runHandle.fd, canonicalRun) !== canonicalRun) {
|
|
1299
|
+
throw new Error("workflow run identity changed during admission");
|
|
1300
|
+
}
|
|
1301
|
+
return deriveWorkflowRunGeneration(runId, rootStats, runStats);
|
|
1302
|
+
}
|
|
1303
|
+
finally {
|
|
1304
|
+
await runHandle?.close().catch(() => undefined);
|
|
1305
|
+
await rootHandle.close().catch(() => undefined);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
496
1308
|
async function getRunDirectoryAgeMs(runDirPath) {
|
|
497
1309
|
const completedAt = await readRunCompletedAt(runDirPath);
|
|
498
1310
|
if (completedAt != null) {
|
|
@@ -522,8 +1334,85 @@ function readRunStateSync(runDir) {
|
|
|
522
1334
|
if (state.state_version !== 1) {
|
|
523
1335
|
throw new Error(`Unsupported state_version ${String(state.state_version)} in ${statePath}`);
|
|
524
1336
|
}
|
|
1337
|
+
validateStrictStateSubtrees(state, statePath);
|
|
525
1338
|
return state;
|
|
526
1339
|
}
|
|
1340
|
+
/**
|
|
1341
|
+
* Strictly validate optional state subtrees that are resume or lifecycle
|
|
1342
|
+
* authorities. Older state_version:1 files may omit either subtree.
|
|
1343
|
+
*
|
|
1344
|
+
* The rest of RunState is still validated only by `state_version`. These are
|
|
1345
|
+
* exceptions because resume derives execution bindings from plan_review_state,
|
|
1346
|
+
* while lifecycle is a strict bounded H0 contract and must never acquire
|
|
1347
|
+
* arbitrary producer fields.
|
|
1348
|
+
*/
|
|
1349
|
+
function validateStrictStateSubtrees(state, statePath) {
|
|
1350
|
+
if (state.workflow_context !== undefined) {
|
|
1351
|
+
try {
|
|
1352
|
+
state = {
|
|
1353
|
+
...state,
|
|
1354
|
+
workflow_context: parseWorkflowContext(state.workflow_context),
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
catch (error) {
|
|
1358
|
+
throw new Error(`Invalid workflow_context in ${statePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
let parsedLifecycle;
|
|
1362
|
+
if (state.context_builder_lifecycle !== undefined) {
|
|
1363
|
+
try {
|
|
1364
|
+
parsedLifecycle = parseContextBuilderLifecycleStateV1(state.context_builder_lifecycle);
|
|
1365
|
+
}
|
|
1366
|
+
catch (error) {
|
|
1367
|
+
throw new Error(`Invalid context_builder_lifecycle in ${statePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
const nestedLifecycle = state.workflow_context?.context_builder.lifecycle;
|
|
1371
|
+
let parsedNestedLifecycle;
|
|
1372
|
+
if (nestedLifecycle !== undefined) {
|
|
1373
|
+
try {
|
|
1374
|
+
parsedNestedLifecycle = parseContextBuilderLifecycleStateV1(nestedLifecycle);
|
|
1375
|
+
}
|
|
1376
|
+
catch (error) {
|
|
1377
|
+
throw new Error(`Invalid workflow_context.context_builder.lifecycle in ${statePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
if (state.workflow_context !== undefined) {
|
|
1381
|
+
const sourceRunId = state.workflow_context.context_builder.source_run_id;
|
|
1382
|
+
for (const lifecycle of [parsedLifecycle, parsedNestedLifecycle]) {
|
|
1383
|
+
if (lifecycle !== undefined &&
|
|
1384
|
+
((lifecycle.status !== "succeeded" && lifecycle.status !== "partial") ||
|
|
1385
|
+
lifecycle.phase !== "complete" ||
|
|
1386
|
+
lifecycle.artifact_run_id !== sourceRunId)) {
|
|
1387
|
+
throw new Error(`Inconsistent workflow context lifecycle in ${statePath}`);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
if (parsedLifecycle !== undefined &&
|
|
1391
|
+
parsedNestedLifecycle !== undefined &&
|
|
1392
|
+
!lifecycleSnapshotsEqual(parsedLifecycle, parsedNestedLifecycle)) {
|
|
1393
|
+
throw new Error(`Inconsistent workflow context lifecycle in ${statePath}`);
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
if (state.plan_review_state !== undefined) {
|
|
1397
|
+
const parsed = PlanReviewRunStateSchema.safeParse(state.plan_review_state);
|
|
1398
|
+
if (!parsed.success) {
|
|
1399
|
+
throw new Error(`Invalid plan_review_state in ${statePath}: ${parsed.error.issues
|
|
1400
|
+
.map((issue) => `${issue.path.join(".") || "plan_review_state"}: ${issue.message}`)
|
|
1401
|
+
.join("; ")}`);
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
if (state.plan_review_error !== undefined) {
|
|
1405
|
+
const parsed = CouncilPlanReviewErrorSchema.safeParse(state.plan_review_error);
|
|
1406
|
+
if (!parsed.success) {
|
|
1407
|
+
throw new Error(`Invalid plan_review_error in ${statePath}: ${parsed.error.issues
|
|
1408
|
+
.map((issue) => `${issue.path.join(".") || "plan_review_error"}: ${issue.message}`)
|
|
1409
|
+
.join("; ")}`);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
function lifecycleSnapshotsEqual(left, right) {
|
|
1414
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
1415
|
+
}
|
|
527
1416
|
function classifyRunLockSync(lockPath) {
|
|
528
1417
|
try {
|
|
529
1418
|
const lock = JSON.parse(readFileSync(lockPath, "utf-8"));
|
|
@@ -540,11 +1429,275 @@ function classifyRunLockSync(lockPath) {
|
|
|
540
1429
|
return code === "ENOENT" ? "stale_or_absent" : "unknown";
|
|
541
1430
|
}
|
|
542
1431
|
}
|
|
1432
|
+
export async function readRunStateForStatus(runId) {
|
|
1433
|
+
const runDir = getRunStateDir(runId);
|
|
1434
|
+
let state;
|
|
1435
|
+
try {
|
|
1436
|
+
state = await readRunState(runDir);
|
|
1437
|
+
}
|
|
1438
|
+
catch (error) {
|
|
1439
|
+
const code = error.code;
|
|
1440
|
+
if (code === "ENOENT") {
|
|
1441
|
+
return null;
|
|
1442
|
+
}
|
|
1443
|
+
throw error;
|
|
1444
|
+
}
|
|
1445
|
+
const lockStatus = classifyRunLockSync(join(runDir, RUN_LOCK_FILENAME));
|
|
1446
|
+
return {
|
|
1447
|
+
state,
|
|
1448
|
+
lock: lockStatus,
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
/**
|
|
1452
|
+
* Reap a run that has no live worker (orphaned/stale/prior-process) by writing a
|
|
1453
|
+
* terminal "cancelled" state and unlinking its lock. Used by cancel_workflow when
|
|
1454
|
+
* the run is not in this process's in-memory cancel registry.
|
|
1455
|
+
*
|
|
1456
|
+
* The run.lock is the single source of truth for ownership: this acquires the
|
|
1457
|
+
* lock itself (reusing acquireRunLock, which atomically creates the lock and
|
|
1458
|
+
* reclaims a stale same-host/dead-PID lock) BEFORE mutating, closing the TOCTOU
|
|
1459
|
+
* where a concurrent resume_workflow could grab the lock between a caller's
|
|
1460
|
+
* status read and the reap write.
|
|
1461
|
+
*
|
|
1462
|
+
* Returns:
|
|
1463
|
+
* - "locked" — a LIVE lock blocks acquisition (held by a running worker, or a
|
|
1464
|
+
* same-process blocking run); nothing is mutated.
|
|
1465
|
+
* - "ownership_unknown" — the lock is malformed/unreadable; nothing is mutated
|
|
1466
|
+
* or reclaimed.
|
|
1467
|
+
* - "skipped" — lock acquired but the run is already terminal (not
|
|
1468
|
+
* pending/running); the lock is released and nothing is mutated.
|
|
1469
|
+
* The caller re-reads to report already_terminal.
|
|
1470
|
+
* - "cancelled" — lock acquired and run was pending/running; terminal
|
|
1471
|
+
* "cancelled" state is written and the lock removed.
|
|
1472
|
+
*
|
|
1473
|
+
* NOTE: This intentionally does NOT use WorkflowStateStore.resume() (which
|
|
1474
|
+
* recovers + re-locks the run — the wrong intent here).
|
|
1475
|
+
*/
|
|
1476
|
+
export async function reapRunCancelled(runId) {
|
|
1477
|
+
const runDir = getRunStateDir(runId);
|
|
1478
|
+
try {
|
|
1479
|
+
await acquireRunLock(runDir);
|
|
1480
|
+
}
|
|
1481
|
+
catch (error) {
|
|
1482
|
+
if (error instanceof RunLockOwnershipUnknownError) {
|
|
1483
|
+
return "ownership_unknown";
|
|
1484
|
+
}
|
|
1485
|
+
// A live lock holder (running worker or same-process blocking run) prevents
|
|
1486
|
+
// acquisition; a truly stale/dead lock would have been reclaimed instead.
|
|
1487
|
+
return "locked";
|
|
1488
|
+
}
|
|
1489
|
+
const lockPath = join(runDir, RUN_LOCK_FILENAME);
|
|
1490
|
+
let lockHeld = true;
|
|
1491
|
+
try {
|
|
1492
|
+
const state = await readRunState(runDir);
|
|
1493
|
+
if (state.status !== "pending" && state.status !== "running") {
|
|
1494
|
+
return "skipped";
|
|
1495
|
+
}
|
|
1496
|
+
let nextState = structuredClone(state);
|
|
1497
|
+
if (isPlanReviewRun(state)) {
|
|
1498
|
+
const intent = {
|
|
1499
|
+
status: "cancelled",
|
|
1500
|
+
completedAt: new Date().toISOString(),
|
|
1501
|
+
error: "Reaped: no live worker process",
|
|
1502
|
+
replaceFailureClass: false,
|
|
1503
|
+
};
|
|
1504
|
+
const intended = applyPlanReviewTerminalIntent(state, intent);
|
|
1505
|
+
if (intended === undefined) {
|
|
1506
|
+
return "skipped";
|
|
1507
|
+
}
|
|
1508
|
+
const finalized = await finalizePlanReviewTerminalState({
|
|
1509
|
+
runDir,
|
|
1510
|
+
previousState: state,
|
|
1511
|
+
intendedState: intended,
|
|
1512
|
+
intent,
|
|
1513
|
+
});
|
|
1514
|
+
nextState = finalized.state;
|
|
1515
|
+
validateStrictStateSubtrees(nextState, join(runDir, STATE_FILENAME));
|
|
1516
|
+
}
|
|
1517
|
+
else {
|
|
1518
|
+
nextState.status = "cancelled";
|
|
1519
|
+
nextState.completedAt = new Date().toISOString();
|
|
1520
|
+
nextState.error = "Reaped: no live worker process";
|
|
1521
|
+
}
|
|
1522
|
+
await writeJsonAtomic(join(runDir, STATE_FILENAME), nextState);
|
|
1523
|
+
await safeUnlink(lockPath);
|
|
1524
|
+
lockHeld = false;
|
|
1525
|
+
return "cancelled";
|
|
1526
|
+
}
|
|
1527
|
+
finally {
|
|
1528
|
+
if (lockHeld) {
|
|
1529
|
+
await safeUnlink(lockPath);
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
/**
|
|
1534
|
+
* Terminalize run directories frozen as running/pending with no live worker.
|
|
1535
|
+
*
|
|
1536
|
+
* A worker that dies between state writes (e.g. during a retry backoff delay,
|
|
1537
|
+
* where applyRetryUpdate has already persisted errorMessage/failureClass while
|
|
1538
|
+
* the call state stays "running") leaves the run persisted as `running`
|
|
1539
|
+
* forever: the only other reconcile lives in WorkflowStateStore.resume(),
|
|
1540
|
+
* which non-resumable runs never reach, and pruning skips running_or_pending
|
|
1541
|
+
* directories. This scan converts such runs to terminal `failed` so status
|
|
1542
|
+
* projection and retention can move on. No new persisted status is introduced;
|
|
1543
|
+
* STALE_RUN_RECONCILE_ERROR is the distinct terminal representation.
|
|
1544
|
+
*
|
|
1545
|
+
* Liveness authority is classifyRunLockSync's same-host PID probe only:
|
|
1546
|
+
* a run is touched solely when its lock is "stale_or_absent" (missing lock or
|
|
1547
|
+
* same-host dead PID). "held" (live PID, or ANY foreign-host lock) and
|
|
1548
|
+
* "unknown" (malformed lock) are skipped entirely, and no decision here
|
|
1549
|
+
* consults heartbeat mtime freshness. TOCTOU-safety mirrors reapRunCancelled:
|
|
1550
|
+
* acquire the run.lock (no heartbeat declaration, like other reap
|
|
1551
|
+
* acquisitions), re-read state under the lock, re-verify non-terminal, write
|
|
1552
|
+
* once, then release. Idempotent: reconciled runs are terminal `failed` and
|
|
1553
|
+
* count as skipped_terminal on re-scan.
|
|
1554
|
+
*/
|
|
1555
|
+
export async function reconcileStaleRunDirectories(runsRootDir) {
|
|
1556
|
+
const result = {
|
|
1557
|
+
scanned: 0,
|
|
1558
|
+
reconciled: 0,
|
|
1559
|
+
skipped_held: 0,
|
|
1560
|
+
skipped_terminal: 0,
|
|
1561
|
+
errors: 0,
|
|
1562
|
+
};
|
|
1563
|
+
let entries;
|
|
1564
|
+
try {
|
|
1565
|
+
entries = await readdir(runsRootDir, { withFileTypes: true });
|
|
1566
|
+
}
|
|
1567
|
+
catch (error) {
|
|
1568
|
+
const code = error.code;
|
|
1569
|
+
if (code !== "ENOENT") {
|
|
1570
|
+
result.errors += 1;
|
|
1571
|
+
logger.warn({ runs_root_dir: runsRootDir, err: error instanceof Error ? error.message : String(error) }, "failed to read workflow runs directory for stale-run reconcile");
|
|
1572
|
+
}
|
|
1573
|
+
return result;
|
|
1574
|
+
}
|
|
1575
|
+
for (const entry of entries) {
|
|
1576
|
+
if (!entry.isDirectory()) {
|
|
1577
|
+
continue;
|
|
1578
|
+
}
|
|
1579
|
+
const runDirPath = join(runsRootDir, entry.name);
|
|
1580
|
+
result.scanned += 1;
|
|
1581
|
+
let state;
|
|
1582
|
+
try {
|
|
1583
|
+
state = readRunStateSync(runDirPath);
|
|
1584
|
+
}
|
|
1585
|
+
catch (error) {
|
|
1586
|
+
result.errors += 1;
|
|
1587
|
+
logger.warn({ run_dir: runDirPath, err: error instanceof Error ? error.message : String(error) }, "skipping run with unreadable state during stale-run reconcile");
|
|
1588
|
+
continue;
|
|
1589
|
+
}
|
|
1590
|
+
if (state.status !== "running" && state.status !== "pending") {
|
|
1591
|
+
result.skipped_terminal += 1;
|
|
1592
|
+
continue;
|
|
1593
|
+
}
|
|
1594
|
+
const lockStatus = classifyRunLockSync(join(runDirPath, RUN_LOCK_FILENAME));
|
|
1595
|
+
if (lockStatus !== "stale_or_absent") {
|
|
1596
|
+
// "held" (live same-host PID or any foreign-host lock) and "unknown"
|
|
1597
|
+
// (malformed lock) are both untouchable — never infer liveness beyond
|
|
1598
|
+
// the same-host PID probe.
|
|
1599
|
+
result.skipped_held += 1;
|
|
1600
|
+
continue;
|
|
1601
|
+
}
|
|
1602
|
+
try {
|
|
1603
|
+
const outcome = await reconcileStaleRun(runDirPath);
|
|
1604
|
+
result[outcome] += 1;
|
|
1605
|
+
}
|
|
1606
|
+
catch (error) {
|
|
1607
|
+
result.errors += 1;
|
|
1608
|
+
logger.warn({ run_dir: runDirPath, err: error instanceof Error ? error.message : String(error) }, "failed to reconcile stale workflow run directory");
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
logger.info({ runs_root_dir: runsRootDir, ...result }, "stale workflow run reconcile complete");
|
|
1612
|
+
return result;
|
|
1613
|
+
}
|
|
1614
|
+
/**
|
|
1615
|
+
* Terminalize one pre-screened stale run under its own lock. See
|
|
1616
|
+
* reconcileStaleRunDirectories for the authority and safety model.
|
|
1617
|
+
*/
|
|
1618
|
+
async function reconcileStaleRun(runDir) {
|
|
1619
|
+
try {
|
|
1620
|
+
await acquireRunLock(runDir);
|
|
1621
|
+
}
|
|
1622
|
+
catch {
|
|
1623
|
+
// A live lock holder appeared between the pre-screen and acquisition, or
|
|
1624
|
+
// ownership is unknown; either way nothing is mutated.
|
|
1625
|
+
return "skipped_held";
|
|
1626
|
+
}
|
|
1627
|
+
const lockPath = join(runDir, RUN_LOCK_FILENAME);
|
|
1628
|
+
try {
|
|
1629
|
+
const state = await readRunState(runDir);
|
|
1630
|
+
if (state.status !== "pending" && state.status !== "running") {
|
|
1631
|
+
return "skipped_terminal";
|
|
1632
|
+
}
|
|
1633
|
+
const reconciledAt = new Date().toISOString();
|
|
1634
|
+
let nextState = structuredClone(state);
|
|
1635
|
+
for (const stage of nextState.stages) {
|
|
1636
|
+
for (const call of stage.calls) {
|
|
1637
|
+
if (call.state === "running") {
|
|
1638
|
+
call.state = "failed";
|
|
1639
|
+
call.completedAt = reconciledAt;
|
|
1640
|
+
// Preserve an already-recorded error verbatim: a retry-scheduled
|
|
1641
|
+
// "permanent" classification must stay permanent (deliberately NOT
|
|
1642
|
+
// resume()'s downgrade-to-transient recovery behavior).
|
|
1643
|
+
if (call.errorMessage === undefined) {
|
|
1644
|
+
call.errorMessage = STALE_RUN_RECONCILE_CALL_ERROR;
|
|
1645
|
+
}
|
|
1646
|
+
if (call.failureClass === undefined) {
|
|
1647
|
+
call.failureClass = "transient";
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
stage.state = deriveStageState(stage.calls);
|
|
1652
|
+
stage.completedAt = deriveStageCompletedAt(stage.calls);
|
|
1653
|
+
}
|
|
1654
|
+
if (isPlanReviewRun(state)) {
|
|
1655
|
+
const intent = {
|
|
1656
|
+
status: "failed",
|
|
1657
|
+
completedAt: reconciledAt,
|
|
1658
|
+
error: STALE_RUN_RECONCILE_ERROR,
|
|
1659
|
+
failureClass: "transient",
|
|
1660
|
+
replaceFailureClass: true,
|
|
1661
|
+
};
|
|
1662
|
+
const intended = applyPlanReviewTerminalIntent(nextState, intent);
|
|
1663
|
+
if (intended === undefined) {
|
|
1664
|
+
return "skipped_terminal";
|
|
1665
|
+
}
|
|
1666
|
+
const finalized = await finalizePlanReviewTerminalState({
|
|
1667
|
+
runDir,
|
|
1668
|
+
previousState: state,
|
|
1669
|
+
intendedState: intended,
|
|
1670
|
+
intent,
|
|
1671
|
+
});
|
|
1672
|
+
nextState = finalized.state;
|
|
1673
|
+
validateStrictStateSubtrees(nextState, join(runDir, STATE_FILENAME));
|
|
1674
|
+
}
|
|
1675
|
+
else {
|
|
1676
|
+
nextState.status = "failed";
|
|
1677
|
+
nextState.completedAt = reconciledAt;
|
|
1678
|
+
nextState.error = STALE_RUN_RECONCILE_ERROR;
|
|
1679
|
+
nextState.failureClass = "transient";
|
|
1680
|
+
}
|
|
1681
|
+
await writeJsonAtomic(join(runDir, STATE_FILENAME), nextState);
|
|
1682
|
+
logger.info({ run_dir: runDir, run_id: state.runId }, "reconciled stale running workflow run to terminal failed");
|
|
1683
|
+
return "reconciled";
|
|
1684
|
+
}
|
|
1685
|
+
finally {
|
|
1686
|
+
await safeUnlink(lockPath);
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
543
1689
|
function isRunLockRecord(value) {
|
|
544
1690
|
const record = asRecord(value);
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
1691
|
+
const pid = record["pid"];
|
|
1692
|
+
const host = record["hostname"];
|
|
1693
|
+
const startedAt = record["startedAt"];
|
|
1694
|
+
return typeof pid === "number" &&
|
|
1695
|
+
Number.isSafeInteger(pid) &&
|
|
1696
|
+
pid > 0 &&
|
|
1697
|
+
typeof host === "string" &&
|
|
1698
|
+
host.length > 0 &&
|
|
1699
|
+
typeof startedAt === "string" &&
|
|
1700
|
+
Number.isFinite(Date.parse(startedAt));
|
|
548
1701
|
}
|
|
549
1702
|
function duplicateRunDirError(runId) {
|
|
550
1703
|
return new WorkflowError(`Run directory for run_id "${runId}" already exists. Use a unique run_id for new runs. If this is a failed/cancelled resumable run, try resume_workflow.`);
|
|
@@ -557,19 +1710,40 @@ async function recycleSucceededRunDir(runDir) {
|
|
|
557
1710
|
lockHeld = true;
|
|
558
1711
|
}
|
|
559
1712
|
catch {
|
|
560
|
-
return false;
|
|
1713
|
+
return { recycled: false };
|
|
561
1714
|
}
|
|
562
1715
|
try {
|
|
563
1716
|
const state = await readRunState(runDir);
|
|
564
1717
|
if (state.status !== "succeeded") {
|
|
565
|
-
return false;
|
|
1718
|
+
return { recycled: false };
|
|
1719
|
+
}
|
|
1720
|
+
const identity = await readSessionLogGeneration(runDir);
|
|
1721
|
+
let retiredSessionLogGeneration;
|
|
1722
|
+
if (identity.kind === "available") {
|
|
1723
|
+
retiredSessionLogGeneration = identity.sessionLogGeneration;
|
|
1724
|
+
}
|
|
1725
|
+
else if (identity.kind === "legacy") {
|
|
1726
|
+
try {
|
|
1727
|
+
retiredSessionLogGeneration = await admitWorkflowRunGeneration(dirname(runDir), runDir, basename(runDir));
|
|
1728
|
+
}
|
|
1729
|
+
catch {
|
|
1730
|
+
logger.warn({ run_id: basename(runDir) }, "legacy recycled workflow session-log generation could not be admitted");
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
else {
|
|
1734
|
+
logger.warn({ run_id: basename(runDir) }, "recycled workflow session-log identity was unsafe");
|
|
566
1735
|
}
|
|
567
1736
|
await rename(runDir, `${runDir}.recycled`);
|
|
568
1737
|
lockHeld = false;
|
|
569
|
-
return
|
|
1738
|
+
return {
|
|
1739
|
+
recycled: true,
|
|
1740
|
+
...(retiredSessionLogGeneration === undefined
|
|
1741
|
+
? {}
|
|
1742
|
+
: { retiredSessionLogGeneration }),
|
|
1743
|
+
};
|
|
570
1744
|
}
|
|
571
1745
|
catch {
|
|
572
|
-
return false;
|
|
1746
|
+
return { recycled: false };
|
|
573
1747
|
}
|
|
574
1748
|
finally {
|
|
575
1749
|
if (lockHeld) {
|
|
@@ -577,6 +1751,21 @@ async function recycleSucceededRunDir(runDir) {
|
|
|
577
1751
|
}
|
|
578
1752
|
}
|
|
579
1753
|
}
|
|
1754
|
+
async function cleanupWorkflowSessionLogGeneration(runsRoot, runId, sessionLogGeneration) {
|
|
1755
|
+
try {
|
|
1756
|
+
const cleanup = await removeSessionLogGeneration({
|
|
1757
|
+
rootDir: join(dirname(resolve(runsRoot)), "session-logs"),
|
|
1758
|
+
family: "workflow",
|
|
1759
|
+
generation: sessionLogGeneration,
|
|
1760
|
+
});
|
|
1761
|
+
if (cleanup.kind === "unsafe" || cleanup.kind === "unavailable") {
|
|
1762
|
+
logger.warn({ run_id: runId, cleanup: cleanup.kind }, "recycled workflow parent removed but exact session-log cleanup was unavailable");
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
catch {
|
|
1766
|
+
logger.warn({ run_id: runId }, "recycled workflow parent removed but exact session-log cleanup failed");
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
580
1769
|
function getCallEntry(state, stageId, callId) {
|
|
581
1770
|
const stage = state.stages.find((entry) => entry.stageId === stageId);
|
|
582
1771
|
if (stage == null) {
|
|
@@ -610,6 +1799,27 @@ function deriveStageCompletedAt(calls) {
|
|
|
610
1799
|
.sort();
|
|
611
1800
|
return completedValues.length > 0 ? completedValues[completedValues.length - 1] : undefined;
|
|
612
1801
|
}
|
|
1802
|
+
function capPersistedError(message) {
|
|
1803
|
+
if (message === undefined) {
|
|
1804
|
+
return undefined;
|
|
1805
|
+
}
|
|
1806
|
+
const encoded = Buffer.from(message, "utf-8");
|
|
1807
|
+
if (encoded.length <= MAX_PERSISTED_ERROR_BYTES) {
|
|
1808
|
+
return message;
|
|
1809
|
+
}
|
|
1810
|
+
let end = MAX_PERSISTED_ERROR_BYTES;
|
|
1811
|
+
// Never split a multi-byte UTF-8 sequence: back off over continuation bytes
|
|
1812
|
+
// so the kept head decodes cleanly.
|
|
1813
|
+
while (end > 0) {
|
|
1814
|
+
const boundaryByte = encoded[end];
|
|
1815
|
+
if (boundaryByte === undefined || (boundaryByte & 0b1100_0000) !== 0b1000_0000) {
|
|
1816
|
+
break;
|
|
1817
|
+
}
|
|
1818
|
+
end -= 1;
|
|
1819
|
+
}
|
|
1820
|
+
const omitted = encoded.length - end;
|
|
1821
|
+
return `${encoded.subarray(0, end).toString("utf-8")} …[truncated ${omitted} bytes]`;
|
|
1822
|
+
}
|
|
613
1823
|
async function persistOutput(runDir, stageId, callId, outputText) {
|
|
614
1824
|
const sizeBytes = Buffer.byteLength(outputText, "utf-8");
|
|
615
1825
|
if (sizeBytes <= OUTPUT_INLINE_THRESHOLD_BYTES) {
|
|
@@ -629,6 +1839,7 @@ async function buildResumeState(definition, state, runDir) {
|
|
|
629
1839
|
const completedCalls = new Map();
|
|
630
1840
|
const preparedInputs = new Map();
|
|
631
1841
|
const failedCalls = new Map();
|
|
1842
|
+
const nextRetryCounts = new Map();
|
|
632
1843
|
const firstIncompleteStageIndex = definition.stages.findIndex((stage) => {
|
|
633
1844
|
const persistedStage = state.stages.find((entry) => entry.stageId === stage.id);
|
|
634
1845
|
return !persistedStage?.calls.every((call) => call.state === "succeeded");
|
|
@@ -653,6 +1864,7 @@ async function buildResumeState(definition, state, runDir) {
|
|
|
653
1864
|
stageId: stage.id,
|
|
654
1865
|
callId: call.id,
|
|
655
1866
|
error: entry.errorMessage,
|
|
1867
|
+
terminal: true,
|
|
656
1868
|
});
|
|
657
1869
|
}
|
|
658
1870
|
}
|
|
@@ -670,10 +1882,16 @@ async function buildResumeState(definition, state, runDir) {
|
|
|
670
1882
|
stageId: stage.id,
|
|
671
1883
|
callId: call.id,
|
|
672
1884
|
error: entry.errorMessage,
|
|
1885
|
+
// Only terminal failures are persisted as `failed`; retryable
|
|
1886
|
+
// intermediate attempts remain `running` with attemptsConsumed.
|
|
1887
|
+
// Never redispatch a persisted failed call after process restart.
|
|
1888
|
+
terminal: true,
|
|
673
1889
|
});
|
|
674
1890
|
}
|
|
675
1891
|
if (entry?.materializedInputPath != null) {
|
|
676
1892
|
preparedInputs.set(`${stage.id}.${call.id}`, await readJsonFile(join(runDir, entry.materializedInputPath)));
|
|
1893
|
+
nextRetryCounts.set(`${stage.id}.${call.id}`, entry.attemptsConsumed ??
|
|
1894
|
+
(entry.state === "running" ? entry.retryCount + 1 : entry.retryCount));
|
|
677
1895
|
}
|
|
678
1896
|
}
|
|
679
1897
|
}
|
|
@@ -682,6 +1900,7 @@ async function buildResumeState(definition, state, runDir) {
|
|
|
682
1900
|
completedCalls,
|
|
683
1901
|
preparedInputs,
|
|
684
1902
|
failedCalls,
|
|
1903
|
+
nextRetryCounts,
|
|
685
1904
|
};
|
|
686
1905
|
}
|
|
687
1906
|
async function rehydrateExecutedCall(runDir, entry) {
|
|
@@ -878,6 +2097,7 @@ async function readRunState(runDir) {
|
|
|
878
2097
|
if (state.state_version !== 1) {
|
|
879
2098
|
throw new Error(`Unsupported state_version ${String(state.state_version)} in ${statePath}`);
|
|
880
2099
|
}
|
|
2100
|
+
validateStrictStateSubtrees(state, statePath);
|
|
881
2101
|
return state;
|
|
882
2102
|
}
|
|
883
2103
|
async function verifySnapshotHash(filePath, expectedHash, label) {
|
|
@@ -913,12 +2133,25 @@ function hashContent(content) {
|
|
|
913
2133
|
async function readJsonFile(filePath) {
|
|
914
2134
|
return JSON.parse(await readFile(filePath, "utf-8"));
|
|
915
2135
|
}
|
|
916
|
-
|
|
2136
|
+
/**
|
|
2137
|
+
* Atomically create `run.lock`, reclaiming a same-host dead-PID lock.
|
|
2138
|
+
*
|
|
2139
|
+
* `declareHeartbeat` is deliberately opt-in: only a caller that goes on to hold
|
|
2140
|
+
* the lock behind a live `WorkflowStateStore` (create/resume) beats it. The
|
|
2141
|
+
* prune/reap/recycle callers hold the lock briefly with no beating owner, so
|
|
2142
|
+
* declaring a heartbeat there would publish a capability claim nobody honors —
|
|
2143
|
+
* and a foreign-host reader would then age it into a false `orphaned`.
|
|
2144
|
+
*/
|
|
2145
|
+
async function acquireRunLock(runDir, options = {}) {
|
|
917
2146
|
const lockPath = join(runDir, RUN_LOCK_FILENAME);
|
|
918
2147
|
const record = {
|
|
919
2148
|
pid: process.pid,
|
|
920
2149
|
hostname: hostname(),
|
|
921
2150
|
startedAt: new Date().toISOString(),
|
|
2151
|
+
token: randomBytes(16).toString("base64url"),
|
|
2152
|
+
...(options.declareHeartbeat === true
|
|
2153
|
+
? { heartbeatIntervalMs: RUN_LOCK_HEARTBEAT_MS }
|
|
2154
|
+
: {}),
|
|
922
2155
|
};
|
|
923
2156
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
924
2157
|
try {
|
|
@@ -929,13 +2162,26 @@ async function acquireRunLock(runDir) {
|
|
|
929
2162
|
finally {
|
|
930
2163
|
await handle.close();
|
|
931
2164
|
}
|
|
932
|
-
return;
|
|
2165
|
+
return record;
|
|
933
2166
|
}
|
|
934
2167
|
catch (error) {
|
|
935
2168
|
if (!(error instanceof Error) || !("code" in error) || error.code !== "EEXIST") {
|
|
936
2169
|
throw error;
|
|
937
2170
|
}
|
|
938
|
-
|
|
2171
|
+
let existingValue;
|
|
2172
|
+
try {
|
|
2173
|
+
existingValue = await readJsonFile(lockPath);
|
|
2174
|
+
}
|
|
2175
|
+
catch (readError) {
|
|
2176
|
+
if (readError.code === "ENOENT") {
|
|
2177
|
+
continue;
|
|
2178
|
+
}
|
|
2179
|
+
throw new RunLockOwnershipUnknownError(lockPath, readError instanceof Error ? readError.message : String(readError));
|
|
2180
|
+
}
|
|
2181
|
+
if (!isRunLockRecord(existingValue)) {
|
|
2182
|
+
throw new RunLockOwnershipUnknownError(lockPath, "record must contain a positive integer pid, non-empty hostname, and valid startedAt timestamp");
|
|
2183
|
+
}
|
|
2184
|
+
const existing = existingValue;
|
|
939
2185
|
if (existing.hostname === hostname() && !isPidLive(existing.pid)) {
|
|
940
2186
|
await unlink(lockPath);
|
|
941
2187
|
continue;
|
|
@@ -943,6 +2189,7 @@ async function acquireRunLock(runDir) {
|
|
|
943
2189
|
throw new Error(`Run lock is held for ${runDir} by pid ${String(existing.pid)} on ${existing.hostname} since ${existing.startedAt}`);
|
|
944
2190
|
}
|
|
945
2191
|
}
|
|
2192
|
+
throw new Error(`Failed to acquire run lock for ${runDir} after 2 attempts; lock ownership was not established`);
|
|
946
2193
|
}
|
|
947
2194
|
function isPidLive(pid) {
|
|
948
2195
|
try {
|