@cleocode/core 2026.9.8 → 2026.9.9
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/dist/audit/reconstruct.d.ts +13 -43
- package/dist/audit/reconstruct.d.ts.map +1 -1
- package/dist/audit/reconstruct.js +295 -290
- package/dist/audit/reconstruct.js.map +1 -1
- package/dist/bootstrap.d.ts.map +1 -1
- package/dist/bootstrap.js +8 -2
- package/dist/bootstrap.js.map +1 -1
- package/dist/dispatch/mutate-projection.d.ts.map +1 -1
- package/dist/dispatch/mutate-projection.js +12 -0
- package/dist/dispatch/mutate-projection.js.map +1 -1
- package/dist/dispatch/mvi-projection.d.ts +29 -6
- package/dist/dispatch/mvi-projection.d.ts.map +1 -1
- package/dist/dispatch/mvi-projection.js +109 -139
- package/dist/dispatch/mvi-projection.js.map +1 -1
- package/dist/docs/docs-read-model.d.ts +3 -2
- package/dist/docs/docs-read-model.d.ts.map +1 -1
- package/dist/docs/docs-read-model.js +4 -3
- package/dist/docs/docs-read-model.js.map +1 -1
- package/dist/docs/export-document.js +5100 -2297
- package/dist/docs/export-document.js.map +3 -3
- package/dist/docs/projection.d.ts +67 -0
- package/dist/docs/projection.d.ts.map +1 -0
- package/dist/docs/projection.js +370 -0
- package/dist/docs/projection.js.map +1 -0
- package/dist/doctor/acceptance-drift.d.ts +6 -2
- package/dist/doctor/acceptance-drift.d.ts.map +1 -1
- package/dist/doctor/acceptance-drift.js +43 -5
- package/dist/doctor/acceptance-drift.js.map +1 -1
- package/dist/doctor/knowledge-summary.d.ts +41 -0
- package/dist/doctor/knowledge-summary.d.ts.map +1 -0
- package/dist/doctor/knowledge-summary.js +81 -0
- package/dist/doctor/knowledge-summary.js.map +1 -0
- package/dist/doctor/knowledge.d.ts +192 -0
- package/dist/doctor/knowledge.d.ts.map +1 -0
- package/dist/doctor/knowledge.js +1864 -0
- package/dist/doctor/knowledge.js.map +1 -0
- package/dist/git/work-tree.d.ts +64 -0
- package/dist/git/work-tree.d.ts.map +1 -0
- package/dist/git/work-tree.js +111 -0
- package/dist/git/work-tree.js.map +1 -0
- package/dist/hooks/handlers/task-hooks.d.ts +8 -5
- package/dist/hooks/handlers/task-hooks.d.ts.map +1 -1
- package/dist/hooks/handlers/task-hooks.js +15 -60
- package/dist/hooks/handlers/task-hooks.js.map +1 -1
- package/dist/identity/cleo-identity.d.ts +13 -0
- package/dist/identity/cleo-identity.d.ts.map +1 -1
- package/dist/identity/cleo-identity.js +20 -6
- package/dist/identity/cleo-identity.js.map +1 -1
- package/dist/injection.d.ts.map +1 -1
- package/dist/injection.js +29 -13
- package/dist/injection.js.map +1 -1
- package/dist/internal.d.ts +2 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +2 -0
- package/dist/internal.js.map +1 -1
- package/dist/lifecycle/verification/satisfies-validator.d.ts.map +1 -1
- package/dist/lifecycle/verification/satisfies-validator.js +124 -120
- package/dist/lifecycle/verification/satisfies-validator.js.map +1 -1
- package/dist/llm/plugin-facade.js +4018 -1726
- package/dist/llm/plugin-facade.js.map +3 -3
- package/dist/memory/brain-backfill.d.ts +27 -12
- package/dist/memory/brain-backfill.d.ts.map +1 -1
- package/dist/memory/brain-backfill.js +325 -134
- package/dist/memory/brain-backfill.js.map +1 -1
- package/dist/memory/brain-doctor.d.ts +21 -0
- package/dist/memory/brain-doctor.d.ts.map +1 -1
- package/dist/memory/brain-doctor.js +111 -53
- package/dist/memory/brain-doctor.js.map +1 -1
- package/dist/memory/brain-links.d.ts.map +1 -1
- package/dist/memory/brain-links.js +31 -4
- package/dist/memory/brain-links.js.map +1 -1
- package/dist/memory/brain-reasoning.d.ts.map +1 -1
- package/dist/memory/brain-reasoning.js +28 -2
- package/dist/memory/brain-reasoning.js.map +1 -1
- package/dist/memory/brain-reconciler.d.ts +14 -19
- package/dist/memory/brain-reconciler.d.ts.map +1 -1
- package/dist/memory/brain-reconciler.js +70 -97
- package/dist/memory/brain-reconciler.js.map +1 -1
- package/dist/memory/brain-search.d.ts +2 -0
- package/dist/memory/brain-search.d.ts.map +1 -1
- package/dist/memory/brain-search.js +59 -29
- package/dist/memory/brain-search.js.map +1 -1
- package/dist/memory/brain-similarity.d.ts.map +1 -1
- package/dist/memory/brain-similarity.js +5 -4
- package/dist/memory/brain-similarity.js.map +1 -1
- package/dist/memory/brain-stub-prune.d.ts +31 -9
- package/dist/memory/brain-stub-prune.d.ts.map +1 -1
- package/dist/memory/brain-stub-prune.js +65 -18
- package/dist/memory/brain-stub-prune.js.map +1 -1
- package/dist/memory/brain-writer-handlers.d.ts +10 -1
- package/dist/memory/brain-writer-handlers.d.ts.map +1 -1
- package/dist/memory/brain-writer-handlers.js +23 -3
- package/dist/memory/brain-writer-handlers.js.map +1 -1
- package/dist/memory/brain-writer-thread.d.ts +11 -1
- package/dist/memory/brain-writer-thread.d.ts.map +1 -1
- package/dist/memory/brain-writer-thread.js +87 -20
- package/dist/memory/brain-writer-thread.js.map +1 -1
- package/dist/memory/brain-writer-worker.js +8 -1
- package/dist/memory/brain-writer-worker.js.map +1 -1
- package/dist/memory/decision-cross-link.d.ts +39 -0
- package/dist/memory/decision-cross-link.d.ts.map +1 -1
- package/dist/memory/decision-cross-link.js +316 -0
- package/dist/memory/decision-cross-link.js.map +1 -1
- package/dist/memory/decisions.d.ts +4 -1
- package/dist/memory/decisions.d.ts.map +1 -1
- package/dist/memory/decisions.js +7 -24
- package/dist/memory/decisions.js.map +1 -1
- package/dist/memory/dispatch-trace.d.ts +12 -5
- package/dist/memory/dispatch-trace.d.ts.map +1 -1
- package/dist/memory/dispatch-trace.js +17 -7
- package/dist/memory/dispatch-trace.js.map +1 -1
- package/dist/memory/eligibility.d.ts +51 -0
- package/dist/memory/eligibility.d.ts.map +1 -0
- package/dist/memory/eligibility.js +77 -0
- package/dist/memory/eligibility.js.map +1 -0
- package/dist/memory/engine-compat.d.ts +2 -0
- package/dist/memory/engine-compat.d.ts.map +1 -1
- package/dist/memory/engine-compat.js +10 -0
- package/dist/memory/engine-compat.js.map +1 -1
- package/dist/memory/extraction-gate.d.ts +21 -7
- package/dist/memory/extraction-gate.d.ts.map +1 -1
- package/dist/memory/extraction-gate.js +178 -216
- package/dist/memory/extraction-gate.js.map +1 -1
- package/dist/memory/graph-auto-populate.d.ts +38 -5
- package/dist/memory/graph-auto-populate.d.ts.map +1 -1
- package/dist/memory/graph-auto-populate.js +182 -60
- package/dist/memory/graph-auto-populate.js.map +1 -1
- package/dist/memory/graph-memory-bridge.d.ts +14 -1
- package/dist/memory/graph-memory-bridge.d.ts.map +1 -1
- package/dist/memory/graph-memory-bridge.js +66 -28
- package/dist/memory/graph-memory-bridge.js.map +1 -1
- package/dist/memory/index.d.ts +1 -19
- package/dist/memory/index.d.ts.map +1 -1
- package/dist/memory/index.js +1 -50
- package/dist/memory/index.js.map +1 -1
- package/dist/memory/learnings.d.ts.map +1 -1
- package/dist/memory/learnings.js +2 -23
- package/dist/memory/learnings.js.map +1 -1
- package/dist/memory/manifest-filter.d.ts +21 -0
- package/dist/memory/manifest-filter.d.ts.map +1 -0
- package/dist/memory/manifest-filter.js +51 -0
- package/dist/memory/manifest-filter.js.map +1 -0
- package/dist/memory/manifest-ingestion.d.ts +28 -30
- package/dist/memory/manifest-ingestion.d.ts.map +1 -1
- package/dist/memory/manifest-ingestion.js +104 -235
- package/dist/memory/manifest-ingestion.js.map +1 -1
- package/dist/memory/observer-reflector.d.ts +2 -0
- package/dist/memory/observer-reflector.d.ts.map +1 -1
- package/dist/memory/observer-reflector.js +6 -49
- package/dist/memory/observer-reflector.js.map +1 -1
- package/dist/memory/patterns.d.ts +10 -0
- package/dist/memory/patterns.d.ts.map +1 -1
- package/dist/memory/patterns.js +25 -33
- package/dist/memory/patterns.js.map +1 -1
- package/dist/memory/pipeline-manifest-sqlite.d.ts +72 -9
- package/dist/memory/pipeline-manifest-sqlite.d.ts.map +1 -1
- package/dist/memory/pipeline-manifest-sqlite.js +508 -412
- package/dist/memory/pipeline-manifest-sqlite.js.map +1 -1
- package/dist/memory/retrieval/build-retrieval-bundle.d.ts.map +1 -1
- package/dist/memory/retrieval/build-retrieval-bundle.js +16 -15
- package/dist/memory/retrieval/build-retrieval-bundle.js.map +1 -1
- package/dist/memory/retrieval/fetch.d.ts +7 -0
- package/dist/memory/retrieval/fetch.d.ts.map +1 -1
- package/dist/memory/retrieval/fetch.js +14 -11
- package/dist/memory/retrieval/fetch.js.map +1 -1
- package/dist/memory/retrieval/observe.d.ts +7 -1
- package/dist/memory/retrieval/observe.d.ts.map +1 -1
- package/dist/memory/retrieval/observe.js +58 -4
- package/dist/memory/retrieval/observe.js.map +1 -1
- package/dist/memory/retrieval/search.d.ts +14 -1
- package/dist/memory/retrieval/search.d.ts.map +1 -1
- package/dist/memory/retrieval/search.js +133 -69
- package/dist/memory/retrieval/search.js.map +1 -1
- package/dist/memory/retrieval/telemetry.d.ts +31 -0
- package/dist/memory/retrieval/telemetry.d.ts.map +1 -0
- package/dist/memory/retrieval/telemetry.js +53 -0
- package/dist/memory/retrieval/telemetry.js.map +1 -0
- package/dist/nexus/analyze-orchestrator.d.ts +41 -9
- package/dist/nexus/analyze-orchestrator.d.ts.map +1 -1
- package/dist/nexus/analyze-orchestrator.js +257 -58
- package/dist/nexus/analyze-orchestrator.js.map +1 -1
- package/dist/nexus/api-contracts.d.ts +2 -6
- package/dist/nexus/api-contracts.d.ts.map +1 -1
- package/dist/nexus/api-contracts.js +11 -2
- package/dist/nexus/api-contracts.js.map +1 -1
- package/dist/nexus/context.d.ts.map +1 -1
- package/dist/nexus/context.js +14 -37
- package/dist/nexus/context.js.map +1 -1
- package/dist/nexus/diff.d.ts.map +1 -1
- package/dist/nexus/diff.js +2 -1
- package/dist/nexus/diff.js.map +1 -1
- package/dist/nexus/identity.d.ts +29 -3
- package/dist/nexus/identity.d.ts.map +1 -1
- package/dist/nexus/identity.js +63 -12
- package/dist/nexus/identity.js.map +1 -1
- package/dist/nexus/impact.d.ts +21 -19
- package/dist/nexus/impact.d.ts.map +1 -1
- package/dist/nexus/impact.js +96 -192
- package/dist/nexus/impact.js.map +1 -1
- package/dist/nexus/knowledge.d.ts +80 -0
- package/dist/nexus/knowledge.d.ts.map +1 -0
- package/dist/nexus/knowledge.js +786 -0
- package/dist/nexus/knowledge.js.map +1 -0
- package/dist/nexus/living-brain.d.ts +8 -5
- package/dist/nexus/living-brain.d.ts.map +1 -1
- package/dist/nexus/living-brain.js +252 -188
- package/dist/nexus/living-brain.js.map +1 -1
- package/dist/nexus/registry.d.ts +11 -2
- package/dist/nexus/registry.d.ts.map +1 -1
- package/dist/nexus/registry.js +144 -128
- package/dist/nexus/registry.js.map +1 -1
- package/dist/nexus/source-roots.d.ts +16 -0
- package/dist/nexus/source-roots.d.ts.map +1 -0
- package/dist/nexus/source-roots.js +174 -0
- package/dist/nexus/source-roots.js.map +1 -0
- package/dist/nexus/task-evidence.d.ts +22 -0
- package/dist/nexus/task-evidence.d.ts.map +1 -0
- package/dist/nexus/task-evidence.js +210 -0
- package/dist/nexus/task-evidence.js.map +1 -0
- package/dist/nexus/tasks-bridge.d.ts +14 -7
- package/dist/nexus/tasks-bridge.d.ts.map +1 -1
- package/dist/nexus/tasks-bridge.js +81 -40
- package/dist/nexus/tasks-bridge.js.map +1 -1
- package/dist/orchestrate/lifecycle-ops.d.ts.map +1 -1
- package/dist/orchestrate/lifecycle-ops.js +170 -120
- package/dist/orchestrate/lifecycle-ops.js.map +1 -1
- package/dist/orchestrate/query-ops.d.ts +6 -7
- package/dist/orchestrate/query-ops.d.ts.map +1 -1
- package/dist/orchestrate/query-ops.js +509 -532
- package/dist/orchestrate/query-ops.js.map +1 -1
- package/dist/orchestration/bootstrap.d.ts +15 -1
- package/dist/orchestration/bootstrap.d.ts.map +1 -1
- package/dist/orchestration/bootstrap.js +21 -10
- package/dist/orchestration/bootstrap.js.map +1 -1
- package/dist/orchestration/index.d.ts +35 -3
- package/dist/orchestration/index.d.ts.map +1 -1
- package/dist/orchestration/index.js +64 -35
- package/dist/orchestration/index.js.map +1 -1
- package/dist/orchestration/lead-rollup.d.ts +24 -1
- package/dist/orchestration/lead-rollup.d.ts.map +1 -1
- package/dist/orchestration/lead-rollup.js +31 -11
- package/dist/orchestration/lead-rollup.js.map +1 -1
- package/dist/orchestration/spawn-prompt.d.ts.map +1 -1
- package/dist/orchestration/spawn-prompt.js +16 -41
- package/dist/orchestration/spawn-prompt.js.map +1 -1
- package/dist/orchestration/unblock.d.ts +15 -1
- package/dist/orchestration/unblock.d.ts.map +1 -1
- package/dist/orchestration/unblock.js +23 -12
- package/dist/orchestration/unblock.js.map +1 -1
- package/dist/orchestration/validate-spawn.d.ts.map +1 -1
- package/dist/orchestration/validate-spawn.js +3 -3
- package/dist/orchestration/validate-spawn.js.map +1 -1
- package/dist/orchestration/waves.d.ts +29 -13
- package/dist/orchestration/waves.d.ts.map +1 -1
- package/dist/orchestration/waves.js +43 -55
- package/dist/orchestration/waves.js.map +1 -1
- package/dist/paths.d.ts +17 -163
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +145 -496
- package/dist/paths.js.map +1 -1
- package/dist/project-info.d.ts +2 -11
- package/dist/project-info.d.ts.map +1 -1
- package/dist/project-info.js +3 -31
- package/dist/project-info.js.map +1 -1
- package/dist/project-scope.d.ts +239 -0
- package/dist/project-scope.d.ts.map +1 -0
- package/dist/project-scope.js +473 -0
- package/dist/project-scope.js.map +1 -0
- package/dist/release/pr-evidence.d.ts +16 -3
- package/dist/release/pr-evidence.d.ts.map +1 -1
- package/dist/release/pr-evidence.js +53 -7
- package/dist/release/pr-evidence.js.map +1 -1
- package/dist/render/nexus/graph/index.d.ts.map +1 -1
- package/dist/render/nexus/graph/index.js +4 -2
- package/dist/render/nexus/graph/index.js.map +1 -1
- package/dist/render/orchestration/audit-reconstruct.d.ts +11 -0
- package/dist/render/orchestration/audit-reconstruct.d.ts.map +1 -1
- package/dist/render/orchestration/audit-reconstruct.js +21 -6
- package/dist/render/orchestration/audit-reconstruct.js.map +1 -1
- package/dist/render/tasks/show.js +2 -1
- package/dist/render/tasks/show.js.map +1 -1
- package/dist/resources/spawn-wrapper.d.ts +82 -14
- package/dist/resources/spawn-wrapper.d.ts.map +1 -1
- package/dist/resources/spawn-wrapper.js +585 -27
- package/dist/resources/spawn-wrapper.js.map +1 -1
- package/dist/selfimprove/fix-gen.js +4026 -1734
- package/dist/selfimprove/fix-gen.js.map +3 -3
- package/dist/sequence/index.d.ts.map +1 -1
- package/dist/sequence/index.js +76 -68
- package/dist/sequence/index.js.map +1 -1
- package/dist/session/canon-lint.d.ts +24 -3
- package/dist/session/canon-lint.d.ts.map +1 -1
- package/dist/session/canon-lint.js +72 -31
- package/dist/session/canon-lint.js.map +1 -1
- package/dist/session/engine-ops.d.ts +1 -4
- package/dist/session/engine-ops.d.ts.map +1 -1
- package/dist/session/engine-ops.js +6 -2
- package/dist/session/engine-ops.js.map +1 -1
- package/dist/sessions/briefing.d.ts +11 -1
- package/dist/sessions/briefing.d.ts.map +1 -1
- package/dist/sessions/briefing.js +76 -8
- package/dist/sessions/briefing.js.map +1 -1
- package/dist/sessions/handoff.d.ts +3 -1
- package/dist/sessions/handoff.d.ts.map +1 -1
- package/dist/sessions/handoff.js +2 -0
- package/dist/sessions/handoff.js.map +1 -1
- package/dist/shutdown-deadline.d.ts +24 -33
- package/dist/shutdown-deadline.d.ts.map +1 -1
- package/dist/shutdown-deadline.js +71 -14
- package/dist/shutdown-deadline.js.map +1 -1
- package/dist/shutdown.d.ts +21 -10
- package/dist/shutdown.d.ts.map +1 -1
- package/dist/shutdown.js +89 -42
- package/dist/shutdown.js.map +1 -1
- package/dist/skills/index.d.ts +1 -1
- package/dist/skills/index.d.ts.map +1 -1
- package/dist/skills/index.js +1 -1
- package/dist/skills/index.js.map +1 -1
- package/dist/skills/orchestrator/validator.d.ts +87 -20
- package/dist/skills/orchestrator/validator.d.ts.map +1 -1
- package/dist/skills/orchestrator/validator.js +330 -241
- package/dist/skills/orchestrator/validator.js.map +1 -1
- package/dist/store/agent-resolver.d.ts.map +1 -1
- package/dist/store/agent-resolver.js +16 -9
- package/dist/store/agent-resolver.js.map +1 -1
- package/dist/store/attachment-store.d.ts +4 -1
- package/dist/store/attachment-store.d.ts.map +1 -1
- package/dist/store/attachment-store.js +20 -3
- package/dist/store/attachment-store.js.map +1 -1
- package/dist/store/background-jobs.d.ts +426 -105
- package/dist/store/background-jobs.d.ts.map +1 -1
- package/dist/store/background-jobs.js +1208 -252
- package/dist/store/background-jobs.js.map +1 -1
- package/dist/store/background-ops.d.ts +145 -8
- package/dist/store/background-ops.d.ts.map +1 -1
- package/dist/store/background-ops.js +459 -15
- package/dist/store/background-ops.js.map +1 -1
- package/dist/store/backup-inspect.d.ts +48 -4
- package/dist/store/backup-inspect.d.ts.map +1 -1
- package/dist/store/backup-inspect.js +384 -4
- package/dist/store/backup-inspect.js.map +1 -1
- package/dist/store/converters.d.ts.map +1 -1
- package/dist/store/converters.js +19 -2
- package/dist/store/converters.js.map +1 -1
- package/dist/store/data-accessor.d.ts +9 -1
- package/dist/store/data-accessor.d.ts.map +1 -1
- package/dist/store/data-accessor.js +22 -5
- package/dist/store/data-accessor.js.map +1 -1
- package/dist/store/data-safety-central.d.ts.map +1 -1
- package/dist/store/data-safety-central.js +272 -232
- package/dist/store/data-safety-central.js.map +1 -1
- package/dist/store/db-helpers.d.ts +19 -3
- package/dist/store/db-helpers.d.ts.map +1 -1
- package/dist/store/db-helpers.js +42 -58
- package/dist/store/db-helpers.js.map +1 -1
- package/dist/store/dual-scope-db.d.ts +63 -4
- package/dist/store/dual-scope-db.d.ts.map +1 -1
- package/dist/store/dual-scope-db.js +166 -19
- package/dist/store/dual-scope-db.js.map +1 -1
- package/dist/store/exodus/archive.d.ts +21 -7
- package/dist/store/exodus/archive.d.ts.map +1 -1
- package/dist/store/exodus/archive.js +60 -18
- package/dist/store/exodus/archive.js.map +1 -1
- package/dist/store/exodus/migrate.d.ts.map +1 -1
- package/dist/store/exodus/migrate.js +27 -23
- package/dist/store/exodus/migrate.js.map +1 -1
- package/dist/store/exodus/on-open.d.ts +16 -27
- package/dist/store/exodus/on-open.d.ts.map +1 -1
- package/dist/store/exodus/on-open.js +121 -169
- package/dist/store/exodus/on-open.js.map +1 -1
- package/dist/store/exodus/recovery.d.ts +90 -0
- package/dist/store/exodus/recovery.d.ts.map +1 -0
- package/dist/store/exodus/recovery.js +434 -0
- package/dist/store/exodus/recovery.js.map +1 -0
- package/dist/store/exodus/types.d.ts +15 -0
- package/dist/store/exodus/types.d.ts.map +1 -1
- package/dist/store/memory-accessor.d.ts +52 -3
- package/dist/store/memory-accessor.d.ts.map +1 -1
- package/dist/store/memory-accessor.js +122 -7
- package/dist/store/memory-accessor.js.map +1 -1
- package/dist/store/nexus-sqlite.d.ts +24 -6
- package/dist/store/nexus-sqlite.d.ts.map +1 -1
- package/dist/store/nexus-sqlite.js +39 -20
- package/dist/store/nexus-sqlite.js.map +1 -1
- package/dist/store/ports/domain-binding.d.ts.map +1 -1
- package/dist/store/ports/domain-binding.js +27 -5
- package/dist/store/ports/domain-binding.js.map +1 -1
- package/dist/store/schema/background-jobs.d.ts +181 -2
- package/dist/store/schema/background-jobs.d.ts.map +1 -1
- package/dist/store/schema/background-jobs.js +27 -3
- package/dist/store/schema/background-jobs.js.map +1 -1
- package/dist/store/schema/cleo-global/skills.d.ts +2 -2
- package/dist/store/schema/cleo-project/lifecycle.d.ts +2 -2
- package/dist/store/schema/cleo-project/runtime.d.ts +1 -1
- package/dist/store/schema/cleo-project/tasks-core.d.ts +1 -1
- package/dist/store/schema/lifecycle.d.ts +2 -2
- package/dist/store/schema/skills-schema.d.ts +2 -2
- package/dist/store/schema/tasks.d.ts +1 -1
- package/dist/store/session-store.d.ts +3 -1
- package/dist/store/session-store.d.ts.map +1 -1
- package/dist/store/session-store.js +252 -207
- package/dist/store/session-store.js.map +1 -1
- package/dist/store/sqlite-data-accessor.d.ts +39 -4
- package/dist/store/sqlite-data-accessor.d.ts.map +1 -1
- package/dist/store/sqlite-data-accessor.js +689 -527
- package/dist/store/sqlite-data-accessor.js.map +1 -1
- package/dist/store/tasks-sqlite.d.ts +3 -1
- package/dist/store/tasks-sqlite.d.ts.map +1 -1
- package/dist/store/tasks-sqlite.js +309 -279
- package/dist/store/tasks-sqlite.js.map +1 -1
- package/dist/store/validation-schemas.d.ts +8 -8
- package/dist/store/writer-lease.d.ts +80 -8
- package/dist/store/writer-lease.d.ts.map +1 -1
- package/dist/store/writer-lease.js +503 -134
- package/dist/store/writer-lease.js.map +1 -1
- package/dist/system/audit.d.ts +2 -1
- package/dist/system/audit.d.ts.map +1 -1
- package/dist/system/audit.js +118 -71
- package/dist/system/audit.js.map +1 -1
- package/dist/system/backup.d.ts.map +1 -1
- package/dist/system/backup.js +5 -5
- package/dist/system/backup.js.map +1 -1
- package/dist/system/inject-generate.js +1 -1
- package/dist/task-work/index.d.ts +3 -0
- package/dist/task-work/index.d.ts.map +1 -1
- package/dist/task-work/index.js +34 -37
- package/dist/task-work/index.js.map +1 -1
- package/dist/tasks/ac-coverage-gate.d.ts.map +1 -1
- package/dist/tasks/ac-coverage-gate.js +4 -0
- package/dist/tasks/ac-coverage-gate.js.map +1 -1
- package/dist/tasks/ac-immutability.d.ts +7 -4
- package/dist/tasks/ac-immutability.d.ts.map +1 -1
- package/dist/tasks/ac-immutability.js +19 -17
- package/dist/tasks/ac-immutability.js.map +1 -1
- package/dist/tasks/ac-table.d.ts +5 -5
- package/dist/tasks/ac-table.d.ts.map +1 -1
- package/dist/tasks/ac-table.js +26 -12
- package/dist/tasks/ac-table.js.map +1 -1
- package/dist/tasks/acceptance-input.d.ts +52 -0
- package/dist/tasks/acceptance-input.d.ts.map +1 -0
- package/dist/tasks/acceptance-input.js +259 -0
- package/dist/tasks/acceptance-input.js.map +1 -0
- package/dist/tasks/add.d.ts +67 -29
- package/dist/tasks/add.d.ts.map +1 -1
- package/dist/tasks/add.js +255 -97
- package/dist/tasks/add.js.map +1 -1
- package/dist/tasks/complete.d.ts +16 -1
- package/dist/tasks/complete.d.ts.map +1 -1
- package/dist/tasks/complete.js +152 -55
- package/dist/tasks/complete.js.map +1 -1
- package/dist/tasks/decompose.d.ts +103 -0
- package/dist/tasks/decompose.d.ts.map +1 -0
- package/dist/tasks/decompose.js +224 -0
- package/dist/tasks/decompose.js.map +1 -0
- package/dist/tasks/delete.d.ts +5 -8
- package/dist/tasks/delete.d.ts.map +1 -1
- package/dist/tasks/delete.js +4 -3
- package/dist/tasks/delete.js.map +1 -1
- package/dist/tasks/dependency-check.d.ts +59 -0
- package/dist/tasks/dependency-check.d.ts.map +1 -1
- package/dist/tasks/dependency-check.js +73 -0
- package/dist/tasks/dependency-check.js.map +1 -1
- package/dist/tasks/deps-ready.d.ts +13 -1
- package/dist/tasks/deps-ready.d.ts.map +1 -1
- package/dist/tasks/deps-ready.js +16 -5
- package/dist/tasks/deps-ready.js.map +1 -1
- package/dist/tasks/engine-converters.d.ts +7 -0
- package/dist/tasks/engine-converters.d.ts.map +1 -1
- package/dist/tasks/engine-converters.js +8 -1
- package/dist/tasks/engine-converters.js.map +1 -1
- package/dist/tasks/evidence.d.ts +54 -26
- package/dist/tasks/evidence.d.ts.map +1 -1
- package/dist/tasks/evidence.js +292 -40
- package/dist/tasks/evidence.js.map +1 -1
- package/dist/tasks/find.d.ts +16 -12
- package/dist/tasks/find.d.ts.map +1 -1
- package/dist/tasks/find.js +107 -93
- package/dist/tasks/find.js.map +1 -1
- package/dist/tasks/gate-runner.d.ts +88 -14
- package/dist/tasks/gate-runner.d.ts.map +1 -1
- package/dist/tasks/gate-runner.js +596 -253
- package/dist/tasks/gate-runner.js.map +1 -1
- package/dist/tasks/hierarchy-policy.d.ts +8 -0
- package/dist/tasks/hierarchy-policy.d.ts.map +1 -1
- package/dist/tasks/hierarchy-policy.js +9 -2
- package/dist/tasks/hierarchy-policy.js.map +1 -1
- package/dist/tasks/hierarchy.d.ts +67 -1
- package/dist/tasks/hierarchy.d.ts.map +1 -1
- package/dist/tasks/hierarchy.js +81 -1
- package/dist/tasks/hierarchy.js.map +1 -1
- package/dist/tasks/index.d.ts +3 -0
- package/dist/tasks/index.d.ts.map +1 -1
- package/dist/tasks/index.js +3 -0
- package/dist/tasks/index.js.map +1 -1
- package/dist/tasks/infer-add-params.d.ts +1 -20
- package/dist/tasks/infer-add-params.d.ts.map +1 -1
- package/dist/tasks/infer-add-params.js +2 -203
- package/dist/tasks/infer-add-params.js.map +1 -1
- package/dist/tasks/list.d.ts +7 -2
- package/dist/tasks/list.d.ts.map +1 -1
- package/dist/tasks/list.js +24 -36
- package/dist/tasks/list.js.map +1 -1
- package/dist/tasks/ops.d.ts +6 -61
- package/dist/tasks/ops.d.ts.map +1 -1
- package/dist/tasks/ops.js +5 -53
- package/dist/tasks/ops.js.map +1 -1
- package/dist/tasks/plan.d.ts +15 -1
- package/dist/tasks/plan.d.ts.map +1 -1
- package/dist/tasks/plan.js +23 -25
- package/dist/tasks/plan.js.map +1 -1
- package/dist/tasks/population.d.ts +46 -0
- package/dist/tasks/population.d.ts.map +1 -0
- package/dist/tasks/population.js +88 -0
- package/dist/tasks/population.js.map +1 -0
- package/dist/tasks/reconcile-scope.d.ts +157 -0
- package/dist/tasks/reconcile-scope.d.ts.map +1 -0
- package/dist/tasks/reconcile-scope.js +242 -0
- package/dist/tasks/reconcile-scope.js.map +1 -0
- package/dist/tasks/req.d.ts +51 -12
- package/dist/tasks/req.d.ts.map +1 -1
- package/dist/tasks/req.js +125 -69
- package/dist/tasks/req.js.map +1 -1
- package/dist/tasks/session-scope.d.ts +6 -31
- package/dist/tasks/session-scope.d.ts.map +1 -1
- package/dist/tasks/session-scope.js +3 -17
- package/dist/tasks/session-scope.js.map +1 -1
- package/dist/tasks/severity-attestation.d.ts +39 -10
- package/dist/tasks/severity-attestation.d.ts.map +1 -1
- package/dist/tasks/severity-attestation.js +75 -36
- package/dist/tasks/severity-attestation.js.map +1 -1
- package/dist/tasks/task-next.d.ts +5 -0
- package/dist/tasks/task-next.d.ts.map +1 -1
- package/dist/tasks/task-next.js +10 -9
- package/dist/tasks/task-next.js.map +1 -1
- package/dist/tasks/task-reparent.d.ts.map +1 -1
- package/dist/tasks/task-reparent.js +3 -13
- package/dist/tasks/task-reparent.js.map +1 -1
- package/dist/tasks/update.d.ts +20 -43
- package/dist/tasks/update.d.ts.map +1 -1
- package/dist/tasks/update.js +64 -49
- package/dist/tasks/update.js.map +1 -1
- package/dist/validation/engine-ops.d.ts +17 -1
- package/dist/validation/engine-ops.d.ts.map +1 -1
- package/dist/validation/engine-ops.js +149 -11
- package/dist/validation/engine-ops.js.map +1 -1
- package/dist/workgraph/reparent-preflight.d.ts +1 -1
- package/dist/workgraph/reparent-preflight.d.ts.map +1 -1
- package/dist/workgraph/reparent-preflight.js +6 -1
- package/dist/workgraph/reparent-preflight.js.map +1 -1
- package/dist/workgraph/scaffold-apply.d.ts.map +1 -1
- package/dist/workgraph/scaffold-apply.js +81 -76
- package/dist/workgraph/scaffold-apply.js.map +1 -1
- package/dist/workgraph/structure-validation.d.ts +1 -1
- package/dist/workgraph/structure-validation.d.ts.map +1 -1
- package/dist/workgraph/structure-validation.js +6 -1
- package/dist/workgraph/structure-validation.js.map +1 -1
- package/migrations/drizzle-tasks/20260919180000_t12263-job-ownership-fences/migration.sql +34 -0
- package/migrations/drizzle-tasks/20260919184500_t12265-durable-pending-proposals/migration.sql +4 -0
- package/package.json +18 -13
- package/schemas/config.schema.json +2 -2
- package/templates/CLEO-INJECTION.md +103 -101
|
@@ -1,370 +1,1326 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Existing durable job store and executor facade.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* database at construction time is immediately transitioned to `orphaned`
|
|
8
|
-
* so that humans/agents can triage it — no silent retry.
|
|
4
|
+
* Opening a client never changes ownership. Each mutation uses a SQLite write
|
|
5
|
+
* transaction and an expiring owner/epoch fence. Legacy unowned rows remain
|
|
6
|
+
* inspectable; they require explicit recovery rather than guessed ownership.
|
|
9
7
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* Drizzle DB-access logic, so it belongs in `@cleocode/core` — the package that
|
|
14
|
-
* owns the consolidated SQLite schema, the single Drizzle instance, and the DB
|
|
15
|
-
* handle types. Hosting it here means there is exactly ONE `drizzle-orm`
|
|
16
|
-
* instance backing the query builders (no dual peer-hashed instance / `tsc -b`
|
|
17
|
-
* `SQL<unknown>` nominal mismatch). `@cleocode/runtime/gateway` imports the
|
|
18
|
-
* accessor from here and declares no `drizzle-orm` dependency of its own.
|
|
8
|
+
* The active table is background_jobs (epoch-ms timestamps). The separate
|
|
9
|
+
* tasks_background_jobs history is retained without rebinding or conversion.
|
|
10
|
+
* Job fences protect job metadata, not arbitrary mutations inside an executor.
|
|
19
11
|
*
|
|
20
|
-
* @task
|
|
21
|
-
* @task T11455
|
|
12
|
+
* @task T12263
|
|
22
13
|
*/
|
|
23
|
-
import { randomUUID } from 'node:crypto';
|
|
24
|
-
import { eq, inArray } from 'drizzle-orm';
|
|
25
|
-
import { backgroundJobs, } from './tasks-schema.js';
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
14
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
15
|
+
import { and, asc, eq, getTableColumns, gt, inArray, isNull, ne, or, sql } from 'drizzle-orm';
|
|
16
|
+
import { BACKGROUND_JOB_STATUSES, backgroundJobs, } from './tasks-schema.js';
|
|
17
|
+
/**
|
|
18
|
+
* Stable refusal of an unsafe ownership, retry, or transaction operation.
|
|
19
|
+
* @remarks A refusal preserves the current owner's work and reports no committed transition.
|
|
20
|
+
*/
|
|
21
|
+
export class BackgroundJobError extends Error {
|
|
22
|
+
/** Machine-readable refusal reason. */
|
|
23
|
+
code;
|
|
24
|
+
/**
|
|
25
|
+
* Construct an explicit job-store refusal.
|
|
26
|
+
* @param code - Stable failure category.
|
|
27
|
+
* @param message - Human-readable reason and required correction.
|
|
28
|
+
* @remarks This error never establishes successful job execution.
|
|
29
|
+
* @example
|
|
30
|
+
* ```ts
|
|
31
|
+
* throw new BackgroundJobError('E_JOB_LEASE_LOST', 'Reclaim expired work first');
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
constructor(code, message) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = 'BackgroundJobError';
|
|
37
|
+
this.code = code;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
30
40
|
function rowToJob(row) {
|
|
41
|
+
const terminal = row.status !== 'running' && row.status !== 'pending';
|
|
31
42
|
const job = {
|
|
32
43
|
id: row.id,
|
|
33
44
|
operation: row.operation,
|
|
34
45
|
status: row.status,
|
|
35
46
|
startedAt: new Date(row.startedAt).toISOString(),
|
|
47
|
+
projectId: row.projectId,
|
|
48
|
+
ownerId: row.ownerId,
|
|
49
|
+
leaseExpiresAt: row.leaseExpiresAt,
|
|
50
|
+
heartbeatAt: row.heartbeatAt,
|
|
51
|
+
fencingEpoch: row.fencingEpoch,
|
|
52
|
+
attempts: row.attempts,
|
|
53
|
+
cancellationRequestedAt: row.cancellationRequestedAt,
|
|
54
|
+
checkpointJson: row.checkpointJson,
|
|
55
|
+
checkpointAt: row.checkpointAt,
|
|
56
|
+
idempotencyKey: row.idempotencyKey,
|
|
57
|
+
proposalHash: row.proposalHash,
|
|
58
|
+
proposalJson: row.proposalJson,
|
|
59
|
+
ownership: terminal
|
|
60
|
+
? 'terminal'
|
|
61
|
+
: row.status === 'pending' && pendingProposalProblem(row) === null
|
|
62
|
+
? 'unclaimed'
|
|
63
|
+
: row.ownerId === null || row.leaseExpiresAt === null
|
|
64
|
+
? 'legacy-unknown'
|
|
65
|
+
: row.leaseExpiresAt <= Date.now()
|
|
66
|
+
? 'expired'
|
|
67
|
+
: 'current',
|
|
36
68
|
};
|
|
37
|
-
if (row.
|
|
69
|
+
if (row.status === 'pending')
|
|
70
|
+
job.diagnosticError = pendingProposalProblem(row) ?? undefined;
|
|
71
|
+
if (row.completedAt !== null)
|
|
38
72
|
job.completedAt = new Date(row.completedAt).toISOString();
|
|
39
|
-
|
|
40
|
-
if (row.result !== null && row.result !== undefined) {
|
|
73
|
+
if (row.result !== null) {
|
|
41
74
|
try {
|
|
42
75
|
job.result = JSON.parse(row.result);
|
|
43
76
|
}
|
|
44
77
|
catch {
|
|
45
78
|
job.result = row.result;
|
|
79
|
+
job.diagnosticError = 'Stored job result is not valid JSON';
|
|
46
80
|
}
|
|
47
81
|
}
|
|
48
|
-
if (row.error !== null
|
|
82
|
+
if (row.error !== null)
|
|
49
83
|
job.error = row.error;
|
|
50
|
-
|
|
51
|
-
if (row.progress !== null && row.progress !== undefined) {
|
|
84
|
+
if (row.progress !== null)
|
|
52
85
|
job.progress = row.progress;
|
|
53
|
-
|
|
54
|
-
if (row.claimedBy !== null && row.claimedBy !== undefined) {
|
|
86
|
+
if (row.claimedBy !== null)
|
|
55
87
|
job.claimedBy = row.claimedBy;
|
|
56
|
-
}
|
|
57
88
|
return job;
|
|
58
89
|
}
|
|
90
|
+
function pendingProposalProblem(row) {
|
|
91
|
+
if (row.ownerId !== null ||
|
|
92
|
+
row.leaseExpiresAt !== null ||
|
|
93
|
+
row.fencingEpoch !== 0 ||
|
|
94
|
+
row.attempts !== 0)
|
|
95
|
+
return 'Pending job contains inconsistent execution ownership; explicit recovery is required';
|
|
96
|
+
if (!row.projectId ||
|
|
97
|
+
!row.idempotencyKey ||
|
|
98
|
+
row.proposalJson === null ||
|
|
99
|
+
row.proposalHash === null)
|
|
100
|
+
return 'Pending job has no authentic scoped proposal payload; explicit recovery is required';
|
|
101
|
+
try {
|
|
102
|
+
requireJson(row.proposalJson);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return 'Pending job proposal is not valid JSON';
|
|
106
|
+
}
|
|
107
|
+
if (createHash('sha256').update(row.proposalJson).digest('hex') !== row.proposalHash)
|
|
108
|
+
return 'Pending job proposal bytes do not match their immutable hash';
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
function requireText(value, field) {
|
|
112
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
113
|
+
throw new BackgroundJobError('E_JOB_INPUT_INVALID', `${field} must be a nonempty string`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function requireJson(value) {
|
|
117
|
+
if (typeof value !== 'string')
|
|
118
|
+
throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Proposal/checkpoint must contain serialized JSON bytes');
|
|
119
|
+
try {
|
|
120
|
+
JSON.parse(value);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Proposal/checkpoint must contain valid JSON bytes');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function requireRunningLimit(maxRunning) {
|
|
127
|
+
if (maxRunning !== undefined && (!Number.isSafeInteger(maxRunning) || maxRunning < 1))
|
|
128
|
+
throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Running-job limit must be a positive safe integer');
|
|
129
|
+
}
|
|
59
130
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
131
|
+
* Revalidate captured job ownership inside the domain's existing write transaction.
|
|
132
|
+
* @param db - The same transaction handle that will perform the domain mutation.
|
|
133
|
+
* @param execution - Captured lifetime and optional immutable job fence.
|
|
134
|
+
* @throws BackgroundJobError when ownership, scope, proposal, cancellation or file identity changed.
|
|
135
|
+
* @remarks This read does not start a transaction or authorize arbitrary work. Callers
|
|
136
|
+
* must own the surrounding write transaction and independently validate their domain
|
|
137
|
+
* preconditions. Keeping this check inside that transaction fences concurrent claims
|
|
138
|
+
* and permits domain mutations and receipts to compose in one atomic unit.
|
|
139
|
+
* @example
|
|
140
|
+
* ```ts
|
|
141
|
+
* db.transaction(tx => { assertOperationWriteFence(tx, execution); writePreparedRows(tx); });
|
|
142
|
+
* ```
|
|
143
|
+
*/
|
|
144
|
+
export function assertOperationWriteFence(db, execution) {
|
|
145
|
+
assertOperationJobState(db, execution, 'running');
|
|
146
|
+
}
|
|
147
|
+
function assertOperationJobState(db, execution, status, resultJson, bookkeeping = false, outcome) {
|
|
148
|
+
if (!bookkeeping)
|
|
149
|
+
execution.assertActive();
|
|
150
|
+
const fence = execution.writeFence;
|
|
151
|
+
if (!fence)
|
|
152
|
+
return;
|
|
153
|
+
const row = db
|
|
154
|
+
.select({
|
|
155
|
+
proposalJson: sql `${backgroundJobs.proposalJson}`,
|
|
156
|
+
result: sql `${backgroundJobs.result}`,
|
|
157
|
+
error: sql `${backgroundJobs.error}`,
|
|
158
|
+
})
|
|
159
|
+
.from(sql `main.${backgroundJobs}`)
|
|
160
|
+
.where(and(eq(backgroundJobs.id, fence.lease.jobId), eq(backgroundJobs.status, status), eq(backgroundJobs.ownerId, fence.lease.ownerId), eq(backgroundJobs.fencingEpoch, fence.lease.epoch), gt(backgroundJobs.leaseExpiresAt, Date.now()), bookkeeping ? undefined : isNull(backgroundJobs.cancellationRequestedAt), eq(backgroundJobs.projectId, execution.identity.projectId), eq(backgroundJobs.operation, execution.identity.operation), eq(backgroundJobs.idempotencyKey, execution.identity.idempotencyKey), eq(backgroundJobs.proposalHash, fence.proposalHash), sql `EXISTS (SELECT 1 FROM pragma_database_list WHERE name = 'main' AND file = ${fence.dbPath})`))
|
|
161
|
+
.get();
|
|
162
|
+
if (!row?.proposalJson ||
|
|
163
|
+
createHash('sha256').update(row.proposalJson).digest('hex') !== fence.proposalHash ||
|
|
164
|
+
(resultJson !== undefined && row.result !== resultJson) ||
|
|
165
|
+
(outcome !== undefined && row.error !== outcome.message)) {
|
|
166
|
+
throw new BackgroundJobError('E_JOB_LEASE_LOST', 'Domain write refused: job authority or proposal no longer matches the captured attempt');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// Coordination only: no connection lifetime or domain cache is owned here.
|
|
170
|
+
const boundedTransactionHandles = new WeakSet();
|
|
171
|
+
/**
|
|
172
|
+
* Drizzle-backed ownership of the established active job table.
|
|
173
|
+
* @remarks Sync methods refuse an already-owned native transaction before writing.
|
|
174
|
+
* No method infers a missing legacy lease to be expired. Rows are never age-pruned.
|
|
175
|
+
* @example
|
|
176
|
+
* ```ts
|
|
177
|
+
* const jobs = new DurableJobStore(db, { projectId: 'project-uuid' });
|
|
178
|
+
* const lease = jobs.insert('job-uuid', 'doctor.knowledge', Date.now());
|
|
179
|
+
* if (lease) jobs.complete(lease.jobId, { verified: true }, Date.now());
|
|
180
|
+
* ```
|
|
67
181
|
*/
|
|
68
182
|
export class DurableJobStore {
|
|
69
183
|
#db;
|
|
70
|
-
|
|
184
|
+
#ownerId = randomUUID();
|
|
185
|
+
#options;
|
|
186
|
+
#grants = new Map();
|
|
187
|
+
#committedCleanupFailures = new Map();
|
|
188
|
+
/** Positive lease duration used for claims and heartbeats. */
|
|
189
|
+
leaseMs;
|
|
190
|
+
/**
|
|
191
|
+
* Open a client without changing persisted work.
|
|
192
|
+
* @param db - Existing canonical database handle; no new connection is opened.
|
|
193
|
+
* @param options - Explicit scope, actor, and lease duration.
|
|
194
|
+
* @remarks Unscoped legacy callers retain visible null project identity.
|
|
195
|
+
* @example
|
|
196
|
+
* ```ts
|
|
197
|
+
* const store = new DurableJobStore(db, { projectId: 'project-uuid', leaseMs: 30000 });
|
|
198
|
+
* ```
|
|
199
|
+
*/
|
|
200
|
+
constructor(db, options = {}) {
|
|
71
201
|
this.#db = db;
|
|
72
|
-
this.#
|
|
202
|
+
this.#options = Object.freeze({ ...options });
|
|
203
|
+
this.leaseMs = options.leaseMs ?? 30_000;
|
|
204
|
+
if (!Number.isSafeInteger(this.leaseMs) || this.leaseMs <= 0) {
|
|
205
|
+
throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'leaseMs must be a positive safe integer');
|
|
206
|
+
}
|
|
207
|
+
if (options.projectId !== undefined)
|
|
208
|
+
requireText(options.projectId, 'projectId');
|
|
209
|
+
}
|
|
210
|
+
#scope() {
|
|
211
|
+
return this.#options.projectId === undefined
|
|
212
|
+
? undefined
|
|
213
|
+
: eq(backgroundJobs.projectId, this.#options.projectId);
|
|
214
|
+
}
|
|
215
|
+
#row(id) {
|
|
216
|
+
const columns = getTableColumns(backgroundJobs);
|
|
217
|
+
return this.#db
|
|
218
|
+
.select({
|
|
219
|
+
id: sql `${columns.id}`.mapWith(columns.id),
|
|
220
|
+
operation: sql `${columns.operation}`.mapWith(columns.operation),
|
|
221
|
+
status: sql `${columns.status}`.mapWith(columns.status),
|
|
222
|
+
startedAt: sql `${columns.startedAt}`.mapWith(columns.startedAt),
|
|
223
|
+
completedAt: sql `${columns.completedAt}`.mapWith(columns.completedAt),
|
|
224
|
+
result: sql `${columns.result}`.mapWith(columns.result),
|
|
225
|
+
error: sql `${columns.error}`.mapWith(columns.error),
|
|
226
|
+
progress: sql `${columns.progress}`.mapWith(columns.progress),
|
|
227
|
+
heartbeatAt: sql `${columns.heartbeatAt}`.mapWith(columns.heartbeatAt),
|
|
228
|
+
claimedBy: sql `${columns.claimedBy}`.mapWith(columns.claimedBy),
|
|
229
|
+
projectId: sql `${columns.projectId}`.mapWith(columns.projectId),
|
|
230
|
+
ownerId: sql `${columns.ownerId}`.mapWith(columns.ownerId),
|
|
231
|
+
leaseExpiresAt: sql `${columns.leaseExpiresAt}`.mapWith(columns.leaseExpiresAt),
|
|
232
|
+
fencingEpoch: sql `${columns.fencingEpoch}`.mapWith(columns.fencingEpoch),
|
|
233
|
+
attempts: sql `${columns.attempts}`.mapWith(columns.attempts),
|
|
234
|
+
cancellationRequestedAt: sql `${columns.cancellationRequestedAt}`.mapWith(columns.cancellationRequestedAt),
|
|
235
|
+
checkpointJson: sql `${columns.checkpointJson}`.mapWith(columns.checkpointJson),
|
|
236
|
+
checkpointAt: sql `${columns.checkpointAt}`.mapWith(columns.checkpointAt),
|
|
237
|
+
idempotencyKey: sql `${columns.idempotencyKey}`.mapWith(columns.idempotencyKey),
|
|
238
|
+
proposalHash: sql `${columns.proposalHash}`.mapWith(columns.proposalHash),
|
|
239
|
+
proposalJson: sql `${columns.proposalJson}`.mapWith(columns.proposalJson),
|
|
240
|
+
})
|
|
241
|
+
.from(sql `main.${backgroundJobs}`)
|
|
242
|
+
.where(and(eq(backgroundJobs.id, id), this.#scope()))
|
|
243
|
+
.get();
|
|
244
|
+
}
|
|
245
|
+
#write(operation, deadlineAt, onCommittedCleanupFailure, assertCommitAllowed, readOnly = false) {
|
|
246
|
+
let outcome;
|
|
247
|
+
let failure;
|
|
248
|
+
let handle;
|
|
249
|
+
let previousTimeout;
|
|
250
|
+
let installedTimeout;
|
|
251
|
+
if (deadlineAt !== undefined) {
|
|
252
|
+
if (Date.now() >= deadlineAt)
|
|
253
|
+
throw new BackgroundJobError('E_JOB_DEADLINE_EXCEEDED', 'No budget remains for job bookkeeping');
|
|
254
|
+
if (!('$client' in this.#db) ||
|
|
255
|
+
typeof this.#db.$client !== 'object' ||
|
|
256
|
+
this.#db.$client === null)
|
|
257
|
+
throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Bounded writes require the actual native handle identity');
|
|
258
|
+
handle = this.#db.$client;
|
|
259
|
+
if (boundedTransactionHandles.has(handle))
|
|
260
|
+
throw new BackgroundJobError('E_JOB_TRANSACTION_OWNED', 'Another caller owns this bounded native transaction');
|
|
261
|
+
boundedTransactionHandles.add(handle);
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
if (deadlineAt !== undefined) {
|
|
265
|
+
previousTimeout = this.#db.get(sql `PRAGMA busy_timeout`).timeout;
|
|
266
|
+
installedTimeout = Math.max(0, Math.min(previousTimeout, Math.floor(deadlineAt - Date.now())));
|
|
267
|
+
this.#db.run(sql.raw(`PRAGMA busy_timeout=${installedTimeout}`));
|
|
268
|
+
if (Date.now() >= deadlineAt)
|
|
269
|
+
throw new BackgroundJobError('E_JOB_DEADLINE_EXCEEDED', 'Deadline elapsed before lock acquisition');
|
|
270
|
+
}
|
|
271
|
+
// BEGIN outside rollback scope: failure must not roll back another caller.
|
|
272
|
+
try {
|
|
273
|
+
this.#db.run(readOnly ? sql `BEGIN DEFERRED` : sql `BEGIN IMMEDIATE`);
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
if (error instanceof Error && /within a transaction/i.test(String(error.cause ?? error)))
|
|
277
|
+
throw new BackgroundJobError('E_JOB_TRANSACTION_OWNED', 'Job write refused: another caller owns the native transaction');
|
|
278
|
+
throw error;
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
if (deadlineAt !== undefined && Date.now() >= deadlineAt)
|
|
282
|
+
throw new BackgroundJobError('E_JOB_DEADLINE_EXCEEDED', 'Deadline elapsed during lock acquisition');
|
|
283
|
+
const value = operation();
|
|
284
|
+
if (deadlineAt !== undefined && Date.now() >= deadlineAt)
|
|
285
|
+
throw new BackgroundJobError('E_JOB_DEADLINE_EXCEEDED', 'Deadline elapsed before commit');
|
|
286
|
+
if (installedTimeout !== undefined &&
|
|
287
|
+
this.#db.get(sql `PRAGMA busy_timeout`).timeout !== installedTimeout)
|
|
288
|
+
throw new BackgroundJobError('E_JOB_LOCK_POLICY_CONFLICT', 'Another caller changed the native lock policy');
|
|
289
|
+
assertCommitAllowed?.();
|
|
290
|
+
this.#db.run(sql `COMMIT`);
|
|
291
|
+
outcome = { value };
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
this.#db.run(sql `ROLLBACK`);
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
failure = error instanceof Error ? error : new Error(String(error));
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
try {
|
|
303
|
+
// Do not overwrite a later caller's independently changed timeout value.
|
|
304
|
+
if (previousTimeout !== undefined &&
|
|
305
|
+
installedTimeout !== undefined &&
|
|
306
|
+
this.#db.get(sql `PRAGMA busy_timeout`).timeout === installedTimeout)
|
|
307
|
+
this.#db.run(sql.raw(`PRAGMA busy_timeout=${previousTimeout}`));
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
if (outcome && onCommittedCleanupFailure)
|
|
311
|
+
onCommittedCleanupFailure(String(error));
|
|
312
|
+
else {
|
|
313
|
+
const cleanupFailure = error instanceof Error ? error : new Error(String(error));
|
|
314
|
+
failure = failure
|
|
315
|
+
? new AggregateError([failure, cleanupFailure], 'Transaction and timeout cleanup failed')
|
|
316
|
+
: cleanupFailure;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
finally {
|
|
320
|
+
if (handle)
|
|
321
|
+
boundedTransactionHandles.delete(handle);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (failure)
|
|
325
|
+
throw failure;
|
|
326
|
+
if (!outcome)
|
|
327
|
+
throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Transaction produced no outcome');
|
|
328
|
+
return outcome.value;
|
|
329
|
+
}
|
|
330
|
+
#owned(id) {
|
|
331
|
+
const grant = this.#grants.get(id);
|
|
332
|
+
const row = this.#row(id);
|
|
333
|
+
if (!grant ||
|
|
334
|
+
!row ||
|
|
335
|
+
row.status !== 'running' ||
|
|
336
|
+
row.ownerId !== this.#ownerId ||
|
|
337
|
+
row.ownerId !== grant.ownerId ||
|
|
338
|
+
row.fencingEpoch !== grant.epoch ||
|
|
339
|
+
row.leaseExpiresAt === null ||
|
|
340
|
+
row.leaseExpiresAt <= Date.now()) {
|
|
341
|
+
throw new BackgroundJobError('E_JOB_LEASE_LOST', `Job ${id} is not owned by this unexpired attempt`);
|
|
342
|
+
}
|
|
343
|
+
return row;
|
|
344
|
+
}
|
|
345
|
+
#grant(row) {
|
|
346
|
+
if (row.ownerId === null || row.leaseExpiresAt === null) {
|
|
347
|
+
throw new BackgroundJobError('E_JOB_LEASE_LOST', 'Cannot issue a grant for unowned legacy work');
|
|
348
|
+
}
|
|
349
|
+
return Object.freeze({
|
|
350
|
+
jobId: row.id,
|
|
351
|
+
ownerId: row.ownerId,
|
|
352
|
+
epoch: row.fencingEpoch,
|
|
353
|
+
expiresAt: row.leaseExpiresAt,
|
|
354
|
+
});
|
|
73
355
|
}
|
|
74
356
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
357
|
+
* Create a leased job, or return the existing immutable scoped submission.
|
|
358
|
+
* @param id - Proposed UUID for a new job.
|
|
359
|
+
* @param operation - Supported operation identifier.
|
|
360
|
+
* @param now - Event timestamp in epoch milliseconds.
|
|
361
|
+
* @param submission - Optional scoped immutable retry contract.
|
|
362
|
+
* @param maxRunning - Optional running-job limit checked atomically with creation.
|
|
363
|
+
* @returns Grant for new work, or null when an identical submission already exists.
|
|
364
|
+
* @remarks A repeated submission never acquires ownership or re-executes by itself.
|
|
365
|
+
* @example
|
|
366
|
+
* ```ts
|
|
367
|
+
* const grant = store.insert(id, 'doctor.knowledge', Date.now(), submission);
|
|
368
|
+
* ```
|
|
79
369
|
*/
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
error: 'process-exited-before-completion',
|
|
88
|
-
})
|
|
89
|
-
.where(eq(backgroundJobs.status, 'running'))
|
|
90
|
-
.run();
|
|
370
|
+
insert(id, operation, now, submission, maxRunning) {
|
|
371
|
+
const { row, created } = this.#submit(id, operation, now, submission, 'running', maxRunning);
|
|
372
|
+
if (!created)
|
|
373
|
+
return null;
|
|
374
|
+
const grant = this.#grant(row);
|
|
375
|
+
this.#grants.set(id, grant);
|
|
376
|
+
return grant;
|
|
91
377
|
}
|
|
92
378
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* @param
|
|
96
|
-
* @
|
|
97
|
-
* @
|
|
379
|
+
* Find an existing immutable submission without claiming it.
|
|
380
|
+
* @param operation - Exact supported operation.
|
|
381
|
+
* @param submission - Project and retry identity.
|
|
382
|
+
* @returns Persisted job, or undefined.
|
|
383
|
+
* @remarks Input conflict validation is performed by insert before coalescing.
|
|
384
|
+
* @example
|
|
385
|
+
* ```ts
|
|
386
|
+
* const prior = store.findSubmission('doctor.knowledge', submission);
|
|
387
|
+
* ```
|
|
98
388
|
*/
|
|
99
|
-
|
|
100
|
-
const row =
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
389
|
+
findSubmission(operation, submission) {
|
|
390
|
+
const row = this.#db
|
|
391
|
+
.select()
|
|
392
|
+
.from(backgroundJobs)
|
|
393
|
+
.where(and(this.#scope(), eq(backgroundJobs.projectId, submission.projectId), eq(backgroundJobs.operation, operation), eq(backgroundJobs.idempotencyKey, submission.idempotencyKey)))
|
|
394
|
+
.get();
|
|
395
|
+
return row ? rowToJob(row) : undefined;
|
|
396
|
+
}
|
|
397
|
+
/** Check the project running limit while the caller owns the write transaction. */
|
|
398
|
+
#requireCapacity(maxRunning, excludeId) {
|
|
399
|
+
requireRunningLimit(maxRunning);
|
|
400
|
+
if (maxRunning === undefined)
|
|
401
|
+
return;
|
|
402
|
+
const count = this.#db
|
|
403
|
+
.select({ count: sql `count(*)` })
|
|
404
|
+
.from(backgroundJobs)
|
|
405
|
+
.where(and(this.#scope(), eq(backgroundJobs.status, 'running'), excludeId ? ne(backgroundJobs.id, excludeId) : undefined))
|
|
406
|
+
.get()?.count ?? 0;
|
|
407
|
+
if (count >= maxRunning)
|
|
408
|
+
throw new BackgroundJobError('E_JOB_INPUT_INVALID', `Maximum concurrent jobs reached (${maxRunning})`);
|
|
409
|
+
}
|
|
410
|
+
/** Check an optional invocation budget and immutable submission scope without granting authority. */
|
|
411
|
+
#assertInvocation(execution, operation, projectId, idempotencyKey) {
|
|
412
|
+
if (!execution)
|
|
413
|
+
return;
|
|
414
|
+
execution.assertActive();
|
|
415
|
+
if (execution.identity.projectId !== projectId ||
|
|
416
|
+
execution.identity.projectId !== this.#options.projectId ||
|
|
417
|
+
execution.identity.operation !== operation ||
|
|
418
|
+
execution.identity.idempotencyKey !== idempotencyKey)
|
|
419
|
+
throw new BackgroundJobError('E_JOB_SCOPE_MISMATCH', 'Invocation differs from immutable job scope');
|
|
420
|
+
}
|
|
421
|
+
/** Atomically coalesce or insert complete immutable proposal state. */
|
|
422
|
+
#submit(id, operation, now, submission, status, maxRunning, execution) {
|
|
423
|
+
this.#assertInvocation(execution, operation, submission?.projectId ?? null, submission?.idempotencyKey ?? null);
|
|
424
|
+
requireRunningLimit(maxRunning);
|
|
425
|
+
requireText(id, 'id');
|
|
426
|
+
if (!Number.isSafeInteger(now))
|
|
427
|
+
throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Job event timestamp must be a safe integer');
|
|
428
|
+
requireText(operation, 'operation');
|
|
429
|
+
if (submission) {
|
|
430
|
+
requireText(submission.projectId, 'projectId');
|
|
431
|
+
requireText(submission.idempotencyKey, 'idempotencyKey');
|
|
432
|
+
requireJson(submission.proposalJson);
|
|
433
|
+
if (this.#options.projectId !== undefined &&
|
|
434
|
+
this.#options.projectId !== submission.projectId) {
|
|
435
|
+
throw new BackgroundJobError('E_JOB_SCOPE_MISMATCH', 'Submission project differs from the store scope');
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const proposalHash = submission
|
|
439
|
+
? createHash('sha256').update(submission.proposalJson).digest('hex')
|
|
440
|
+
: null;
|
|
441
|
+
let committedId = id;
|
|
442
|
+
return this.#write(() => {
|
|
443
|
+
this.#assertInvocation(execution, operation, submission?.projectId ?? null, submission?.idempotencyKey ?? null);
|
|
444
|
+
if (submission) {
|
|
445
|
+
const prior = this.#db
|
|
446
|
+
.select()
|
|
447
|
+
.from(backgroundJobs)
|
|
448
|
+
.where(and(eq(backgroundJobs.projectId, submission.projectId), eq(backgroundJobs.operation, operation), eq(backgroundJobs.idempotencyKey, submission.idempotencyKey)))
|
|
449
|
+
.get();
|
|
450
|
+
if (prior) {
|
|
451
|
+
if (prior.proposalHash !== proposalHash ||
|
|
452
|
+
(prior.proposalJson !== null && prior.proposalJson !== submission.proposalJson))
|
|
453
|
+
throw new BackgroundJobError('E_JOB_IDEMPOTENCY_CONFLICT', 'Retry key was already used with different immutable proposal bytes');
|
|
454
|
+
committedId = prior.id;
|
|
455
|
+
return { row: prior, created: false };
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (status === 'running')
|
|
459
|
+
this.#requireCapacity(maxRunning);
|
|
460
|
+
const row = this.#db
|
|
461
|
+
.insert(backgroundJobs)
|
|
462
|
+
.values({
|
|
463
|
+
id,
|
|
464
|
+
operation,
|
|
465
|
+
status,
|
|
466
|
+
startedAt: now,
|
|
467
|
+
heartbeatAt: now,
|
|
468
|
+
claimedBy: this.#options.actor ?? null,
|
|
469
|
+
projectId: submission?.projectId ?? this.#options.projectId ?? null,
|
|
470
|
+
ownerId: status === 'running' ? this.#ownerId : null,
|
|
471
|
+
leaseExpiresAt: status === 'running' ? Date.now() + this.leaseMs : null,
|
|
472
|
+
fencingEpoch: status === 'running' ? 1 : 0,
|
|
473
|
+
attempts: status === 'running' ? 1 : 0,
|
|
474
|
+
proposalHash,
|
|
475
|
+
proposalJson: submission?.proposalJson ?? null,
|
|
476
|
+
idempotencyKey: submission?.idempotencyKey ?? null,
|
|
477
|
+
})
|
|
478
|
+
.returning()
|
|
479
|
+
.get();
|
|
480
|
+
return { row, created: true };
|
|
481
|
+
}, execution?.deadlineAt, (message) => this.#committedCleanupFailures.set(committedId, message), () => execution?.assertActive());
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Persist authentic pending work without claiming or starting an executor.
|
|
485
|
+
* @param id - Proposed job identity; matching retries retain the original identity.
|
|
486
|
+
* @param operation - Exact operation whose supported inputs the domain service validates.
|
|
487
|
+
* @param now - Submission timestamp in epoch milliseconds.
|
|
488
|
+
* @param submission - Required project-scoped immutable serialized proposal.
|
|
489
|
+
* @param execution - Optional original invocation budget; omitted legacy callers retain existing behavior.
|
|
490
|
+
* @returns Persisted pending job, or unchanged prior matching submission.
|
|
491
|
+
* @remarks Input and its hash commit in one native transaction. Existing history
|
|
492
|
+
* without payload is retained, never reconstructed from a hash or overwritten.
|
|
493
|
+
* @example
|
|
494
|
+
* ```ts
|
|
495
|
+
* const pending = store.defer(id, 'docs.projection', Date.now(), submission);
|
|
496
|
+
* ```
|
|
497
|
+
*/
|
|
498
|
+
defer(id, operation, now, submission, execution) {
|
|
499
|
+
if (!submission)
|
|
500
|
+
throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Pending work requires a scoped proposal');
|
|
501
|
+
return rowToJob(this.#submit(id, operation, now, submission, 'pending', undefined, execution).row);
|
|
108
502
|
}
|
|
109
503
|
/**
|
|
110
|
-
*
|
|
504
|
+
* Read a job with scope and ownership disclosed.
|
|
505
|
+
* @param id - Job identity.
|
|
506
|
+
* @returns Job or undefined.
|
|
507
|
+
* @remarks A missing or expired lease is never represented as current ownership.
|
|
508
|
+
* @example
|
|
509
|
+
* ```ts
|
|
510
|
+
* const job = store.get(id);
|
|
511
|
+
* ```
|
|
111
512
|
*/
|
|
112
513
|
get(id) {
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
514
|
+
const row = this.#row(id);
|
|
515
|
+
if (!row)
|
|
516
|
+
return undefined;
|
|
517
|
+
const job = rowToJob(row);
|
|
518
|
+
const cleanupError = this.#committedCleanupFailures.get(id);
|
|
519
|
+
return cleanupError ? { ...job, diagnosticError: cleanupError } : job;
|
|
116
520
|
}
|
|
117
521
|
/**
|
|
118
|
-
*
|
|
522
|
+
* List visible jobs.
|
|
523
|
+
* @param status - Optional lifecycle filter.
|
|
524
|
+
* @returns Matching scoped jobs.
|
|
525
|
+
* @remarks Project-scoped clients exclude other projects and unscoped legacy rows.
|
|
526
|
+
* @example
|
|
527
|
+
* ```ts
|
|
528
|
+
* const jobs = store.list('running');
|
|
529
|
+
* ```
|
|
119
530
|
*/
|
|
120
531
|
list(status) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
532
|
+
if (status !== undefined &&
|
|
533
|
+
!BACKGROUND_JOB_STATUSES.some((candidate) => candidate === status)) {
|
|
534
|
+
throw new BackgroundJobError('E_JOB_INPUT_INVALID', `Unsupported job status: ${status}`);
|
|
535
|
+
}
|
|
536
|
+
return this.#db
|
|
537
|
+
.select()
|
|
538
|
+
.from(backgroundJobs)
|
|
539
|
+
.where(and(this.#scope(), status ? eq(backgroundJobs.status, status) : undefined))
|
|
540
|
+
.all()
|
|
541
|
+
.map(rowToJob);
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Read a bounded candidate page without a runtime manager or ownership mutation.
|
|
545
|
+
* @param query - Exact operation, optional status, payload cap and bound cursor.
|
|
546
|
+
* @param execution - Original captured caller and deadline; never renewed by this read.
|
|
547
|
+
* @returns Candidate rows and a last-scanned cursor; the domain must validate principals.
|
|
548
|
+
* @throws BackgroundJobError for ambiguous scope, mismatched cursors, oversized rows or borrowed transactions.
|
|
549
|
+
* @remarks SQL limits candidates before payload materialization or JSON parsing. Mutable
|
|
550
|
+
* claimedBy is not used as principal authority. Existing transaction cleanup bounds lock waits;
|
|
551
|
+
* synchronous SQLite work is cooperative and cannot be preempted by a JavaScript timer.
|
|
552
|
+
* @example
|
|
553
|
+
* ```ts
|
|
554
|
+
* const page = store.listPage({ operation: 'doctor.knowledge', limit: 20 }, execution);
|
|
555
|
+
* ```
|
|
556
|
+
*/
|
|
557
|
+
listPage(query, execution) {
|
|
558
|
+
execution.assertActive();
|
|
559
|
+
const projectId = this.#options.projectId;
|
|
560
|
+
const actor = this.#options.actor;
|
|
561
|
+
if (!projectId ||
|
|
562
|
+
!actor?.trim() ||
|
|
563
|
+
execution.identity.projectId !== projectId ||
|
|
564
|
+
execution.identity.actor !== actor ||
|
|
565
|
+
execution.identity.operation !== query.operation)
|
|
566
|
+
throw new BackgroundJobError('E_JOB_SCOPE_MISMATCH', 'Candidate reads require an exact project, explicit caller and operation');
|
|
567
|
+
requireText(query.operation, 'operation');
|
|
568
|
+
const limit = query.limit ?? 25;
|
|
569
|
+
const maxPayloadBytes = query.maxPayloadBytes ?? 262144;
|
|
570
|
+
if (!Number.isSafeInteger(limit) ||
|
|
571
|
+
limit < 1 ||
|
|
572
|
+
limit > 100 ||
|
|
573
|
+
!Number.isSafeInteger(maxPayloadBytes) ||
|
|
574
|
+
maxPayloadBytes < 1 ||
|
|
575
|
+
maxPayloadBytes > 1048576 ||
|
|
576
|
+
(query.status !== undefined &&
|
|
577
|
+
!BACKGROUND_JOB_STATUSES.some((status) => status === query.status)))
|
|
578
|
+
throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Invalid bounded candidate query');
|
|
579
|
+
const scope = {
|
|
580
|
+
version: 1,
|
|
581
|
+
projectId,
|
|
582
|
+
projectRoot: execution.identity.projectRoot,
|
|
583
|
+
actor,
|
|
584
|
+
operation: query.operation,
|
|
585
|
+
status: query.status ?? null,
|
|
586
|
+
limit,
|
|
587
|
+
maxPayloadBytes,
|
|
588
|
+
};
|
|
589
|
+
const after = query.after;
|
|
590
|
+
if (after !== undefined &&
|
|
591
|
+
(!after ||
|
|
592
|
+
typeof after !== 'object' ||
|
|
593
|
+
!Number.isSafeInteger(after.startedAt) ||
|
|
594
|
+
typeof after.id !== 'string' ||
|
|
595
|
+
!after.id.trim() ||
|
|
596
|
+
after.version !== scope.version ||
|
|
597
|
+
after.projectId !== scope.projectId ||
|
|
598
|
+
after.projectRoot !== scope.projectRoot ||
|
|
599
|
+
after.actor !== scope.actor ||
|
|
600
|
+
after.operation !== scope.operation ||
|
|
601
|
+
after.status !== scope.status ||
|
|
602
|
+
after.limit !== scope.limit ||
|
|
603
|
+
after.maxPayloadBytes !== scope.maxPayloadBytes))
|
|
604
|
+
throw new BackgroundJobError('E_JOB_SCOPE_MISMATCH', 'Candidate cursor belongs to a different caller or query');
|
|
605
|
+
return this.#write(() => {
|
|
606
|
+
execution.assertActive();
|
|
607
|
+
const columns = getTableColumns(backgroundJobs);
|
|
608
|
+
const payloadBytes = sql `${sql.join(Object.values(columns)
|
|
609
|
+
.filter((column) => column.dataType === 'string' || column.dataType === 'string enum')
|
|
610
|
+
.map((column) => sql `length(CAST(coalesce(${column}, '') AS BLOB))`), sql ` + `)}`;
|
|
611
|
+
// The first query never returns opaque payloads. An over-limit ID is withheld too.
|
|
612
|
+
const selected = this.#db
|
|
613
|
+
.select({
|
|
614
|
+
id: sql `CASE WHEN ${payloadBytes} <= ${maxPayloadBytes} THEN ${columns.id} ELSE '' END`,
|
|
615
|
+
startedAt: columns.startedAt,
|
|
616
|
+
payloadBytes,
|
|
617
|
+
})
|
|
124
618
|
.from(backgroundJobs)
|
|
125
|
-
.where(eq(
|
|
126
|
-
.
|
|
127
|
-
|
|
128
|
-
|
|
619
|
+
.where(and(this.#scope(), eq(columns.operation, query.operation), query.status ? eq(columns.status, query.status) : undefined, after
|
|
620
|
+
? or(gt(columns.startedAt, after.startedAt), and(eq(columns.startedAt, after.startedAt), gt(columns.id, after.id)))
|
|
621
|
+
: undefined))
|
|
622
|
+
.orderBy(asc(columns.startedAt), asc(columns.id))
|
|
623
|
+
.limit(limit + 1)
|
|
624
|
+
.all();
|
|
625
|
+
execution.assertActive();
|
|
626
|
+
const scanned = selected.slice(0, limit);
|
|
627
|
+
if (scanned.some((row) => row.payloadBytes > maxPayloadBytes))
|
|
628
|
+
throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Candidate payload exceeds the explicit byte cap; no complete inventory is available');
|
|
629
|
+
execution.consume({
|
|
630
|
+
items: scanned.length,
|
|
631
|
+
bytes: scanned.reduce((total, row) => total + row.payloadBytes, 0),
|
|
632
|
+
});
|
|
633
|
+
const rows = scanned.length
|
|
634
|
+
? this.#db
|
|
635
|
+
.select()
|
|
636
|
+
.from(backgroundJobs)
|
|
637
|
+
.where(and(this.#scope(), eq(columns.operation, query.operation), inArray(columns.id, scanned.map((row) => row.id))))
|
|
638
|
+
.orderBy(asc(columns.startedAt), asc(columns.id))
|
|
639
|
+
.all()
|
|
640
|
+
: [];
|
|
641
|
+
const candidates = rows.map((row) => {
|
|
642
|
+
execution.assertActive();
|
|
643
|
+
return rowToJob(row);
|
|
644
|
+
});
|
|
645
|
+
const last = scanned.at(-1);
|
|
646
|
+
return {
|
|
647
|
+
candidates,
|
|
648
|
+
scope,
|
|
649
|
+
scannedCount: scanned.length,
|
|
650
|
+
hasMoreCandidates: selected.length > limit,
|
|
651
|
+
nextCursor: selected.length > limit && last
|
|
652
|
+
? { ...scope, startedAt: last.startedAt, id: last.id }
|
|
653
|
+
: null,
|
|
654
|
+
matchingTotal: null,
|
|
655
|
+
observation: 'per-page-snapshot',
|
|
656
|
+
principalValidation: 'domain-required',
|
|
657
|
+
};
|
|
658
|
+
}, execution.deadlineAt, undefined, () => execution.assertActive(), true);
|
|
129
659
|
}
|
|
130
660
|
/**
|
|
131
|
-
*
|
|
661
|
+
* Claim authentic unstarted work or reclaim an explicitly expired lease.
|
|
662
|
+
* @param id - Existing job identity.
|
|
663
|
+
* @param now - Claim timestamp.
|
|
664
|
+
* @param maxRunning - Optional capacity limit enforced atomically with the claim.
|
|
665
|
+
* @param execution - Optional original invocation budget; supplying it adds scoped cancellation and deadline checks.
|
|
666
|
+
* @returns New attempt's lease.
|
|
667
|
+
* @remarks Preserves checkpoint and cancellation request. Pending work must have
|
|
668
|
+
* authentic hash-matching inputs and no prior execution; ambiguous legacy rows refuse.
|
|
669
|
+
* @example
|
|
670
|
+
* ```ts
|
|
671
|
+
* const lease = store.claim(expiredJob.id, Date.now());
|
|
672
|
+
* ```
|
|
132
673
|
*/
|
|
133
|
-
|
|
134
|
-
|
|
674
|
+
claim(id, now, maxRunning, execution) {
|
|
675
|
+
execution?.assertActive();
|
|
676
|
+
const row = this.#write(() => {
|
|
677
|
+
const prior = this.#row(id);
|
|
678
|
+
const pending = prior?.status === 'pending';
|
|
679
|
+
const problem = pending && prior ? pendingProposalProblem(prior) : null;
|
|
680
|
+
if (!prior ||
|
|
681
|
+
(pending
|
|
682
|
+
? problem !== null
|
|
683
|
+
: prior.status !== 'running' ||
|
|
684
|
+
prior.ownerId === null ||
|
|
685
|
+
prior.leaseExpiresAt === null ||
|
|
686
|
+
prior.leaseExpiresAt > Date.now())) {
|
|
687
|
+
throw new BackgroundJobError('E_JOB_NOT_RECLAIMABLE', problem ?? 'Only explicitly expired ownership or authentic pending work may be claimed');
|
|
688
|
+
}
|
|
689
|
+
this.#assertInvocation(execution, prior.operation, prior.projectId, prior.idempotencyKey);
|
|
690
|
+
this.#requireCapacity(maxRunning, id);
|
|
691
|
+
if (!Number.isSafeInteger(prior.fencingEpoch + 1) ||
|
|
692
|
+
!Number.isSafeInteger(prior.attempts + 1)) {
|
|
693
|
+
throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Claim counter exhausted; explicit recovery is required');
|
|
694
|
+
}
|
|
695
|
+
return this.#claimRow(prior, now);
|
|
696
|
+
}, execution?.deadlineAt, (message) => this.#committedCleanupFailures.set(id, message), () => execution?.assertActive());
|
|
697
|
+
if (!row)
|
|
698
|
+
throw new BackgroundJobError('E_JOB_LEASE_LOST', 'Claimed job disappeared');
|
|
699
|
+
const grant = this.#grant(row);
|
|
700
|
+
this.#grants.set(id, grant);
|
|
701
|
+
return grant;
|
|
702
|
+
}
|
|
703
|
+
/** Assign the next owned epoch; terminal retry clears its retained previous outcome from the active row. */
|
|
704
|
+
#claimRow(prior, now, terminalRetry = false) {
|
|
705
|
+
return this.#db
|
|
135
706
|
.update(backgroundJobs)
|
|
136
707
|
.set({
|
|
137
|
-
status: '
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
708
|
+
status: 'running',
|
|
709
|
+
ownerId: this.#ownerId,
|
|
710
|
+
claimedBy: this.#options.actor ?? null,
|
|
711
|
+
fencingEpoch: prior.fencingEpoch + 1,
|
|
712
|
+
attempts: prior.attempts + 1,
|
|
141
713
|
heartbeatAt: now,
|
|
714
|
+
leaseExpiresAt: Date.now() + this.leaseMs,
|
|
715
|
+
...(terminalRetry
|
|
716
|
+
? {
|
|
717
|
+
completedAt: null,
|
|
718
|
+
result: null,
|
|
719
|
+
error: null,
|
|
720
|
+
cancellationRequestedAt: null,
|
|
721
|
+
progress: 0,
|
|
722
|
+
}
|
|
723
|
+
: {}),
|
|
142
724
|
})
|
|
143
|
-
.where(eq(backgroundJobs.id, id))
|
|
144
|
-
.
|
|
725
|
+
.where(eq(backgroundJobs.id, prior.id))
|
|
726
|
+
.returning()
|
|
727
|
+
.get();
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Start an explicit new attempt after retaining terminal or expired owned work atomically.
|
|
731
|
+
* @param id - Authentic failed, cancelled or explicitly expired owned job with the same immutable proposal.
|
|
732
|
+
* @param now - New attempt timestamp.
|
|
733
|
+
* @param execution - Fresh bounded invocation; expired contexts or old fences cannot be renewed.
|
|
734
|
+
* @param retainOutcome - Trusted synchronous core callback that rechecks domain preconditions,
|
|
735
|
+
* appends the complete previous row image, and verifies its receipt before returning.
|
|
736
|
+
* @param maxRunning - Optional capacity limit enforced inside the same transaction.
|
|
737
|
+
* @returns New owned lease, without reapplying any domain operation.
|
|
738
|
+
* @throws BackgroundJobError when scope, terminal status, authentic input, counters, or receipt is invalid.
|
|
739
|
+
* @remarks Complete jobs are never reopened. Expired running rows retain their authentic
|
|
740
|
+
* uncertain outcome; expiry does not invent failure. Explicit resume acknowledges a retained
|
|
741
|
+
* previous cancellation request and clears it only with the new claim. Callback failure, cancellation, or claim failure
|
|
742
|
+
* rolls back both historical bookkeeping and the new claim. The caller must independently
|
|
743
|
+
* preserve its append-only history in the same database; this is not an arbitrary callback sandbox.
|
|
744
|
+
* @example
|
|
745
|
+
* ```ts
|
|
746
|
+
* const lease = store.retryAtomically(id, Date.now(), context, retainPreviousAttempt);
|
|
747
|
+
* ```
|
|
748
|
+
*/
|
|
749
|
+
retryAtomically(id, now, execution, retainOutcome, maxRunning) {
|
|
750
|
+
execution.assertActive();
|
|
751
|
+
if (execution.writeFence)
|
|
752
|
+
throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Explicit retry requires a fresh invocation without an old attempt fence');
|
|
753
|
+
requireRunningLimit(maxRunning);
|
|
754
|
+
if (!Number.isSafeInteger(now))
|
|
755
|
+
throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Retry timestamp must be a safe integer');
|
|
756
|
+
const row = this.#write(() => {
|
|
757
|
+
const prior = this.#row(id);
|
|
758
|
+
const expiredOwned = prior?.status === 'running' &&
|
|
759
|
+
prior.ownerId !== null &&
|
|
760
|
+
prior.leaseExpiresAt !== null &&
|
|
761
|
+
prior.leaseExpiresAt <= Date.now();
|
|
762
|
+
if (!prior || (prior.status !== 'failed' && prior.status !== 'cancelled' && !expiredOwned))
|
|
763
|
+
throw new BackgroundJobError('E_JOB_NOT_RECLAIMABLE', 'Explicit retry requires terminal or expired owned work; committed effects cannot be reopened');
|
|
764
|
+
this.#assertInvocation(execution, prior.operation, prior.projectId, prior.idempotencyKey);
|
|
765
|
+
if (!prior.proposalJson ||
|
|
766
|
+
!prior.proposalHash ||
|
|
767
|
+
createHash('sha256').update(prior.proposalJson).digest('hex') !== prior.proposalHash)
|
|
768
|
+
throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Terminal retry requires authentic immutable proposal bytes');
|
|
769
|
+
if (!Number.isSafeInteger(prior.fencingEpoch + 1) ||
|
|
770
|
+
!Number.isSafeInteger(prior.attempts + 1))
|
|
771
|
+
throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Retry counter exhausted');
|
|
772
|
+
this.#requireCapacity(maxRunning, id);
|
|
773
|
+
const receipt = retainOutcome(JSON.stringify(prior));
|
|
774
|
+
if (typeof receipt !== 'string')
|
|
775
|
+
throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Retry bookkeeping must return synchronous JSON receipt bytes');
|
|
776
|
+
requireJson(receipt);
|
|
777
|
+
if (JSON.stringify(this.#row(id)) !== JSON.stringify(prior))
|
|
778
|
+
throw new BackgroundJobError('E_JOB_LEASE_LOST', 'Retry bookkeeping changed the original job before its new claim');
|
|
779
|
+
execution.assertActive();
|
|
780
|
+
return this.#claimRow(prior, now, true);
|
|
781
|
+
}, execution.deadlineAt, (message) => this.#committedCleanupFailures.set(id, message), () => execution.assertActive());
|
|
782
|
+
if (!row)
|
|
783
|
+
throw new BackgroundJobError('E_JOB_LEASE_LOST', 'Retried job disappeared');
|
|
784
|
+
const grant = this.#grant(row);
|
|
785
|
+
this.#grants.set(id, grant);
|
|
786
|
+
return grant;
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Renew an owned lease and observe persisted cancellation.
|
|
790
|
+
* @param id - Owned job identity.
|
|
791
|
+
* @param now - Heartbeat timestamp.
|
|
792
|
+
* @returns Current job including cancellation request.
|
|
793
|
+
* @remarks Expired attempts cannot revive themselves through heartbeat.
|
|
794
|
+
* @example
|
|
795
|
+
* ```ts
|
|
796
|
+
* const job = store.heartbeat(id, Date.now());
|
|
797
|
+
* ```
|
|
798
|
+
*/
|
|
799
|
+
heartbeat(id, now) {
|
|
800
|
+
return this.#write(() => {
|
|
801
|
+
this.#owned(id);
|
|
802
|
+
const row = this.#db
|
|
803
|
+
.update(backgroundJobs)
|
|
804
|
+
.set({ heartbeatAt: now, leaseExpiresAt: Date.now() + this.leaseMs })
|
|
805
|
+
.where(eq(backgroundJobs.id, id))
|
|
806
|
+
.returning()
|
|
807
|
+
.get();
|
|
808
|
+
if (!row)
|
|
809
|
+
throw new BackgroundJobError('E_JOB_LEASE_LOST', 'Heartbeat target disappeared');
|
|
810
|
+
return rowToJob(row);
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Persist a checkpoint under the current attempt fence.
|
|
815
|
+
* @param id - Owned job identity.
|
|
816
|
+
* @param valueJson - Valid serialized checkpoint bytes.
|
|
817
|
+
* @param now - Checkpoint timestamp.
|
|
818
|
+
* @remarks Writes atomically with ownership verification; does not authorize domain writes.
|
|
819
|
+
* @example
|
|
820
|
+
* ```ts
|
|
821
|
+
* store.checkpoint(id, JSON.stringify({ nextFile: 8 }), Date.now());
|
|
822
|
+
* ```
|
|
823
|
+
*/
|
|
824
|
+
checkpoint(id, valueJson, now) {
|
|
825
|
+
requireJson(valueJson);
|
|
826
|
+
this.#write(() => {
|
|
827
|
+
this.#owned(id);
|
|
828
|
+
this.#db
|
|
829
|
+
.update(backgroundJobs)
|
|
830
|
+
.set({ checkpointJson: valueJson, checkpointAt: now })
|
|
831
|
+
.where(eq(backgroundJobs.id, id))
|
|
832
|
+
.run();
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Request cancellation without claiming that execution has stopped.
|
|
837
|
+
* @param id - Visible job identity.
|
|
838
|
+
* @param now - Request timestamp.
|
|
839
|
+
* @param execution - Optional original invocation context; never renews its deadline.
|
|
840
|
+
* @returns Whether pending work was cancelled or running work has a persisted request.
|
|
841
|
+
* @remarks Terminal results are preserved when cancellation arrives after commit.
|
|
842
|
+
* @example
|
|
843
|
+
* ```ts
|
|
844
|
+
* const requested = store.requestCancel(id, Date.now());
|
|
845
|
+
* ```
|
|
846
|
+
*/
|
|
847
|
+
requestCancel(id, now, execution) {
|
|
848
|
+
execution?.assertActive();
|
|
849
|
+
return this.#write(() => {
|
|
850
|
+
const row = this.#row(id);
|
|
851
|
+
if (!row)
|
|
852
|
+
return false;
|
|
853
|
+
this.#assertInvocation(execution, row.operation, row.projectId, row.idempotencyKey);
|
|
854
|
+
if (row.status !== 'running' && row.status !== 'pending')
|
|
855
|
+
return false;
|
|
856
|
+
if (row.status === 'pending') {
|
|
857
|
+
const problem = pendingProposalProblem(row);
|
|
858
|
+
if (problem)
|
|
859
|
+
throw new BackgroundJobError('E_JOB_NOT_RECLAIMABLE', problem);
|
|
860
|
+
this.#db
|
|
861
|
+
.update(backgroundJobs)
|
|
862
|
+
.set({ status: 'cancelled', cancellationRequestedAt: now, completedAt: now })
|
|
863
|
+
.where(eq(backgroundJobs.id, id))
|
|
864
|
+
.run();
|
|
865
|
+
return true;
|
|
866
|
+
}
|
|
867
|
+
if (row.cancellationRequestedAt === null)
|
|
868
|
+
this.#db
|
|
869
|
+
.update(backgroundJobs)
|
|
870
|
+
.set({ cancellationRequestedAt: now })
|
|
871
|
+
.where(eq(backgroundJobs.id, id))
|
|
872
|
+
.run();
|
|
873
|
+
return true;
|
|
874
|
+
}, execution?.deadlineAt, (message) => this.#committedCleanupFailures.set(id, message), () => execution?.assertActive());
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Commit actual executor success under ownership.
|
|
878
|
+
* @param id - Job identity.
|
|
879
|
+
* @param result - Actual outcome.
|
|
880
|
+
* @param now - Completion timestamp.
|
|
881
|
+
* @remarks An earlier cancellation request does not erase work already committed.
|
|
882
|
+
* @example
|
|
883
|
+
* ```ts
|
|
884
|
+
* store.complete(id, { verified: true }, Date.now());
|
|
885
|
+
* ```
|
|
886
|
+
*/
|
|
887
|
+
complete(id, result, now) {
|
|
888
|
+
const resultJson = result === undefined ? null : JSON.stringify(result);
|
|
889
|
+
this.#write(() => {
|
|
890
|
+
this.#owned(id);
|
|
891
|
+
this.#completeRow(id, resultJson, now);
|
|
892
|
+
});
|
|
893
|
+
this.#grants.delete(id);
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Persist terminal data inside the already-owned transaction.
|
|
897
|
+
* @param id - Job whose ownership was checked by the caller.
|
|
898
|
+
* @param resultJson - Serialized outcome, or null for the legacy empty result.
|
|
899
|
+
* @param now - Completion time in epoch milliseconds.
|
|
900
|
+
*/
|
|
901
|
+
#completeRow(id, resultJson, now) {
|
|
902
|
+
this.#db.run(sql `UPDATE main.background_jobs SET status='complete',
|
|
903
|
+
completed_at=${now}, result=${resultJson}, progress=100, heartbeat_at=${now}
|
|
904
|
+
WHERE id=${id}`);
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Commit a synchronous domain mutation, its receipt and job completion atomically.
|
|
908
|
+
* @param execution - Captured context carrying this client's current claimed fence.
|
|
909
|
+
* @param mutation - Trusted synchronous domain operation returning JSON receipt bytes.
|
|
910
|
+
* @returns Exact receipt bytes committed with the domain changes and terminal job row.
|
|
911
|
+
* @throws BackgroundJobError when the transaction is borrowed, authority changed,
|
|
912
|
+
* cancellation was observed before commit, or receipt bytes are invalid.
|
|
913
|
+
* @remarks The store acquires its own write transaction; an ambient BEGIN confers
|
|
914
|
+
* no authority. Domain code must independently validate its proposal and operate
|
|
915
|
+
* only on the same SQLite database without transaction control or external effects.
|
|
916
|
+
* Guards are cooperative boundaries, not preemption. Cancellation observed after
|
|
917
|
+
* COMMIT cannot erase the committed result. Arbitrary async callbacks are unsupported.
|
|
918
|
+
* @example
|
|
919
|
+
* ```ts
|
|
920
|
+
* const receiptJson = store.completeAtomically(execution, () => applyPreparedRepair());
|
|
921
|
+
* ```
|
|
922
|
+
*/
|
|
923
|
+
completeAtomically(execution, mutation) {
|
|
924
|
+
execution.assertActive();
|
|
925
|
+
const fence = execution.writeFence;
|
|
926
|
+
if (!fence) {
|
|
927
|
+
throw new BackgroundJobError('E_JOB_LEASE_LOST', 'Atomic mutation requires a claimed job fence');
|
|
928
|
+
}
|
|
929
|
+
const resultJson = this.#write(() => {
|
|
930
|
+
this.#owned(fence.lease.jobId);
|
|
931
|
+
assertOperationWriteFence(this.#db, execution);
|
|
932
|
+
const receipt = mutation(execution);
|
|
933
|
+
requireJson(receipt);
|
|
934
|
+
assertOperationWriteFence(this.#db, execution);
|
|
935
|
+
this.#completeRow(fence.lease.jobId, receipt, Date.now());
|
|
936
|
+
assertOperationJobState(this.#db, execution, 'complete', receipt);
|
|
937
|
+
execution.assertActive();
|
|
938
|
+
return receipt;
|
|
939
|
+
}, execution.deadlineAt, (message) => this.#committedCleanupFailures.set(fence.lease.jobId, message));
|
|
940
|
+
this.#grants.delete(fence.lease.jobId);
|
|
941
|
+
return resultJson;
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* Finalize trusted receipt/event metadata and a failed or cancelled attempt atomically.
|
|
945
|
+
* @param execution - Original bounded attempt identity and current ownership fence.
|
|
946
|
+
* @param outcome - Sourced observed outcome; expiry alone cannot supply this fact.
|
|
947
|
+
* @param bookkeeping - Synchronous metadata-only service callback returning JSON receipt bytes.
|
|
948
|
+
* @returns Committed receipt or explicit pending finalization with actual elapsed time.
|
|
949
|
+
* @remarks Cancellation allows bookkeeping only; domain repair remains forbidden.
|
|
950
|
+
* An exhausted deadline never acquires a lock. Failed finalization preserves the
|
|
951
|
+
* lease/checkpoint for inspection and a later explicit recovery attempt. No budget
|
|
952
|
+
* is renewed, and synchronous SQLite completion is not preempted by a timer.
|
|
953
|
+
* @example
|
|
954
|
+
* ```ts
|
|
955
|
+
* const result = store.finalizeAtomically(context, outcome, () => appendAttemptReceipt());
|
|
956
|
+
* ```
|
|
957
|
+
*/
|
|
958
|
+
finalizeAtomically(execution, outcome, bookkeeping) {
|
|
959
|
+
const startedAt = Date.now();
|
|
960
|
+
let cleanupError;
|
|
961
|
+
try {
|
|
962
|
+
if (outcome.status !== 'failed' && outcome.status !== 'cancelled')
|
|
963
|
+
throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Unsupported terminal attempt outcome');
|
|
964
|
+
requireText(outcome.message, 'outcome.message');
|
|
965
|
+
const fence = execution.writeFence;
|
|
966
|
+
if (!fence)
|
|
967
|
+
throw new BackgroundJobError('E_JOB_LEASE_LOST', 'Finalization requires a claimed job fence');
|
|
968
|
+
const resultJson = this.#write(() => {
|
|
969
|
+
const row = this.#owned(fence.lease.jobId);
|
|
970
|
+
assertOperationJobState(this.#db, execution, 'running', undefined, true);
|
|
971
|
+
if (outcome.status === 'cancelled' &&
|
|
972
|
+
row.cancellationRequestedAt === null &&
|
|
973
|
+
!execution.signal.aborted)
|
|
974
|
+
throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Cancellation has not been observed or requested');
|
|
975
|
+
const receipt = bookkeeping();
|
|
976
|
+
requireJson(receipt);
|
|
977
|
+
this.#owned(fence.lease.jobId);
|
|
978
|
+
assertOperationJobState(this.#db, execution, 'running', undefined, true);
|
|
979
|
+
const now = Date.now();
|
|
980
|
+
this.#db.run(sql `UPDATE main.background_jobs SET status=${outcome.status},
|
|
981
|
+
completed_at=${now}, result=${receipt}, error=${outcome.message}, heartbeat_at=${now}
|
|
982
|
+
WHERE id=${fence.lease.jobId}`);
|
|
983
|
+
assertOperationJobState(this.#db, execution, outcome.status, receipt, true, outcome);
|
|
984
|
+
return receipt;
|
|
985
|
+
}, execution.deadlineAt, (message) => {
|
|
986
|
+
cleanupError = message;
|
|
987
|
+
this.#committedCleanupFailures.set(fence.lease.jobId, message);
|
|
988
|
+
});
|
|
989
|
+
this.#grants.delete(fence.lease.jobId);
|
|
990
|
+
return {
|
|
991
|
+
state: 'finalized',
|
|
992
|
+
resultJson,
|
|
993
|
+
...(cleanupError ? { cleanupError } : {}),
|
|
994
|
+
elapsedMs: Date.now() - startedAt,
|
|
995
|
+
deadlineExceeded: Date.now() >= execution.deadlineAt,
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
catch (error) {
|
|
999
|
+
return {
|
|
1000
|
+
state: 'pending-finalization',
|
|
1001
|
+
reason: String(error),
|
|
1002
|
+
elapsedMs: Date.now() - startedAt,
|
|
1003
|
+
deadlineExceeded: Date.now() >= execution.deadlineAt,
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
145
1006
|
}
|
|
146
1007
|
/**
|
|
147
|
-
*
|
|
1008
|
+
* Commit actual executor failure under ownership.
|
|
1009
|
+
* @param id - Job identity.
|
|
1010
|
+
* @param error - Failure message.
|
|
1011
|
+
* @param now - Failure timestamp.
|
|
1012
|
+
* @remarks Stale owners cannot replace a newer attempt's outcome.
|
|
1013
|
+
* @example
|
|
1014
|
+
* ```ts
|
|
1015
|
+
* store.fail(id, 'Verification failed', Date.now());
|
|
1016
|
+
* ```
|
|
148
1017
|
*/
|
|
149
1018
|
fail(id, error, now) {
|
|
150
|
-
this.#
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
})
|
|
158
|
-
|
|
159
|
-
.run();
|
|
1019
|
+
this.#write(() => {
|
|
1020
|
+
this.#owned(id);
|
|
1021
|
+
this.#db
|
|
1022
|
+
.update(backgroundJobs)
|
|
1023
|
+
.set({ status: 'failed', completedAt: now, error, heartbeatAt: now })
|
|
1024
|
+
.where(eq(backgroundJobs.id, id))
|
|
1025
|
+
.run();
|
|
1026
|
+
});
|
|
1027
|
+
this.#grants.delete(id);
|
|
160
1028
|
}
|
|
161
1029
|
/**
|
|
162
|
-
*
|
|
1030
|
+
* Acknowledge an executor's cancellation.
|
|
1031
|
+
* @param id - Owned job identity.
|
|
1032
|
+
* @param now - Acknowledgement timestamp.
|
|
1033
|
+
* @remarks Requires a persisted cancellation request and current ownership.
|
|
1034
|
+
* @example
|
|
1035
|
+
* ```ts
|
|
1036
|
+
* store.cancel(id, Date.now());
|
|
1037
|
+
* ```
|
|
163
1038
|
*/
|
|
164
1039
|
cancel(id, now) {
|
|
165
|
-
this.#
|
|
166
|
-
|
|
167
|
-
.
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
1040
|
+
this.#write(() => {
|
|
1041
|
+
const row = this.#owned(id);
|
|
1042
|
+
if (row.cancellationRequestedAt === null)
|
|
1043
|
+
throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Cancellation must be requested before acknowledgement');
|
|
1044
|
+
this.#db
|
|
1045
|
+
.update(backgroundJobs)
|
|
1046
|
+
.set({ status: 'cancelled', completedAt: now, heartbeatAt: now })
|
|
1047
|
+
.where(eq(backgroundJobs.id, id))
|
|
1048
|
+
.run();
|
|
1049
|
+
});
|
|
1050
|
+
this.#grants.delete(id);
|
|
174
1051
|
}
|
|
175
1052
|
/**
|
|
176
|
-
* Update
|
|
1053
|
+
* Update an owned attempt's progress.
|
|
1054
|
+
* @param id - Job identity.
|
|
1055
|
+
* @param progress - Finite percentage, clamped to zero through one hundred.
|
|
1056
|
+
* @param now - Observation timestamp.
|
|
1057
|
+
* @remarks Progress does not renew an expired lease or overwrite another owner.
|
|
1058
|
+
* @example
|
|
1059
|
+
* ```ts
|
|
1060
|
+
* store.progress(id, 50, Date.now());
|
|
1061
|
+
* ```
|
|
177
1062
|
*/
|
|
178
1063
|
progress(id, progress, now) {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
1064
|
+
if (!Number.isFinite(progress))
|
|
1065
|
+
throw new BackgroundJobError('E_JOB_INPUT_INVALID', 'Progress must be finite');
|
|
1066
|
+
this.#write(() => {
|
|
1067
|
+
this.#owned(id);
|
|
1068
|
+
this.#db
|
|
1069
|
+
.update(backgroundJobs)
|
|
1070
|
+
.set({ progress: Math.max(0, Math.min(100, progress)), heartbeatAt: now })
|
|
1071
|
+
.where(eq(backgroundJobs.id, id))
|
|
1072
|
+
.run();
|
|
1073
|
+
});
|
|
187
1074
|
}
|
|
188
1075
|
/**
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
* @
|
|
1076
|
+
* Retain historical job evidence regardless of age.
|
|
1077
|
+
* @param _cutoffMs - Former deletion threshold, retained for API compatibility.
|
|
1078
|
+
* @returns Zero; any future deletion requires explicit evidence-aware recovery policy.
|
|
1079
|
+
* @remarks No automatic or age-only pruning is performed.
|
|
1080
|
+
* @example
|
|
1081
|
+
* ```ts
|
|
1082
|
+
* const removed = store.purgeOlderThan(Date.now()); // 0
|
|
1083
|
+
* ```
|
|
193
1084
|
*/
|
|
194
|
-
purgeOlderThan(
|
|
195
|
-
|
|
196
|
-
const rows = this.#db
|
|
197
|
-
.select({ id: backgroundJobs.id, completedAt: backgroundJobs.completedAt })
|
|
198
|
-
.from(backgroundJobs)
|
|
199
|
-
.where(inArray(backgroundJobs.status, terminalStatuses))
|
|
200
|
-
.all();
|
|
201
|
-
const staleIds = rows
|
|
202
|
-
.filter((r) => r.completedAt !== null && r.completedAt !== undefined && r.completedAt < cutoffMs)
|
|
203
|
-
.map((r) => r.id);
|
|
204
|
-
if (staleIds.length === 0) {
|
|
205
|
-
return 0;
|
|
206
|
-
}
|
|
207
|
-
this.#db.delete(backgroundJobs).where(inArray(backgroundJobs.id, staleIds)).run();
|
|
208
|
-
return staleIds.length;
|
|
1085
|
+
purgeOlderThan(_cutoffMs) {
|
|
1086
|
+
return 0;
|
|
209
1087
|
}
|
|
210
1088
|
}
|
|
211
|
-
// ---------------------------------------------------------------------------
|
|
212
|
-
// BackgroundJobManager — public façade (same interface as before T641)
|
|
213
|
-
// ---------------------------------------------------------------------------
|
|
214
1089
|
/**
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
1090
|
+
* Existing executor facade with persisted ownership and cooperative cancellation.
|
|
1091
|
+
* @remarks Executors must use a separately authorized transactional service for
|
|
1092
|
+
* domain changes and receipts; this class fences only job metadata/checkpoints.
|
|
1093
|
+
* @example
|
|
1094
|
+
* ```ts
|
|
1095
|
+
* const manager = new BackgroundJobManager(db, { projectId: 'project-uuid' });
|
|
1096
|
+
* const id = await manager.startJob('inspect', async ({ signal }) => ({ cancelled: signal.aborted }));
|
|
1097
|
+
* ```
|
|
222
1098
|
*/
|
|
223
1099
|
export class BackgroundJobManager {
|
|
224
1100
|
#store;
|
|
225
|
-
#abortControllers;
|
|
1101
|
+
#abortControllers = new Map();
|
|
1102
|
+
#heartbeatTimers = new Map();
|
|
1103
|
+
#diagnostics = new Map();
|
|
226
1104
|
#maxJobs;
|
|
227
|
-
|
|
228
|
-
|
|
1105
|
+
/**
|
|
1106
|
+
* Open the existing facade.
|
|
1107
|
+
* @param db - Canonical store.
|
|
1108
|
+
* @param config - Scope and lease configuration.
|
|
1109
|
+
* @remarks Construction does not orphan or execute persisted work.
|
|
1110
|
+
* @example
|
|
1111
|
+
* ```ts
|
|
1112
|
+
* const manager = new BackgroundJobManager(db, { projectId: 'project-uuid' });
|
|
1113
|
+
* ```
|
|
1114
|
+
*/
|
|
229
1115
|
constructor(db, config) {
|
|
230
|
-
this.#store = new DurableJobStore(db);
|
|
231
|
-
this.#abortControllers = new Map();
|
|
1116
|
+
this.#store = new DurableJobStore(db, config);
|
|
232
1117
|
this.#maxJobs = config?.maxJobs ?? 10;
|
|
233
|
-
this.#retentionMs = config?.retentionMs ?? 3_600_000; // 1 hour default
|
|
234
|
-
this.#cleanupTimer = null;
|
|
235
|
-
// Start periodic cleanup every 5 minutes
|
|
236
|
-
this.#cleanupTimer = setInterval(() => this.cleanup(), 300_000);
|
|
237
|
-
// Don't prevent process exit
|
|
238
|
-
if (this.#cleanupTimer.unref) {
|
|
239
|
-
this.#cleanupTimer.unref();
|
|
240
|
-
}
|
|
241
1118
|
}
|
|
242
1119
|
/**
|
|
243
|
-
* Start
|
|
244
|
-
*
|
|
245
|
-
* @param
|
|
246
|
-
* @param
|
|
247
|
-
* @returns
|
|
248
|
-
* @
|
|
1120
|
+
* Start new work or coalesce an identical immutable submission.
|
|
1121
|
+
* @param operation - Supported operation.
|
|
1122
|
+
* @param executor - Existing executor, now given cancellation and checkpoint capabilities.
|
|
1123
|
+
* @param submission - Optional scoped retry identity.
|
|
1124
|
+
* @returns New or already persisted job identity.
|
|
1125
|
+
* @remarks Repeated submissions never invoke a second executor.
|
|
1126
|
+
* @example
|
|
1127
|
+
* ```ts
|
|
1128
|
+
* const id = await manager.startJob('inspect', async () => ({ verified: true }), submission);
|
|
1129
|
+
* ```
|
|
249
1130
|
*/
|
|
250
|
-
async startJob(operation, executor) {
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
if (
|
|
254
|
-
|
|
1131
|
+
async startJob(operation, executor, submission) {
|
|
1132
|
+
const id = randomUUID();
|
|
1133
|
+
const lease = this.#store.insert(id, operation, Date.now(), submission, this.#maxJobs);
|
|
1134
|
+
if (!lease) {
|
|
1135
|
+
const existing = submission && this.#store.findSubmission(operation, submission);
|
|
1136
|
+
if (!existing)
|
|
1137
|
+
throw new BackgroundJobError('E_JOB_LEASE_LOST', 'Coalesced job is unavailable');
|
|
1138
|
+
return existing.id;
|
|
255
1139
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
1140
|
+
this.#launch(lease, executor);
|
|
1141
|
+
return id;
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Record optional work durably without scheduling an executor or model.
|
|
1145
|
+
* @param operation - Supported action validated by the calling domain service.
|
|
1146
|
+
* @param submission - Authentic scoped proposal bytes for explicit later resume.
|
|
1147
|
+
* @returns Existing or newly persisted job identity.
|
|
1148
|
+
* @remarks This does not establish completion of the optional projection or a
|
|
1149
|
+
* domain repair receipt. Explicit resume must validate current prerequisites.
|
|
1150
|
+
* @example
|
|
1151
|
+
* ```ts
|
|
1152
|
+
* const id = manager.deferJob('docs.projection', submission);
|
|
1153
|
+
* ```
|
|
1154
|
+
*/
|
|
1155
|
+
deferJob(operation, submission) {
|
|
1156
|
+
return this.#store.defer(randomUUID(), operation, Date.now(), submission).id;
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Explicitly start pending work or resume expired ownership using the existing facade.
|
|
1160
|
+
* @param id - Expired job identity.
|
|
1161
|
+
* @param executor - Executor capable of resuming its persisted checkpoint.
|
|
1162
|
+
* @returns Identity of the resumed job.
|
|
1163
|
+
* @remarks The earlier owner is fenced before a replacement executor can run.
|
|
1164
|
+
* @example
|
|
1165
|
+
* ```ts
|
|
1166
|
+
* await manager.resumeJob(id, async ({ checkpoint }) => { checkpoint('{}'); });
|
|
1167
|
+
* ```
|
|
1168
|
+
*/
|
|
1169
|
+
async resumeJob(id, executor) {
|
|
1170
|
+
const lease = this.#store.claim(id, Date.now(), this.#maxJobs);
|
|
1171
|
+
this.#launch(lease, executor);
|
|
1172
|
+
return id;
|
|
1173
|
+
}
|
|
1174
|
+
#launch(lease, executor) {
|
|
1175
|
+
const controller = new AbortController();
|
|
1176
|
+
this.#abortControllers.set(lease.jobId, controller);
|
|
1177
|
+
if (this.#store.get(lease.jobId)?.cancellationRequestedAt !== null)
|
|
1178
|
+
controller.abort();
|
|
1179
|
+
const timer = setInterval(() => {
|
|
1180
|
+
try {
|
|
1181
|
+
const job = this.#store.heartbeat(lease.jobId, Date.now());
|
|
1182
|
+
if (job.cancellationRequestedAt !== null)
|
|
1183
|
+
controller.abort();
|
|
1184
|
+
}
|
|
1185
|
+
catch (error) {
|
|
1186
|
+
this.#diagnostics.set(lease.jobId, error instanceof Error ? error.message : String(error));
|
|
1187
|
+
controller.abort();
|
|
1188
|
+
}
|
|
1189
|
+
}, Math.max(1, Math.floor(this.#store.leaseMs / 3)));
|
|
1190
|
+
timer.unref();
|
|
1191
|
+
this.#heartbeatTimers.set(lease.jobId, timer);
|
|
1192
|
+
void this.#executeJob(lease, executor, controller.signal);
|
|
264
1193
|
}
|
|
265
1194
|
/**
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
* @returns
|
|
1195
|
+
* Read persisted state and local diagnostic failures.
|
|
1196
|
+
* @param id - Job identity.
|
|
1197
|
+
* @returns Job or undefined.
|
|
1198
|
+
* @remarks Persistence errors are disclosed separately from a committed outcome.
|
|
1199
|
+
* @example
|
|
1200
|
+
* ```ts
|
|
1201
|
+
* const job = manager.getJob(id);
|
|
1202
|
+
* ```
|
|
269
1203
|
*/
|
|
270
|
-
getJob(
|
|
271
|
-
|
|
1204
|
+
getJob(id) {
|
|
1205
|
+
const job = this.#store.get(id);
|
|
1206
|
+
const error = this.#diagnostics.get(id);
|
|
1207
|
+
if (job && error)
|
|
1208
|
+
job.diagnosticError = error;
|
|
1209
|
+
return job;
|
|
272
1210
|
}
|
|
273
1211
|
/**
|
|
274
|
-
* List
|
|
275
|
-
*
|
|
276
|
-
* @
|
|
277
|
-
* @
|
|
1212
|
+
* List persisted scoped jobs.
|
|
1213
|
+
* @param status - Optional lifecycle filter.
|
|
1214
|
+
* @returns Matching jobs.
|
|
1215
|
+
* @remarks Includes cancellation requests and local diagnostic failures.
|
|
1216
|
+
* @example
|
|
1217
|
+
* ```ts
|
|
1218
|
+
* const jobs = manager.listJobs('running');
|
|
1219
|
+
* ```
|
|
278
1220
|
*/
|
|
279
1221
|
listJobs(status) {
|
|
280
|
-
return this.#store.list(status);
|
|
1222
|
+
return this.#store.list(status).map((job) => this.getJob(job.id) ?? job);
|
|
281
1223
|
}
|
|
282
1224
|
/**
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
* @returns
|
|
1225
|
+
* Request cancellation of visible running work.
|
|
1226
|
+
* @param id - Job identity.
|
|
1227
|
+
* @returns Whether the request was recorded, not proof of termination.
|
|
1228
|
+
* @remarks Other owners observe the durable request on heartbeat.
|
|
1229
|
+
* @example
|
|
1230
|
+
* ```ts
|
|
1231
|
+
* const requested = manager.cancelJob(id);
|
|
1232
|
+
* ```
|
|
286
1233
|
*/
|
|
287
|
-
cancelJob(
|
|
288
|
-
const
|
|
289
|
-
if (
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const controller = this.#abortControllers.get(jobId);
|
|
293
|
-
if (controller) {
|
|
294
|
-
controller.abort();
|
|
295
|
-
this.#abortControllers.delete(jobId);
|
|
296
|
-
}
|
|
297
|
-
this.#store.cancel(jobId, Date.now());
|
|
298
|
-
return true;
|
|
1234
|
+
cancelJob(id) {
|
|
1235
|
+
const requested = this.#store.requestCancel(id, Date.now());
|
|
1236
|
+
if (requested)
|
|
1237
|
+
this.#abortControllers.get(id)?.abort();
|
|
1238
|
+
return requested;
|
|
299
1239
|
}
|
|
300
1240
|
/**
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
* @
|
|
1241
|
+
* Report progress from an owned executor.
|
|
1242
|
+
* @param id - Job identity.
|
|
1243
|
+
* @param progress - Finite percentage.
|
|
1244
|
+
* @returns Whether the job exists and is running.
|
|
1245
|
+
* @remarks A visible job owned by another attempt is explicitly refused.
|
|
1246
|
+
* @example
|
|
1247
|
+
* ```ts
|
|
1248
|
+
* manager.updateProgress(id, 50);
|
|
1249
|
+
* ```
|
|
304
1250
|
*/
|
|
305
|
-
updateProgress(
|
|
306
|
-
const job = this.#store.get(
|
|
307
|
-
if (!job || job.status !== 'running')
|
|
1251
|
+
updateProgress(id, progress) {
|
|
1252
|
+
const job = this.#store.get(id);
|
|
1253
|
+
if (!job || job.status !== 'running')
|
|
308
1254
|
return false;
|
|
309
|
-
|
|
310
|
-
this.#store.progress(jobId, progress, Date.now());
|
|
1255
|
+
this.#store.progress(id, progress, Date.now());
|
|
311
1256
|
return true;
|
|
312
1257
|
}
|
|
313
1258
|
/**
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
* @
|
|
1259
|
+
* Preserve historical evidence.
|
|
1260
|
+
* @returns Zero rows removed.
|
|
1261
|
+
* @remarks Age alone never authorizes deletion of job evidence.
|
|
1262
|
+
* @example
|
|
1263
|
+
* ```ts
|
|
1264
|
+
* const removed = manager.cleanup(); // 0
|
|
1265
|
+
* ```
|
|
317
1266
|
*/
|
|
318
1267
|
cleanup() {
|
|
319
|
-
|
|
320
|
-
return this.#store.purgeOlderThan(cutoff);
|
|
1268
|
+
return 0;
|
|
321
1269
|
}
|
|
322
1270
|
/**
|
|
323
|
-
*
|
|
324
|
-
*
|
|
1271
|
+
* Request cancellation only for this manager's executors and stop its timers.
|
|
1272
|
+
* @remarks Does not claim non-cooperative executors stopped or alter another owner.
|
|
1273
|
+
* @example
|
|
1274
|
+
* ```ts
|
|
1275
|
+
* manager.destroy(); // inspect persisted cancellation/completion separately
|
|
1276
|
+
* ```
|
|
325
1277
|
*/
|
|
326
1278
|
destroy() {
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
if (controller) {
|
|
331
|
-
controller.abort();
|
|
1279
|
+
for (const [id, controller] of this.#abortControllers) {
|
|
1280
|
+
try {
|
|
1281
|
+
this.#store.requestCancel(id, Date.now());
|
|
332
1282
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
if (this.#cleanupTimer) {
|
|
338
|
-
clearInterval(this.#cleanupTimer);
|
|
339
|
-
this.#cleanupTimer = null;
|
|
1283
|
+
catch (error) {
|
|
1284
|
+
this.#diagnostics.set(id, error instanceof Error ? error.message : String(error));
|
|
1285
|
+
}
|
|
1286
|
+
controller.abort();
|
|
340
1287
|
}
|
|
1288
|
+
for (const timer of this.#heartbeatTimers.values())
|
|
1289
|
+
clearInterval(timer);
|
|
1290
|
+
this.#heartbeatTimers.clear();
|
|
341
1291
|
}
|
|
342
|
-
|
|
343
|
-
* Execute a job's executor function and persist the outcome.
|
|
344
|
-
*/
|
|
345
|
-
async #executeJob(jobId, executor, signal) {
|
|
346
|
-
const job = this.#store.get(jobId);
|
|
347
|
-
if (!job) {
|
|
348
|
-
return;
|
|
349
|
-
}
|
|
1292
|
+
async #executeJob(lease, executor, signal) {
|
|
350
1293
|
try {
|
|
351
|
-
const result = await executor();
|
|
352
|
-
// Check if cancelled during execution
|
|
353
1294
|
if (signal.aborted) {
|
|
1295
|
+
this.#store.cancel(lease.jobId, Date.now());
|
|
354
1296
|
return;
|
|
355
1297
|
}
|
|
356
|
-
|
|
1298
|
+
const result = await executor({
|
|
1299
|
+
signal,
|
|
1300
|
+
lease,
|
|
1301
|
+
checkpoint: (value) => this.#store.checkpoint(lease.jobId, value, Date.now()),
|
|
1302
|
+
});
|
|
1303
|
+
// A resolved executor reports actual completion, even if cancellation arrived
|
|
1304
|
+
// after its commit. Only an acknowledged abort establishes cancelled status.
|
|
1305
|
+
this.#store.complete(lease.jobId, result, Date.now());
|
|
357
1306
|
}
|
|
358
1307
|
catch (error) {
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
1308
|
+
try {
|
|
1309
|
+
if (signal.aborted && error instanceof Error && error.name === 'AbortError')
|
|
1310
|
+
this.#store.cancel(lease.jobId, Date.now());
|
|
1311
|
+
else
|
|
1312
|
+
this.#store.fail(lease.jobId, error instanceof Error ? error.message : String(error), Date.now());
|
|
1313
|
+
}
|
|
1314
|
+
catch (persistenceError) {
|
|
1315
|
+
this.#diagnostics.set(lease.jobId, persistenceError instanceof Error ? persistenceError.message : String(persistenceError));
|
|
362
1316
|
}
|
|
363
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
364
|
-
this.#store.fail(jobId, message, Date.now());
|
|
365
1317
|
}
|
|
366
1318
|
finally {
|
|
367
|
-
this.#
|
|
1319
|
+
const timer = this.#heartbeatTimers.get(lease.jobId);
|
|
1320
|
+
if (timer)
|
|
1321
|
+
clearInterval(timer);
|
|
1322
|
+
this.#heartbeatTimers.delete(lease.jobId);
|
|
1323
|
+
this.#abortControllers.delete(lease.jobId);
|
|
368
1324
|
}
|
|
369
1325
|
}
|
|
370
1326
|
}
|