@quolu/lattice 0.1.0 → 0.12.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -4
- package/bin/lattice-bridge.mjs +87 -0
- package/bin/lattice-dashboard.mjs +70 -0
- package/bin/lattice-mcp.mjs +9 -9
- package/bin/lattice.mjs +61 -8
- package/docs/bridge-setup.md +87 -0
- package/docs/schemas/lattice.plan_create_input.v1.schema.json +56 -0
- package/docs/schemas/lattice.plan_create_input.v2.schema.json +72 -0
- package/docs/schemas/lattice.plan_create_input.v3.schema.json +81 -0
- package/package.json +19 -7
- package/sensor/dist/bin/command-supervision.d.ts +1 -1
- package/sensor/dist/bin/command-supervision.js +4 -4
- package/sensor/dist/bin/command-supervision.js.map +1 -1
- package/sensor/dist/bin/fatal-handler.js +2 -2
- package/sensor/dist/bin/fatal-handler.js.map +1 -1
- package/sensor/dist/bin/{codegraph.js → lattice-sensor.js} +207 -328
- package/sensor/dist/bin/node-version-check.d.ts +7 -5
- package/sensor/dist/bin/node-version-check.d.ts.map +1 -1
- package/sensor/dist/bin/node-version-check.js +19 -14
- package/sensor/dist/bin/node-version-check.js.map +1 -1
- package/sensor/dist/context/index.d.ts +1 -1
- package/sensor/dist/context/index.js +6 -6
- package/sensor/dist/context/index.js.map +1 -1
- package/sensor/dist/db/index.d.ts +5 -5
- package/sensor/dist/db/index.d.ts.map +1 -1
- package/sensor/dist/db/index.js +7 -7
- package/sensor/dist/db/index.js.map +1 -1
- package/sensor/dist/db/migrations.js +1 -1
- package/sensor/dist/db/migrations.js.map +1 -1
- package/sensor/dist/db/queries.d.ts +1 -1
- package/sensor/dist/db/queries.js +5 -5
- package/sensor/dist/db/queries.js.map +1 -1
- package/sensor/dist/db/schema.sql +2 -2
- package/sensor/dist/db/sqlite-adapter.d.ts +2 -2
- package/sensor/dist/db/sqlite-adapter.js +3 -3
- package/sensor/dist/db/sqlite-adapter.js.map +1 -1
- package/sensor/dist/db/wal-valve.d.ts +1 -1
- package/sensor/dist/db/wal-valve.d.ts.map +1 -1
- package/sensor/dist/db/wal-valve.js +2 -2
- package/sensor/dist/db/wal-valve.js.map +1 -1
- package/sensor/dist/directory.d.ts +27 -63
- package/sensor/dist/directory.d.ts.map +1 -1
- package/sensor/dist/directory.js +78 -134
- package/sensor/dist/directory.js.map +1 -1
- package/sensor/dist/errors.d.ts +15 -15
- package/sensor/dist/errors.d.ts.map +1 -1
- package/sensor/dist/errors.js +21 -21
- package/sensor/dist/errors.js.map +1 -1
- package/sensor/dist/extraction/dynamic-import.d.ts +1 -1
- package/sensor/dist/extraction/dynamic-import.js +1 -1
- package/sensor/dist/extraction/extraction-version.d.ts +1 -1
- package/sensor/dist/extraction/extraction-version.js +1 -1
- package/sensor/dist/extraction/generated-detection.d.ts +1 -1
- package/sensor/dist/extraction/generated-detection.js +1 -1
- package/sensor/dist/extraction/grammars.d.ts +3 -3
- package/sensor/dist/extraction/grammars.js +5 -5
- package/sensor/dist/extraction/grammars.js.map +1 -1
- package/sensor/dist/extraction/index.d.ts +6 -6
- package/sensor/dist/extraction/index.js +50 -50
- package/sensor/dist/extraction/index.js.map +1 -1
- package/sensor/dist/extraction/kernel/decode.d.ts +1 -1
- package/sensor/dist/extraction/kernel/decode.js +1 -1
- package/sensor/dist/extraction/kernel/index.d.ts +3 -3
- package/sensor/dist/extraction/kernel/index.js +6 -6
- package/sensor/dist/extraction/kernel/index.js.map +1 -1
- package/sensor/dist/extraction/kernel/layout.d.ts +1 -1
- package/sensor/dist/extraction/kernel/layout.js +1 -1
- package/sensor/dist/extraction/kernel/loader.d.ts +6 -6
- package/sensor/dist/extraction/kernel/loader.js +13 -13
- package/sensor/dist/extraction/kernel/loader.js.map +1 -1
- package/sensor/dist/extraction/languages/cfquery.d.ts +1 -1
- package/sensor/dist/extraction/languages/cfquery.js +1 -1
- package/sensor/dist/extraction/languages/lua.d.ts +11 -0
- package/sensor/dist/extraction/languages/lua.d.ts.map +1 -1
- package/sensor/dist/extraction/languages/lua.js +1 -0
- package/sensor/dist/extraction/languages/lua.js.map +1 -1
- package/sensor/dist/extraction/parse-pool.d.ts +5 -5
- package/sensor/dist/extraction/parse-pool.d.ts.map +1 -1
- package/sensor/dist/extraction/parse-pool.js +4 -4
- package/sensor/dist/extraction/parse-worker.js +1 -1
- package/sensor/dist/extraction/parse-worker.js.map +1 -1
- package/sensor/dist/extraction/store-writer.d.ts +1 -1
- package/sensor/dist/extraction/store-writer.js +1 -1
- package/sensor/dist/extraction/svelte-extractor.d.ts +1 -1
- package/sensor/dist/extraction/svelte-extractor.js +1 -1
- package/sensor/dist/extraction/tree-sitter-helpers.js +1 -1
- package/sensor/dist/extraction/tree-sitter.d.ts +2 -2
- package/sensor/dist/extraction/tree-sitter.d.ts.map +1 -1
- package/sensor/dist/extraction/tree-sitter.js +71 -4
- package/sensor/dist/extraction/tree-sitter.js.map +1 -1
- package/sensor/dist/extraction/wasm-runtime-flags.d.ts +2 -2
- package/sensor/dist/extraction/wasm-runtime-flags.d.ts.map +1 -1
- package/sensor/dist/extraction/wasm-runtime-flags.js +5 -5
- package/sensor/dist/extraction/wasm-runtime-flags.js.map +1 -1
- package/sensor/dist/index.d.ts +36 -36
- package/sensor/dist/index.d.ts.map +1 -1
- package/sensor/dist/index.js +71 -71
- package/sensor/dist/index.js.map +1 -1
- package/sensor/dist/mcp/daemon-manager.js +1 -1
- package/sensor/dist/mcp/daemon-paths.d.ts +3 -3
- package/sensor/dist/mcp/daemon-paths.js +7 -7
- package/sensor/dist/mcp/daemon-paths.js.map +1 -1
- package/sensor/dist/mcp/daemon-registry.d.ts +2 -2
- package/sensor/dist/mcp/daemon-registry.js +5 -5
- package/sensor/dist/mcp/daemon.d.ts +7 -7
- package/sensor/dist/mcp/daemon.d.ts.map +1 -1
- package/sensor/dist/mcp/daemon.js +21 -21
- package/sensor/dist/mcp/daemon.js.map +1 -1
- package/sensor/dist/mcp/dynamic-boundaries.js +1 -1
- package/sensor/dist/mcp/engine.d.ts +9 -9
- package/sensor/dist/mcp/engine.d.ts.map +1 -1
- package/sensor/dist/mcp/engine.js +44 -44
- package/sensor/dist/mcp/engine.js.map +1 -1
- package/sensor/dist/mcp/index.d.ts +14 -14
- package/sensor/dist/mcp/index.d.ts.map +1 -1
- package/sensor/dist/mcp/index.js +41 -41
- package/sensor/dist/mcp/index.js.map +1 -1
- package/sensor/dist/mcp/liveness-watchdog.js +7 -7
- package/sensor/dist/mcp/liveness-watchdog.js.map +1 -1
- package/sensor/dist/mcp/ppid-watchdog.d.ts +3 -3
- package/sensor/dist/mcp/ppid-watchdog.js +2 -2
- package/sensor/dist/mcp/proxy.d.ts.map +1 -1
- package/sensor/dist/mcp/proxy.js +37 -37
- package/sensor/dist/mcp/proxy.js.map +1 -1
- package/sensor/dist/mcp/query-pool.d.ts +3 -3
- package/sensor/dist/mcp/query-pool.js +7 -7
- package/sensor/dist/mcp/query-pool.js.map +1 -1
- package/sensor/dist/mcp/query-worker.d.ts +2 -2
- package/sensor/dist/mcp/query-worker.js +7 -7
- package/sensor/dist/mcp/query-worker.js.map +1 -1
- package/sensor/dist/mcp/server-instructions.d.ts +10 -12
- package/sensor/dist/mcp/server-instructions.d.ts.map +1 -1
- package/sensor/dist/mcp/server-instructions.js +39 -37
- package/sensor/dist/mcp/server-instructions.js.map +1 -1
- package/sensor/dist/mcp/session.d.ts +2 -2
- package/sensor/dist/mcp/session.js +18 -18
- package/sensor/dist/mcp/session.js.map +1 -1
- package/sensor/dist/mcp/startup-handshake.d.ts +3 -3
- package/sensor/dist/mcp/startup-handshake.d.ts.map +1 -1
- package/sensor/dist/mcp/startup-handshake.js +3 -3
- package/sensor/dist/mcp/startup-handshake.js.map +1 -1
- package/sensor/dist/mcp/tools.d.ts +53 -53
- package/sensor/dist/mcp/tools.d.ts.map +1 -1
- package/sensor/dist/mcp/tools.js +180 -216
- package/sensor/dist/mcp/tools.js.map +1 -1
- package/sensor/dist/mcp/transport.d.ts +1 -1
- package/sensor/dist/mcp/transport.js +6 -6
- package/sensor/dist/mcp/transport.js.map +1 -1
- package/sensor/dist/mcp/version.d.ts +2 -2
- package/sensor/dist/mcp/version.d.ts.map +1 -1
- package/sensor/dist/mcp/version.js +3 -3
- package/sensor/dist/mcp/version.js.map +1 -1
- package/sensor/dist/project-config.d.ts +6 -6
- package/sensor/dist/project-config.d.ts.map +1 -1
- package/sensor/dist/project-config.js +10 -10
- package/sensor/dist/project-config.js.map +1 -1
- package/sensor/dist/resolution/callback-synthesizer.js +4 -4
- package/sensor/dist/resolution/callback-synthesizer.js.map +1 -1
- package/sensor/dist/resolution/frameworks/drupal.d.ts +3 -3
- package/sensor/dist/resolution/frameworks/drupal.js +3 -3
- package/sensor/dist/resolution/frameworks/fabric.js +1 -1
- package/sensor/dist/resolution/frameworks/fabric.js.map +1 -1
- package/sensor/dist/resolution/frameworks/java.js +1 -1
- package/sensor/dist/resolution/frameworks/java.js.map +1 -1
- package/sensor/dist/resolution/frameworks/svelte.js +1 -1
- package/sensor/dist/resolution/frameworks/svelte.js.map +1 -1
- package/sensor/dist/resolution/goframe-synthesizer.d.ts +1 -1
- package/sensor/dist/resolution/goframe-synthesizer.js +1 -1
- package/sensor/dist/resolution/index.d.ts +1 -1
- package/sensor/dist/resolution/index.js +9 -9
- package/sensor/dist/resolution/index.js.map +1 -1
- package/sensor/dist/resolution/name-matcher.js +2 -2
- package/sensor/dist/resolution/name-matcher.js.map +1 -1
- package/sensor/dist/resolution/resolver-pool.d.ts +2 -2
- package/sensor/dist/resolution/resolver-pool.js +4 -4
- package/sensor/dist/resolution/resolver-pool.js.map +1 -1
- package/sensor/dist/resolution/resolver-worker.js +2 -2
- package/sensor/dist/resolution/resolver-worker.js.map +1 -1
- package/sensor/dist/resolution/workspace-packages.js +1 -1
- package/sensor/dist/resolution/workspace-packages.js.map +1 -1
- package/sensor/dist/search/identifier-segments.d.ts +1 -1
- package/sensor/dist/search/identifier-segments.js +1 -1
- package/sensor/dist/search/query-utils.d.ts +1 -0
- package/sensor/dist/search/query-utils.d.ts.map +1 -1
- package/sensor/dist/search/query-utils.js +10 -3
- package/sensor/dist/search/query-utils.js.map +1 -1
- package/sensor/dist/sync/git-hooks.d.ts +9 -9
- package/sensor/dist/sync/git-hooks.d.ts.map +1 -1
- package/sensor/dist/sync/git-hooks.js +15 -15
- package/sensor/dist/sync/git-hooks.js.map +1 -1
- package/sensor/dist/sync/watch-policy.d.ts +2 -2
- package/sensor/dist/sync/watch-policy.js +5 -5
- package/sensor/dist/sync/watch-policy.js.map +1 -1
- package/sensor/dist/sync/watcher.d.ts +2 -2
- package/sensor/dist/sync/watcher.d.ts.map +1 -1
- package/sensor/dist/sync/watcher.js +16 -16
- package/sensor/dist/sync/watcher.js.map +1 -1
- package/sensor/dist/sync/worktree.d.ts +5 -5
- package/sensor/dist/sync/worktree.d.ts.map +1 -1
- package/sensor/dist/sync/worktree.js +9 -9
- package/sensor/dist/sync/worktree.js.map +1 -1
- package/sensor/dist/telemetry/index.d.ts +8 -8
- package/sensor/dist/telemetry/index.d.ts.map +1 -1
- package/sensor/dist/telemetry/index.js +16 -16
- package/sensor/dist/telemetry/index.js.map +1 -1
- package/sensor/dist/types.d.ts +2 -2
- package/sensor/dist/types.js +1 -1
- package/sensor/dist/ui/color.d.ts +2 -2
- package/sensor/dist/ui/color.js +2 -2
- package/sensor/dist/ui/glyphs.d.ts +3 -3
- package/sensor/dist/ui/glyphs.js +7 -7
- package/sensor/dist/ui/glyphs.js.map +1 -1
- package/sensor/dist/utils.d.ts +4 -4
- package/sensor/dist/utils.js +8 -8
- package/sensor/dist/utils.js.map +1 -1
- package/sensor/package.json +8 -13
- package/src/artifact-contracts.mjs +2 -2
- package/src/boundary-compiler.mjs +28 -28
- package/src/boundary-observation-compiler-v2.mjs +10 -10
- package/src/bounded-seam.mjs +144 -0
- package/src/bridge-cli.mjs +227 -0
- package/src/bridge-config.mjs +346 -0
- package/src/bridge-daemon.mjs +328 -0
- package/src/bridge-launch-agent.mjs +311 -0
- package/src/bridge-server.mjs +309 -0
- package/src/cli-help.mjs +163 -0
- package/src/control-compiler.mjs +30 -30
- package/src/dag-chain.mjs +261 -0
- package/src/factory-diagnostics.mjs +8 -7
- package/src/hash-chain.mjs +76 -0
- package/src/node-version-guard.mjs +5 -4
- package/src/project-cli.mjs +392 -0
- package/src/project-identity.mjs +71 -0
- package/src/rc1-evidence-bundle.mjs +16 -16
- package/src/rc1-v4-campaign.mjs +15 -14
- package/src/rc1-v5-artifact-set.mjs +2 -2
- package/src/rc1-v5-campaign.mjs +15 -14
- package/src/rc1-v6-artifact-set.mjs +18 -18
- package/src/rc1-v6-campaign.mjs +16 -16
- package/src/rc1-v6-causal-binding.mjs +16 -14
- package/src/rc1-v6-measurement.mjs +20 -38
- package/src/rc2-artifact-set.mjs +45 -40
- package/src/rc2-campaign.mjs +48 -66
- package/src/rc2-delivery-policy-front-end.mjs +30 -30
- package/src/rc2-rc1-transfer-front-end.mjs +5 -5
- package/src/rc3-actual-dogfood.mjs +6 -6
- package/src/rc3-dogfood-scaffold.mjs +12 -7
- package/src/rc3-scripted-campaign.mjs +75 -21
- package/src/rc4-stage1-dogfood.mjs +24 -7
- package/src/runtime-cli.mjs +2424 -45
- package/src/runtime-contracts.mjs +7 -7
- package/src/runtime-control-store.mjs +547 -0
- package/src/runtime-controller-protocol.mjs +517 -0
- package/src/runtime-direct-os-observer.mjs +284 -0
- package/src/runtime-engine.mjs +2 -2
- package/src/runtime-errors.mjs +1 -1
- package/src/runtime-event-store.mjs +15 -43
- package/src/runtime-front-end.mjs +26 -26
- package/src/runtime-gate-store.mjs +477 -0
- package/src/runtime-hold-recompile.mjs +214 -23
- package/src/runtime-lifecycle-lock.mjs +290 -0
- package/src/runtime-managed-supervisor.mjs +1276 -0
- package/src/runtime-multi-epoch-store.mjs +761 -0
- package/src/runtime-projection.mjs +62 -0
- package/src/seam-transform.mjs +38 -38
- package/src/{codegraph-adapter.mjs → sensor-adapter.mjs} +7 -7
- package/src/sensor-cli.mjs +107 -0
- package/src/sensor-runtime.mjs +48 -0
- package/src/timestamp-contract.mjs +8 -0
- package/src/todo-chain.mjs +178 -0
- package/src/todo-cli.mjs +1032 -0
- package/src/todo-contracts.mjs +501 -0
- package/src/todo-dashboard-registry.mjs +318 -0
- package/src/todo-gantt-html.mjs +416 -0
- package/src/todo-gantt-layout.mjs +603 -0
- package/src/todo-gantt-live.mjs +269 -0
- package/src/todo-gantt-presentation.mjs +217 -0
- package/src/todo-gantt-svg.mjs +249 -0
- package/src/todo-markdown-renderer.mjs +260 -0
- package/src/todo-migration.mjs +260 -0
- package/src/todo-narrative-anchor.mjs +130 -0
- package/src/todo-revision.mjs +425 -0
- package/src/todo-status.mjs +340 -0
- package/src/todo-store.mjs +3451 -0
- package/src/treatment-compiler.mjs +28 -28
- package/src/treatment-runner.mjs +42 -44
- package/sensor/dist/bin/codegraph.d.ts +0 -26
- package/sensor/dist/bin/codegraph.d.ts.map +0 -1
- package/sensor/dist/bin/codegraph.js.map +0 -1
- package/sensor/dist/bin/uninstall.d.ts +0 -14
- package/sensor/dist/bin/uninstall.d.ts.map +0 -1
- package/sensor/dist/bin/uninstall.js +0 -36
- package/sensor/dist/bin/uninstall.js.map +0 -1
- package/sensor/dist/installer/beta-signup.d.ts +0 -54
- package/sensor/dist/installer/beta-signup.d.ts.map +0 -1
- package/sensor/dist/installer/beta-signup.js +0 -178
- package/sensor/dist/installer/beta-signup.js.map +0 -1
- package/sensor/dist/installer/config-writer.d.ts +0 -28
- package/sensor/dist/installer/config-writer.d.ts.map +0 -1
- package/sensor/dist/installer/config-writer.js +0 -91
- package/sensor/dist/installer/config-writer.js.map +0 -1
- package/sensor/dist/installer/index.d.ts +0 -142
- package/sensor/dist/installer/index.d.ts.map +0 -1
- package/sensor/dist/installer/index.js +0 -622
- package/sensor/dist/installer/index.js.map +0 -1
- package/sensor/dist/installer/instructions-template.d.ts +0 -41
- package/sensor/dist/installer/instructions-template.d.ts.map +0 -1
- package/sensor/dist/installer/instructions-template.js +0 -53
- package/sensor/dist/installer/instructions-template.js.map +0 -1
- package/sensor/dist/installer/targets/antigravity.d.ts +0 -57
- package/sensor/dist/installer/targets/antigravity.d.ts.map +0 -1
- package/sensor/dist/installer/targets/antigravity.js +0 -308
- package/sensor/dist/installer/targets/antigravity.js.map +0 -1
- package/sensor/dist/installer/targets/claude.d.ts +0 -62
- package/sensor/dist/installer/targets/claude.d.ts.map +0 -1
- package/sensor/dist/installer/targets/claude.js +0 -454
- package/sensor/dist/installer/targets/claude.js.map +0 -1
- package/sensor/dist/installer/targets/codex.d.ts +0 -18
- package/sensor/dist/installer/targets/codex.d.ts.map +0 -1
- package/sensor/dist/installer/targets/codex.js +0 -185
- package/sensor/dist/installer/targets/codex.js.map +0 -1
- package/sensor/dist/installer/targets/cursor.d.ts +0 -35
- package/sensor/dist/installer/targets/cursor.d.ts.map +0 -1
- package/sensor/dist/installer/targets/cursor.js +0 -254
- package/sensor/dist/installer/targets/cursor.js.map +0 -1
- package/sensor/dist/installer/targets/gemini.d.ts +0 -26
- package/sensor/dist/installer/targets/gemini.d.ts.map +0 -1
- package/sensor/dist/installer/targets/gemini.js +0 -165
- package/sensor/dist/installer/targets/gemini.js.map +0 -1
- package/sensor/dist/installer/targets/hermes.d.ts +0 -18
- package/sensor/dist/installer/targets/hermes.d.ts.map +0 -1
- package/sensor/dist/installer/targets/hermes.js +0 -359
- package/sensor/dist/installer/targets/hermes.js.map +0 -1
- package/sensor/dist/installer/targets/kiro.d.ts +0 -27
- package/sensor/dist/installer/targets/kiro.d.ts.map +0 -1
- package/sensor/dist/installer/targets/kiro.js +0 -178
- package/sensor/dist/installer/targets/kiro.js.map +0 -1
- package/sensor/dist/installer/targets/opencode.d.ts +0 -38
- package/sensor/dist/installer/targets/opencode.d.ts.map +0 -1
- package/sensor/dist/installer/targets/opencode.js +0 -288
- package/sensor/dist/installer/targets/opencode.js.map +0 -1
- package/sensor/dist/installer/targets/registry.d.ts +0 -35
- package/sensor/dist/installer/targets/registry.d.ts.map +0 -1
- package/sensor/dist/installer/targets/registry.js +0 -91
- package/sensor/dist/installer/targets/registry.js.map +0 -1
- package/sensor/dist/installer/targets/shared.d.ts +0 -101
- package/sensor/dist/installer/targets/shared.d.ts.map +0 -1
- package/sensor/dist/installer/targets/shared.js +0 -264
- package/sensor/dist/installer/targets/shared.js.map +0 -1
- package/sensor/dist/installer/targets/toml.d.ts +0 -52
- package/sensor/dist/installer/targets/toml.d.ts.map +0 -1
- package/sensor/dist/installer/targets/toml.js +0 -147
- package/sensor/dist/installer/targets/toml.js.map +0 -1
- package/sensor/dist/installer/targets/types.d.ts +0 -108
- package/sensor/dist/installer/targets/types.d.ts.map +0 -1
- package/sensor/dist/installer/targets/types.js +0 -16
- package/sensor/dist/installer/targets/types.js.map +0 -1
- package/sensor/dist/upgrade/index.d.ts +0 -171
- package/sensor/dist/upgrade/index.d.ts.map +0 -1
- package/sensor/dist/upgrade/index.js +0 -659
- package/sensor/dist/upgrade/index.js.map +0 -1
- package/sensor/dist/upgrade/remove-binary.d.ts +0 -87
- package/sensor/dist/upgrade/remove-binary.d.ts.map +0 -1
- package/sensor/dist/upgrade/remove-binary.js +0 -289
- package/sensor/dist/upgrade/remove-binary.js.map +0 -1
- package/sensor/dist/upgrade/update-check.d.ts +0 -92
- package/sensor/dist/upgrade/update-check.d.ts.map +0 -1
- package/sensor/dist/upgrade/update-check.js +0 -258
- package/sensor/dist/upgrade/update-check.js.map +0 -1
- package/src/bootstrap.mjs +0 -88
|
@@ -0,0 +1,3451 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import {
|
|
4
|
+
lstat, mkdir, open, readFile, readdir, realpath, rename, rm, rmdir,
|
|
5
|
+
} from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import {
|
|
8
|
+
TODO_LIMITS,
|
|
9
|
+
canonicalizeTodoArtifact,
|
|
10
|
+
digestTodoArtifact,
|
|
11
|
+
exactRecord,
|
|
12
|
+
isStrictTodoTimestamp,
|
|
13
|
+
isTodoDigest,
|
|
14
|
+
isTodoIdentifier,
|
|
15
|
+
todoSelfDigest,
|
|
16
|
+
validateEvidenceDescriptor,
|
|
17
|
+
validateTodoImportSource,
|
|
18
|
+
validateTodoEvent,
|
|
19
|
+
validateTodoManifest,
|
|
20
|
+
validateTodoPlan,
|
|
21
|
+
validateTodoSnapshot,
|
|
22
|
+
} from './todo-contracts.mjs';
|
|
23
|
+
import { sha256Bytes, verifyLinearHashChain } from './hash-chain.mjs';
|
|
24
|
+
import {
|
|
25
|
+
parseTodoSourceRef,
|
|
26
|
+
todoCutoverArchiveSourceRef,
|
|
27
|
+
todoLegacyReconciliationDigest,
|
|
28
|
+
validatePhaseTodoRevision,
|
|
29
|
+
validateTodoRevision,
|
|
30
|
+
validateTodoRevisionSet,
|
|
31
|
+
} from './todo-revision.mjs';
|
|
32
|
+
|
|
33
|
+
const STORE_ROOT_REF = '.lattice/todo';
|
|
34
|
+
const MANIFEST_REF = `${STORE_ROOT_REF}/manifest.json`;
|
|
35
|
+
const SOURCE_CUTOVER_BARRIER_REF = `${STORE_ROOT_REF}/source-cutover-recovery.json`;
|
|
36
|
+
const SOURCE_CUTOVER_RECOVERY_CAPABILITY = Symbol('lattice.todo.source-cutover-recovery');
|
|
37
|
+
const MAX_FUTURE_SKEW_MS = 5 * 60 * 1_000;
|
|
38
|
+
const WRITER_CALLERS = new Set(['g4-migration', 'g5-authoring']);
|
|
39
|
+
const TODO_REVISION_SCHEMAS = Object.freeze([
|
|
40
|
+
'lattice.todo_revision.v1', 'lattice.todo_revision.v2',
|
|
41
|
+
]);
|
|
42
|
+
const PHASE_TODO_REVISION_SCHEMAS = Object.freeze([
|
|
43
|
+
'lattice.phase_todo_revision.v1', 'lattice.phase_todo_revision.v2',
|
|
44
|
+
'lattice.phase_todo_revision.v3',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
export class TodoStoreError extends Error {
|
|
48
|
+
constructor(code, reason, message = reason, detail = {}) {
|
|
49
|
+
super(message);
|
|
50
|
+
this.name = 'TodoStoreError';
|
|
51
|
+
this.code = code;
|
|
52
|
+
this.detail = { reason, ...detail };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createTodoStoreWriter(options = {}) {
|
|
57
|
+
if (!exactRecord(options, ['caller']) || !WRITER_CALLERS.has(options.caller)) {
|
|
58
|
+
throw new TypeError('todo store writer caller must be g4-migration or g5-authoring');
|
|
59
|
+
}
|
|
60
|
+
return Object.freeze({ schema: 'lattice.todo_store_writer.v1', caller: options.caller });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function fail(code, reason, detail) {
|
|
64
|
+
throw new TodoStoreError(code, reason, reason, detail);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function rejectUnsupportedRevisionSchema(value, { family, supportedSchemas }) {
|
|
68
|
+
const schema = value?.schema;
|
|
69
|
+
if (typeof schema === 'string'
|
|
70
|
+
&& new RegExp(`^lattice\\.${family}\\.v[1-9]\\d*$`, 'u').test(schema)
|
|
71
|
+
&& !supportedSchemas.includes(schema)) {
|
|
72
|
+
fail('UNSUPPORTED_SCHEMA', 'unsupported_revision_schema', {
|
|
73
|
+
schema, supported_schemas: [...supportedSchemas],
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function requireWriter(writer, caller) {
|
|
79
|
+
if (!exactRecord(writer, ['schema', 'caller']) || writer.schema !== 'lattice.todo_store_writer.v1'
|
|
80
|
+
|| writer.caller !== caller) throw new TypeError(`writer capability for ${caller} required`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function canonicalLine(value) {
|
|
84
|
+
return Buffer.from(`${canonicalizeTodoArtifact(value)}\n`, 'utf8');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function pathState(repoRoot, ref, classification, { missing = false } = {}) {
|
|
88
|
+
let canonicalRepoRoot;
|
|
89
|
+
try { canonicalRepoRoot = await realpath(repoRoot); } catch { canonicalRepoRoot = path.resolve(repoRoot); }
|
|
90
|
+
const absolute = path.resolve(canonicalRepoRoot, ref);
|
|
91
|
+
const root = path.resolve(canonicalRepoRoot, STORE_ROOT_REF);
|
|
92
|
+
if (absolute !== root && !absolute.startsWith(`${root}${path.sep}`)) {
|
|
93
|
+
fail(classification, 'path_outside_store', { ref });
|
|
94
|
+
}
|
|
95
|
+
let stats;
|
|
96
|
+
try { stats = await lstat(absolute); } catch (error) {
|
|
97
|
+
if (missing && error?.code === 'ENOENT') return null;
|
|
98
|
+
fail(classification, 'artifact_missing', { ref });
|
|
99
|
+
}
|
|
100
|
+
if (stats.isSymbolicLink() || !stats.isFile()) fail(classification, 'unsafe_artifact_path', { ref });
|
|
101
|
+
const resolved = await realpath(absolute);
|
|
102
|
+
if (resolved !== absolute) fail(classification, 'path_alias_or_escape', { ref });
|
|
103
|
+
return { absolute, stats };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function decodeUtf8(bytes, code, reason) {
|
|
107
|
+
try { return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); }
|
|
108
|
+
catch { fail(code, reason); }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseCanonicalJsonLine(bytes, { code, reason, maxBytes, validate }) {
|
|
112
|
+
if (bytes.length === 0 || bytes.length > maxBytes) fail(code, bytes.length > maxBytes ? 'size_limit_exceeded' : reason);
|
|
113
|
+
const text = decodeUtf8(bytes, code, 'invalid_utf8');
|
|
114
|
+
if (!text.endsWith('\n') || text.includes('\r') || text.startsWith('\uFEFF')
|
|
115
|
+
|| text.slice(0, -1).includes('\n')) fail(code, reason);
|
|
116
|
+
let value;
|
|
117
|
+
try { value = JSON.parse(text.slice(0, -1)); } catch { fail(code, reason); }
|
|
118
|
+
let expected;
|
|
119
|
+
try { expected = `${canonicalizeTodoArtifact(value)}\n`; } catch { fail(code, reason); }
|
|
120
|
+
if (text !== expected) fail(code, 'non_canonical_or_duplicate_key');
|
|
121
|
+
if (!validate(value)) fail(code, 'schema_invalid');
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function readArtifact(repoRoot, ref, { code, maxBytes, validate, missing = false }) {
|
|
126
|
+
const state = await pathState(repoRoot, ref, code, { missing });
|
|
127
|
+
if (state === null) return null;
|
|
128
|
+
const bytes = await readFile(state.absolute);
|
|
129
|
+
return parseCanonicalJsonLine(bytes, { code, reason: 'artifact_truncated_or_trailing_bytes', maxBytes, validate });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function readSnapshotArtifact(repoRoot, ref) {
|
|
133
|
+
const state = await pathState(repoRoot, ref, 'STORE_INCONSISTENT', { missing: true });
|
|
134
|
+
if (state === null) return null;
|
|
135
|
+
const bytes = await readFile(state.absolute);
|
|
136
|
+
return parseCanonicalJsonLine(bytes, {
|
|
137
|
+
code: 'SNAPSHOT_INVALID', reason: 'snapshot_truncated_or_trailing_bytes',
|
|
138
|
+
maxBytes: TODO_LIMITS.snapshotBytes, validate: validateTodoSnapshot,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parseJournalSegment(bytes) {
|
|
143
|
+
if (bytes.length === 0 || bytes.length > TODO_LIMITS.journalSegmentBytes) {
|
|
144
|
+
fail('STORE_CORRUPT', bytes.length > TODO_LIMITS.journalSegmentBytes ? 'journal_segment_limit_exceeded' : 'journal_empty');
|
|
145
|
+
}
|
|
146
|
+
const text = decodeUtf8(bytes, 'STORE_CORRUPT', 'journal_invalid_utf8');
|
|
147
|
+
if (!text.endsWith('\n') || text.includes('\r') || text.startsWith('\uFEFF')) fail('STORE_CORRUPT', 'journal_byte_contract');
|
|
148
|
+
const lines = text.slice(0, -1).split('\n');
|
|
149
|
+
if (lines.some((line) => line.length === 0)) fail('STORE_CORRUPT', 'journal_truncated_or_empty_line');
|
|
150
|
+
return lines.map((line) => {
|
|
151
|
+
let event;
|
|
152
|
+
try { event = JSON.parse(line); } catch { fail('STORE_CORRUPT', 'journal_json_invalid'); }
|
|
153
|
+
let canonical;
|
|
154
|
+
try { canonical = canonicalizeTodoArtifact(event); } catch { fail('STORE_CORRUPT', 'journal_schema_invalid'); }
|
|
155
|
+
if (line !== canonical) fail('STORE_CORRUPT', 'journal_non_canonical_or_duplicate_key');
|
|
156
|
+
if (!validateTodoEvent(event)) fail('STORE_CORRUPT', 'journal_schema_invalid');
|
|
157
|
+
return event;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function readJournal(repoRoot, journalRef) {
|
|
162
|
+
const state = await pathState(repoRoot, journalRef, 'STORE_CORRUPT');
|
|
163
|
+
const directory = path.dirname(state.absolute);
|
|
164
|
+
const sealedDirectory = path.join(directory, 'sealed');
|
|
165
|
+
const segments = [];
|
|
166
|
+
try {
|
|
167
|
+
const sealedStats = await lstat(sealedDirectory);
|
|
168
|
+
if (sealedStats.isSymbolicLink() || !sealedStats.isDirectory()) fail('STORE_CORRUPT', 'unsafe_sealed_directory');
|
|
169
|
+
const names = (await readdir(sealedDirectory)).sort();
|
|
170
|
+
for (const name of names) {
|
|
171
|
+
if (!/^\d{12}-\d{12}-[0-9a-f]{64}-[0-9a-f]{64}\.jsonl$/u.test(name)) {
|
|
172
|
+
fail('STORE_CORRUPT', 'sealed_segment_name_invalid', { name });
|
|
173
|
+
}
|
|
174
|
+
const ref = path.posix.join(path.posix.dirname(journalRef), 'sealed', name);
|
|
175
|
+
const sealed = await pathState(repoRoot, ref, 'STORE_CORRUPT');
|
|
176
|
+
const bytes = await readFile(sealed.absolute);
|
|
177
|
+
const [, startText, endText, previousDigest, segmentDigest] = name.match(
|
|
178
|
+
/^(\d{12})-(\d{12})-([0-9a-f]{64})-([0-9a-f]{64})\.jsonl$/u,
|
|
179
|
+
);
|
|
180
|
+
if (sha256Bytes(bytes) !== segmentDigest) fail('STORE_CORRUPT', 'sealed_segment_digest_mismatch');
|
|
181
|
+
const events = parseJournalSegment(bytes);
|
|
182
|
+
if (events[0].sequence !== Number(startText) || events.at(-1).sequence !== Number(endText)) {
|
|
183
|
+
fail('STORE_CORRUPT', 'sealed_segment_range_mismatch');
|
|
184
|
+
}
|
|
185
|
+
if (previousDigest !== (segments.at(-1)?.events.at(-1).event_digest ?? '0'.repeat(64))) {
|
|
186
|
+
fail('STORE_CORRUPT', 'sealed_segment_link_mismatch');
|
|
187
|
+
}
|
|
188
|
+
segments.push({ ref, bytes, events });
|
|
189
|
+
}
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (error instanceof TodoStoreError) throw error;
|
|
192
|
+
if (error?.code !== 'ENOENT') fail('STORE_CORRUPT', 'sealed_segment_read_failed');
|
|
193
|
+
}
|
|
194
|
+
const activeBytes = await readFile(state.absolute);
|
|
195
|
+
segments.push({ ref: journalRef, bytes: activeBytes, events: parseJournalSegment(activeBytes) });
|
|
196
|
+
const events = segments.flatMap(({ events: entries }) => entries);
|
|
197
|
+
const failures = verifyLinearHashChain({
|
|
198
|
+
entries: events,
|
|
199
|
+
canonicalize: canonicalizeTodoArtifact,
|
|
200
|
+
digestField: 'event_digest',
|
|
201
|
+
genesisPrevious: events[0]?.previous_digest ?? null,
|
|
202
|
+
});
|
|
203
|
+
if (failures.size > 0) fail('STORE_CORRUPT', [...failures].sort()[0], { failed_conditions: [...failures].sort() });
|
|
204
|
+
if (events[0]?.kind !== 'plan_genesis' || events.slice(1).some(({ kind }) => kind === 'plan_genesis')) {
|
|
205
|
+
fail('STORE_CORRUPT', 'genesis_missing_or_repeated');
|
|
206
|
+
}
|
|
207
|
+
const successorSchema = events[0].schema === 'lattice.todo_event.v2';
|
|
208
|
+
const phaseSchema = ['lattice.todo_event.v3', 'lattice.todo_event.v4'].includes(events[0].schema);
|
|
209
|
+
const phaseRevisionSchema = events[0].schema === 'lattice.todo_event.v4';
|
|
210
|
+
const phaseTail = (event) => event.schema === 'lattice.todo_event.v3';
|
|
211
|
+
const legacyTail = (event) => event.schema === 'lattice.todo_event.v1';
|
|
212
|
+
if ((phaseSchema && events.some(({ schema }, index) => index === 0
|
|
213
|
+
? !['lattice.todo_event.v3', 'lattice.todo_event.v4'].includes(schema) : !phaseTail(events[index])))
|
|
214
|
+
|| (!phaseSchema && events.slice(1).some((event) => !legacyTail(event)))
|
|
215
|
+
|| (!phaseSchema && !successorSchema && events.some((event, index) => index === 0
|
|
216
|
+
? event.schema !== 'lattice.todo_event.v1' : !legacyTail(event)))) {
|
|
217
|
+
fail('STORE_CORRUPT', 'journal_schema_sequence_invalid');
|
|
218
|
+
}
|
|
219
|
+
return { segments, events, activeBytes };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function taskState(taskId) {
|
|
223
|
+
return { task_id: taskId, status: 'pending', started_at: null, done_at: null, blocked_reason: null,
|
|
224
|
+
evidence: null, evidence_unverified: false, imported: false };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function emptyPhaseState(phaseId) {
|
|
228
|
+
return { phase_id: phaseId, status: 'locked', review_event_digest: null,
|
|
229
|
+
decision_event_digest: null, decision_evidence: null };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function derivedPhaseStatus(plan, taskStates, phaseStates, phaseId) {
|
|
233
|
+
const state = phaseStates.get(phaseId);
|
|
234
|
+
if (['reviewing', 'accepted', 'rejected'].includes(state.status)) return state.status;
|
|
235
|
+
const phase = plan.phases.find((entry) => entry.phase_id === phaseId);
|
|
236
|
+
if (!phase.predecessor_phase_ids.every((id) => phaseStates.get(id)?.status === 'accepted')) return 'locked';
|
|
237
|
+
const tasks = plan.tasks.filter((entry) => entry.phase_id === phaseId);
|
|
238
|
+
return tasks.every((entry) => taskStates.get(entry.task_id)?.status === 'done') ? 'gate_ready' : 'active';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function projectPhaseStates(plan, events, taskStates) {
|
|
242
|
+
if (!['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(plan.schema)) return [];
|
|
243
|
+
const states = new Map(plan.phases.map(({ phase_id }) => [phase_id, emptyPhaseState(phase_id)]));
|
|
244
|
+
for (const event of events) {
|
|
245
|
+
if (event.schema === 'lattice.todo_event.v4') {
|
|
246
|
+
for (const migration of event.phase_state_migration) {
|
|
247
|
+
if (migration.state_policy === 'carry') {
|
|
248
|
+
Object.assign(states.get(migration.phase_id), structuredClone(migration.state));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
} else if (event.kind === 'phase_review') {
|
|
252
|
+
const state = states.get(event.phase_id);
|
|
253
|
+
state.status = 'reviewing'; state.review_event_digest = event.event_digest;
|
|
254
|
+
state.decision_event_digest = null; state.decision_evidence = null;
|
|
255
|
+
} else if (event.kind === 'phase_accept') {
|
|
256
|
+
const state = states.get(event.phase_id);
|
|
257
|
+
state.status = 'accepted'; state.decision_event_digest = event.event_digest;
|
|
258
|
+
state.decision_evidence = event.payload.decision_evidence;
|
|
259
|
+
} else if (event.kind === 'phase_reject') {
|
|
260
|
+
const state = states.get(event.phase_id);
|
|
261
|
+
state.status = 'rejected'; state.decision_event_digest = event.event_digest;
|
|
262
|
+
state.decision_evidence = event.payload.decision_evidence;
|
|
263
|
+
} else if (event.kind === 'phase_reopen') {
|
|
264
|
+
Object.assign(states.get(event.phase_id), emptyPhaseState(event.phase_id));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
for (const phase of plan.phases) {
|
|
268
|
+
const state = states.get(phase.phase_id);
|
|
269
|
+
state.status = derivedPhaseStatus(plan, taskStates, states, phase.phase_id);
|
|
270
|
+
}
|
|
271
|
+
return [...states.values()].sort((left, right) => left.phase_id.localeCompare(right.phase_id));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function localPredecessors(plan, taskId) {
|
|
275
|
+
const result = [];
|
|
276
|
+
for (const edge of plan.hard_dependencies) {
|
|
277
|
+
if (edge.to.project_id === plan.project_id && edge.to.plan_key === plan.plan_key && edge.to.task_id === taskId
|
|
278
|
+
&& edge.from.project_id === plan.project_id && edge.from.plan_key === plan.plan_key) result.push(edge.from.task_id);
|
|
279
|
+
}
|
|
280
|
+
for (const join of plan.joins) {
|
|
281
|
+
if (join.before.project_id === plan.project_id && join.before.plan_key === plan.plan_key && join.before.task_id === taskId) {
|
|
282
|
+
for (const after of join.after) if (after.project_id === plan.project_id && after.plan_key === plan.plan_key) result.push(after.task_id);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return [...new Set(result)];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function localSuccessors(plan, taskId) {
|
|
289
|
+
return plan.tasks.map(({ task_id }) => task_id).filter((candidate) => localPredecessors(plan, candidate).includes(taskId));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function replay(plan, events, { now = new Date(), verifyEvidence, verifyImportSource } = {}) {
|
|
293
|
+
const states = new Map(plan.tasks.map(({ task_id }) => [task_id, taskState(task_id)]));
|
|
294
|
+
const phaseStates = new Map((plan.phases ?? []).map(({ phase_id }) => [phase_id, emptyPhaseState(phase_id)]));
|
|
295
|
+
const doneDigest = new Map();
|
|
296
|
+
const completion = new Map();
|
|
297
|
+
const importedGenesis = events[0]?.payload.historical_import === true;
|
|
298
|
+
let previousTime = null;
|
|
299
|
+
for (const event of events) {
|
|
300
|
+
if (event.project_id !== plan.project_id || event.plan_key !== plan.plan_key || event.plan_version !== plan.plan_version) {
|
|
301
|
+
fail('STORE_CORRUPT', 'journal_identity_mismatch');
|
|
302
|
+
}
|
|
303
|
+
if (!isStrictTodoTimestamp(event.recorded_at)) fail('STORE_CORRUPT', 'timestamp_invalid');
|
|
304
|
+
const time = Date.parse(event.recorded_at);
|
|
305
|
+
if (previousTime !== null && time < previousTime) fail('STORE_INCONSISTENT', 'clock_reversal');
|
|
306
|
+
if (time > now.valueOf() + MAX_FUTURE_SKEW_MS) fail('STORE_INCONSISTENT', 'future_clock_skew');
|
|
307
|
+
previousTime = time;
|
|
308
|
+
if (event.kind === 'plan_genesis') {
|
|
309
|
+
if (event.payload.plan_digest !== plan.plan_digest || event.payload.topology_digest !== plan.topology_digest
|
|
310
|
+
|| event.payload.predecessor_plan_digest !== plan.predecessor_plan_digest) {
|
|
311
|
+
fail('STORE_INCONSISTENT', 'genesis_plan_binding_mismatch');
|
|
312
|
+
}
|
|
313
|
+
if (['lattice.todo_event.v2', 'lattice.todo_event.v4'].includes(event.schema)) {
|
|
314
|
+
const projected = event.state_migration.map(({ from_task_id, to_task_id }) => ({
|
|
315
|
+
from_task_id, to_task_id,
|
|
316
|
+
}));
|
|
317
|
+
if (canonicalizeTodoArtifact(projected) !== canonicalizeTodoArtifact(event.payload.task_migration)) {
|
|
318
|
+
fail('STORE_INCONSISTENT', 'genesis_migration_projection_mismatch');
|
|
319
|
+
}
|
|
320
|
+
const activeTargets = event.state_migration
|
|
321
|
+
.filter(({ to_task_id }) => to_task_id !== 'removed').map(({ to_task_id }) => to_task_id);
|
|
322
|
+
if (new Set(activeTargets).size !== activeTargets.length
|
|
323
|
+
|| activeTargets.some((taskId) => !states.has(taskId))) {
|
|
324
|
+
fail('STORE_INCONSISTENT', 'genesis_migration_target_invalid');
|
|
325
|
+
}
|
|
326
|
+
for (const migration of event.state_migration) {
|
|
327
|
+
if (!['carry', 'carry_reconciled_metadata'].includes(migration.state_policy)) continue;
|
|
328
|
+
const state = states.get(migration.to_task_id);
|
|
329
|
+
Object.assign(state, structuredClone(migration.state), { evidence_unverified: false });
|
|
330
|
+
if (state.evidence !== null) {
|
|
331
|
+
if (state.imported) {
|
|
332
|
+
if (verifyImportSource) verifyImportSource(state.evidence);
|
|
333
|
+
} else if (verifyEvidence) verifyEvidence(state.evidence);
|
|
334
|
+
}
|
|
335
|
+
if (state.status === 'done') {
|
|
336
|
+
doneDigest.set(migration.to_task_id, event.event_digest);
|
|
337
|
+
completion.set(migration.to_task_id, state.imported
|
|
338
|
+
? { mode: 'historical_import', completed_at: state.done_at ?? 'unknown_requires_evidence' }
|
|
339
|
+
: { mode: 'authored', completed_at: state.done_at });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (event.schema === 'lattice.todo_event.v4') {
|
|
343
|
+
for (const migration of event.phase_state_migration) {
|
|
344
|
+
if (migration.state_policy === 'carry') {
|
|
345
|
+
Object.assign(phaseStates.get(migration.phase_id), structuredClone(migration.state));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (event.kind.startsWith('phase_')) {
|
|
353
|
+
if (!['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(plan.schema) || !phaseStates.has(event.phase_id)) {
|
|
354
|
+
fail('STORE_INCONSISTENT', 'event_phase_missing');
|
|
355
|
+
}
|
|
356
|
+
const state = phaseStates.get(event.phase_id);
|
|
357
|
+
const currentStatus = derivedPhaseStatus(plan, states, phaseStates, event.phase_id);
|
|
358
|
+
if (event.kind === 'phase_review') {
|
|
359
|
+
if (currentStatus !== 'gate_ready') fail('STORE_INCONSISTENT', 'phase_gate_not_ready');
|
|
360
|
+
state.status = 'reviewing'; state.review_event_digest = event.event_digest;
|
|
361
|
+
state.decision_event_digest = null; state.decision_evidence = null;
|
|
362
|
+
} else if (event.kind === 'phase_accept') {
|
|
363
|
+
const phase = plan.phases.find(({ phase_id }) => phase_id === event.phase_id);
|
|
364
|
+
const slots = event.payload.evidence_slots.map(({ slot_id }) => slot_id);
|
|
365
|
+
if (currentStatus !== 'reviewing' || state.review_event_digest !== event.payload.review_event_digest
|
|
366
|
+
|| canonicalizeTodoArtifact(slots) !== canonicalizeTodoArtifact(phase.required_evidence_slots)) {
|
|
367
|
+
fail('STORE_INCONSISTENT', 'phase_accept_binding_invalid');
|
|
368
|
+
}
|
|
369
|
+
if (verifyEvidence) {
|
|
370
|
+
verifyEvidence(event.payload.decision_evidence);
|
|
371
|
+
for (const slot of event.payload.evidence_slots) verifyEvidence(slot.evidence);
|
|
372
|
+
}
|
|
373
|
+
state.status = 'accepted'; state.decision_event_digest = event.event_digest;
|
|
374
|
+
state.decision_evidence = event.payload.decision_evidence;
|
|
375
|
+
} else if (event.kind === 'phase_reject') {
|
|
376
|
+
if (currentStatus !== 'reviewing' || state.review_event_digest !== event.payload.review_event_digest) {
|
|
377
|
+
fail('STORE_INCONSISTENT', 'phase_reject_binding_invalid');
|
|
378
|
+
}
|
|
379
|
+
if (verifyEvidence) verifyEvidence(event.payload.decision_evidence);
|
|
380
|
+
state.status = 'rejected'; state.decision_event_digest = event.event_digest;
|
|
381
|
+
state.decision_evidence = event.payload.decision_evidence;
|
|
382
|
+
} else {
|
|
383
|
+
if (!['accepted', 'rejected'].includes(currentStatus)
|
|
384
|
+
|| state.decision_event_digest !== event.payload.target_decision_digest) {
|
|
385
|
+
fail('STORE_INCONSISTENT', 'phase_reopen_binding_invalid');
|
|
386
|
+
}
|
|
387
|
+
const successorStarted = currentStatus === 'accepted' && (plan.schema === 'lattice.todo_plan.v4'
|
|
388
|
+
? plan.phases.some((phase) => phase.predecessor_phase_ids.includes(event.phase_id)
|
|
389
|
+
&& (phaseStates.get(phase.phase_id).status !== 'locked'
|
|
390
|
+
|| plan.tasks.some((task) => task.phase_id === phase.phase_id
|
|
391
|
+
&& states.get(task.task_id).status !== 'pending')))
|
|
392
|
+
: plan.phases.some((phase) => phase.predecessor_phase_ids.includes(event.phase_id)
|
|
393
|
+
&& phaseStates.get(phase.phase_id).status !== 'locked')
|
|
394
|
+
|| plan.phase_accept_dependencies.some((edge) => edge.from.project_id === plan.project_id
|
|
395
|
+
&& edge.from.plan_key === plan.plan_key && edge.from.phase_id === event.phase_id
|
|
396
|
+
&& edge.to.project_id === plan.project_id && edge.to.plan_key === plan.plan_key
|
|
397
|
+
&& states.get(edge.to.task_id)?.status !== 'pending'));
|
|
398
|
+
if (successorStarted && event.payload.override_reason === null) {
|
|
399
|
+
fail('STORE_INCONSISTENT', 'phase_reopen_has_started_successor');
|
|
400
|
+
}
|
|
401
|
+
Object.assign(state, emptyPhaseState(event.phase_id));
|
|
402
|
+
}
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
const state = states.get(event.task_id);
|
|
406
|
+
if (state === undefined) fail('STORE_INCONSISTENT', 'event_task_missing');
|
|
407
|
+
const dependenciesDone = localPredecessors(plan, event.task_id).every((id) => states.get(id)?.status === 'done');
|
|
408
|
+
if (event.kind === 'start') {
|
|
409
|
+
if (event.payload.start_mode === 'historical_import') {
|
|
410
|
+
if (!importedGenesis || state.status !== 'pending') {
|
|
411
|
+
fail('STORE_INCONSISTENT', 'invalid_historical_import_start_transition');
|
|
412
|
+
}
|
|
413
|
+
if (verifyImportSource) verifyImportSource(event.payload.evidence);
|
|
414
|
+
state.status = 'in-progress';
|
|
415
|
+
state.started_at = event.payload.started_at === 'unknown_requires_evidence'
|
|
416
|
+
? null : event.payload.started_at;
|
|
417
|
+
state.evidence = event.payload.evidence;
|
|
418
|
+
state.imported = true;
|
|
419
|
+
} else {
|
|
420
|
+
const phaseStatus = plan.schema === 'lattice.todo_plan.v4'
|
|
421
|
+
? derivedPhaseStatus(plan, states, phaseStates,
|
|
422
|
+
plan.tasks.find(({ task_id }) => task_id === event.task_id).phase_id) : 'active';
|
|
423
|
+
if (state.status !== 'pending' || phaseStatus !== 'active'
|
|
424
|
+
|| (!dependenciesDone && event.payload.override_reason === null)) {
|
|
425
|
+
fail('STORE_INCONSISTENT', 'invalid_start_transition');
|
|
426
|
+
}
|
|
427
|
+
state.status = 'in-progress'; state.started_at = event.recorded_at;
|
|
428
|
+
}
|
|
429
|
+
} else if (event.kind === 'block') {
|
|
430
|
+
if (state.status !== 'in-progress') fail('STORE_INCONSISTENT', 'invalid_block_transition');
|
|
431
|
+
state.status = 'blocked'; state.blocked_reason = event.payload.reason;
|
|
432
|
+
} else if (event.kind === 'unblock') {
|
|
433
|
+
if (state.status !== 'blocked') fail('STORE_INCONSISTENT', 'invalid_unblock_transition');
|
|
434
|
+
state.status = 'in-progress'; state.blocked_reason = null;
|
|
435
|
+
} else if (event.kind === 'done') {
|
|
436
|
+
if (event.payload.done_mode === 'authored') {
|
|
437
|
+
if (state.status !== 'in-progress' || !dependenciesDone) fail('STORE_INCONSISTENT', 'invalid_done_transition');
|
|
438
|
+
if (verifyEvidence) verifyEvidence(event.payload.evidence);
|
|
439
|
+
state.status = 'done'; state.done_at = event.recorded_at; state.evidence = event.payload.evidence;
|
|
440
|
+
state.imported = false;
|
|
441
|
+
completion.set(event.task_id, { mode: 'authored', completed_at: event.recorded_at });
|
|
442
|
+
} else if (event.payload.done_mode === 'historical_import') {
|
|
443
|
+
if (!importedGenesis || state.status !== 'pending') fail('STORE_INCONSISTENT', 'invalid_historical_import_transition');
|
|
444
|
+
if (verifyImportSource) verifyImportSource(event.payload.evidence);
|
|
445
|
+
state.status = 'done'; state.done_at = event.payload.completed_at === 'unknown_requires_evidence'
|
|
446
|
+
? null : event.payload.completed_at;
|
|
447
|
+
state.evidence = event.payload.evidence; state.imported = true;
|
|
448
|
+
completion.set(event.task_id, { mode: 'historical_import', completed_at: event.payload.completed_at });
|
|
449
|
+
} else {
|
|
450
|
+
const current = completion.get(event.task_id);
|
|
451
|
+
if (state.status !== 'done' || current?.mode !== 'historical_import'
|
|
452
|
+
|| current.completed_at !== 'unknown_requires_evidence'
|
|
453
|
+
|| doneDigest.get(event.task_id) !== event.payload.target_done_digest) {
|
|
454
|
+
fail('STORE_INCONSISTENT', 'invalid_evidence_promotion');
|
|
455
|
+
}
|
|
456
|
+
if (verifyEvidence) verifyEvidence(event.payload.evidence);
|
|
457
|
+
state.evidence = event.payload.evidence;
|
|
458
|
+
completion.set(event.task_id, { mode: 'evidence_promotion', completed_at: current.completed_at });
|
|
459
|
+
}
|
|
460
|
+
doneDigest.set(event.task_id, event.event_digest);
|
|
461
|
+
} else if (event.kind === 'reopen') {
|
|
462
|
+
if (state.status !== 'done' || doneDigest.get(event.task_id) !== event.payload.target_done_digest) {
|
|
463
|
+
fail('STORE_INCONSISTENT', 'invalid_reopen_binding');
|
|
464
|
+
}
|
|
465
|
+
if (['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(plan.schema)) {
|
|
466
|
+
const phaseId = plan.tasks.find(({ task_id }) => task_id === event.task_id).phase_id;
|
|
467
|
+
if (derivedPhaseStatus(plan, states, phaseStates, phaseId) === 'accepted') {
|
|
468
|
+
fail('STORE_INCONSISTENT', 'task_reopen_requires_phase_reopen');
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const startedSuccessor = localSuccessors(plan, event.task_id).some((id) => states.get(id).status !== 'pending');
|
|
472
|
+
if (startedSuccessor && event.payload.override_reason === null) fail('STORE_INCONSISTENT', 'reopen_has_started_successor');
|
|
473
|
+
state.status = 'in-progress'; state.done_at = null; state.evidence = null;
|
|
474
|
+
completion.delete(event.task_id);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return [...states.values()].sort((left, right) => left.task_id < right.task_id ? -1 : left.task_id > right.task_id ? 1 : 0);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function snapshotFor(plan, events, tasks) {
|
|
481
|
+
const head = events.at(-1);
|
|
482
|
+
const phasePlan = ['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(plan.schema);
|
|
483
|
+
const snapshot = {
|
|
484
|
+
schema: phasePlan ? 'lattice.todo_snapshot.v2' : 'lattice.todo_snapshot.v1',
|
|
485
|
+
project_id: plan.project_id, plan_key: plan.plan_key,
|
|
486
|
+
plan_version: plan.plan_version, projection_version: phasePlan ? 2 : 1, through_sequence: head.sequence,
|
|
487
|
+
journal_head_digest: head.event_digest, tasks, snapshot_digest: '',
|
|
488
|
+
...(phasePlan ? { phases: projectPhaseStates(plan, events,
|
|
489
|
+
new Map(tasks.map((task) => [task.task_id, task]))) } : {}),
|
|
490
|
+
};
|
|
491
|
+
snapshot.snapshot_digest = todoSelfDigest(snapshot, 'snapshot_digest');
|
|
492
|
+
return snapshot;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function validateMergedGraph(members) {
|
|
496
|
+
const tasks = new Map();
|
|
497
|
+
for (const member of members) for (const task of member.plan.tasks) {
|
|
498
|
+
tasks.set(`${member.plan.project_id}\0${member.plan.plan_key}\0${task.task_id}`, member.plan.topology_digest);
|
|
499
|
+
}
|
|
500
|
+
const adjacency = new Map([...tasks.keys()].map((key) => [key, []]));
|
|
501
|
+
const phases = new Map();
|
|
502
|
+
for (const member of members) {
|
|
503
|
+
if (member.plan.schema !== 'lattice.todo_plan.v5') continue;
|
|
504
|
+
for (const phase of member.plan.phases) {
|
|
505
|
+
const key = `phase\0${member.plan.project_id}\0${member.plan.plan_key}\0${phase.phase_id}`;
|
|
506
|
+
phases.set(key, member.plan.topology_digest);
|
|
507
|
+
adjacency.set(key, []);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
const bind = (ref, ownerPlan) => {
|
|
511
|
+
const key = `${ref.project_id}\0${ref.plan_key}\0${ref.task_id}`;
|
|
512
|
+
const topology = tasks.get(key);
|
|
513
|
+
if (topology === undefined) fail('STORE_INCONSISTENT', 'dangling_dependency');
|
|
514
|
+
if ((ref.project_id !== ownerPlan.project_id || ref.plan_key !== ownerPlan.plan_key)
|
|
515
|
+
&& ref.expected_topology_digest === undefined) fail('STORE_INCONSISTENT', 'cross_plan_binding_missing');
|
|
516
|
+
if (ref.expected_topology_digest !== undefined && topology !== ref.expected_topology_digest) {
|
|
517
|
+
fail('STORE_INCONSISTENT', 'binding_stale');
|
|
518
|
+
}
|
|
519
|
+
return key;
|
|
520
|
+
};
|
|
521
|
+
const bindPhase = (ref, ownerPlan) => {
|
|
522
|
+
const key = `phase\0${ref.project_id}\0${ref.plan_key}\0${ref.phase_id}`;
|
|
523
|
+
const topology = phases.get(key);
|
|
524
|
+
if (topology === undefined) fail('STORE_INCONSISTENT', 'dangling_phase_dependency');
|
|
525
|
+
if ((ref.project_id !== ownerPlan.project_id || ref.plan_key !== ownerPlan.plan_key)
|
|
526
|
+
&& ref.expected_topology_digest === undefined) fail('STORE_INCONSISTENT', 'cross_plan_binding_missing');
|
|
527
|
+
if (ref.expected_topology_digest !== undefined && topology !== ref.expected_topology_digest) {
|
|
528
|
+
fail('STORE_INCONSISTENT', 'binding_stale');
|
|
529
|
+
}
|
|
530
|
+
return key;
|
|
531
|
+
};
|
|
532
|
+
for (const { plan } of members) {
|
|
533
|
+
for (const edge of plan.hard_dependencies) {
|
|
534
|
+
const from = bind(edge.from, plan); const to = bind(edge.to, plan);
|
|
535
|
+
if (from === to) fail('STORE_INCONSISTENT', 'self_edge');
|
|
536
|
+
adjacency.get(from).push(to);
|
|
537
|
+
}
|
|
538
|
+
for (const join of plan.joins) {
|
|
539
|
+
const before = bind(join.before, plan);
|
|
540
|
+
for (const afterRef of join.after) {
|
|
541
|
+
const after = bind(afterRef, plan);
|
|
542
|
+
if (after === before) fail('STORE_INCONSISTENT', 'join_self_edge');
|
|
543
|
+
adjacency.get(after).push(before);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
if (plan.schema === 'lattice.todo_plan.v5') {
|
|
547
|
+
for (const task of plan.tasks) {
|
|
548
|
+
const taskKey = `${plan.project_id}\0${plan.plan_key}\0${task.task_id}`;
|
|
549
|
+
const phaseKey = `phase\0${plan.project_id}\0${plan.plan_key}\0${task.phase_id}`;
|
|
550
|
+
adjacency.get(taskKey).push(phaseKey);
|
|
551
|
+
}
|
|
552
|
+
for (const phase of plan.phases) {
|
|
553
|
+
const to = `phase\0${plan.project_id}\0${plan.plan_key}\0${phase.phase_id}`;
|
|
554
|
+
for (const predecessor of phase.predecessor_phase_ids) {
|
|
555
|
+
adjacency.get(`phase\0${plan.project_id}\0${plan.plan_key}\0${predecessor}`).push(to);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
for (const edge of plan.phase_accept_dependencies) {
|
|
559
|
+
adjacency.get(bindPhase(edge.from, plan)).push(bind(edge.to, plan));
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
const colors = new Map();
|
|
564
|
+
const visit = (node) => {
|
|
565
|
+
if (colors.get(node) === 1) fail('STORE_INCONSISTENT', 'merged_cycle');
|
|
566
|
+
if (colors.get(node) === 2) return;
|
|
567
|
+
colors.set(node, 1); for (const next of adjacency.get(node)) visit(next); colors.set(node, 2);
|
|
568
|
+
};
|
|
569
|
+
for (const node of adjacency.keys()) visit(node);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function mergedTaskKey(refValue) {
|
|
573
|
+
return `${refValue.project_id}\0${refValue.plan_key}\0${refValue.task_id}`;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function mergedTaskStates(store) {
|
|
577
|
+
return new Map(store.members.flatMap((member) => member.tasks.map((taskValue) => ([
|
|
578
|
+
mergedTaskKey({
|
|
579
|
+
project_id: member.plan.project_id, plan_key: member.plan.plan_key, task_id: taskValue.task_id,
|
|
580
|
+
}),
|
|
581
|
+
taskValue.status,
|
|
582
|
+
]))));
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function mergedTaskPhase(store, taskKey) {
|
|
586
|
+
for (const member of store.members) {
|
|
587
|
+
const task = member.plan.tasks.find((entry) => mergedTaskKey({
|
|
588
|
+
project_id: member.plan.project_id, plan_key: member.plan.plan_key, task_id: entry.task_id,
|
|
589
|
+
}) === taskKey);
|
|
590
|
+
if (task === undefined) continue;
|
|
591
|
+
if (!['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(member.plan.schema)) return null;
|
|
592
|
+
return { schema: member.plan.schema, plan_key: member.plan.plan_key, phase_id: task.phase_id,
|
|
593
|
+
status: member.snapshot.phases.find(({ phase_id }) => phase_id === task.phase_id)?.status };
|
|
594
|
+
}
|
|
595
|
+
return undefined;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function mergedDependencyPhaseReady(store, predecessorKey, targetKey) {
|
|
599
|
+
const predecessor = mergedTaskPhase(store, predecessorKey);
|
|
600
|
+
const target = mergedTaskPhase(store, targetKey);
|
|
601
|
+
if (predecessor === undefined) return false;
|
|
602
|
+
if (predecessor === null) return true;
|
|
603
|
+
if (predecessor.schema === 'lattice.todo_plan.v5') return true;
|
|
604
|
+
if (target !== null && target !== undefined && predecessor.plan_key === target.plan_key
|
|
605
|
+
&& predecessor.phase_id === target.phase_id) return true;
|
|
606
|
+
return predecessor.status === 'accepted';
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function mergedPhaseAcceptReady(store, targetKey) {
|
|
610
|
+
for (const member of store.members) {
|
|
611
|
+
if (member.plan.schema !== 'lattice.todo_plan.v5') continue;
|
|
612
|
+
for (const edge of member.plan.phase_accept_dependencies) {
|
|
613
|
+
if (mergedTaskKey(edge.to) !== targetKey) continue;
|
|
614
|
+
const source = store.members.find((candidate) => candidate.plan.project_id === edge.from.project_id
|
|
615
|
+
&& candidate.plan.plan_key === edge.from.plan_key);
|
|
616
|
+
if (source?.snapshot.phases.find(({ phase_id }) => phase_id === edge.from.phase_id)?.status !== 'accepted') {
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
return true;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function mergedPredecessorKeys(store, targetKey) {
|
|
625
|
+
const result = [];
|
|
626
|
+
for (const member of store.members) {
|
|
627
|
+
for (const edge of member.plan.hard_dependencies) {
|
|
628
|
+
if (mergedTaskKey(edge.to) === targetKey) result.push(mergedTaskKey(edge.from));
|
|
629
|
+
}
|
|
630
|
+
for (const join of member.plan.joins) {
|
|
631
|
+
if (mergedTaskKey(join.before) === targetKey) {
|
|
632
|
+
for (const after of join.after) result.push(mergedTaskKey(after));
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return [...new Set(result)];
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function mergedSuccessorKeys(store, targetKey) {
|
|
640
|
+
const result = [];
|
|
641
|
+
for (const member of store.members) {
|
|
642
|
+
for (const edge of member.plan.hard_dependencies) {
|
|
643
|
+
if (mergedTaskKey(edge.from) === targetKey) result.push(mergedTaskKey(edge.to));
|
|
644
|
+
}
|
|
645
|
+
for (const join of member.plan.joins) {
|
|
646
|
+
if (join.after.some((after) => mergedTaskKey(after) === targetKey)) {
|
|
647
|
+
result.push(mergedTaskKey(join.before));
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return [...new Set(result)];
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function validateMergedTransition(store, member, event) {
|
|
655
|
+
const targetKey = mergedTaskKey({
|
|
656
|
+
project_id: member.plan.project_id, plan_key: member.plan.plan_key, task_id: event.task_id,
|
|
657
|
+
});
|
|
658
|
+
const states = mergedTaskStates(store);
|
|
659
|
+
const taskDependenciesDone = mergedPredecessorKeys(store, targetKey)
|
|
660
|
+
.every((key) => states.get(key) === 'done' && mergedDependencyPhaseReady(store, key, targetKey));
|
|
661
|
+
const phaseAcceptReady = mergedPhaseAcceptReady(store, targetKey);
|
|
662
|
+
const dependenciesDone = taskDependenciesDone && phaseAcceptReady;
|
|
663
|
+
if (event.kind === 'start' && event.payload.start_mode !== 'historical_import'
|
|
664
|
+
&& (!phaseAcceptReady || (!taskDependenciesDone && event.payload.override_reason === null))) {
|
|
665
|
+
fail('STORE_INCONSISTENT', 'invalid_start_transition');
|
|
666
|
+
}
|
|
667
|
+
if (event.kind === 'done' && event.payload.done_mode === 'authored' && !dependenciesDone) {
|
|
668
|
+
fail('STORE_INCONSISTENT', 'invalid_done_transition');
|
|
669
|
+
}
|
|
670
|
+
if (event.kind === 'reopen' && event.payload.override_reason === null) {
|
|
671
|
+
const startedSuccessor = mergedSuccessorKeys(store, targetKey)
|
|
672
|
+
.some((key) => states.get(key) !== 'pending');
|
|
673
|
+
if (startedSuccessor) fail('STORE_INCONSISTENT', 'reopen_has_started_successor');
|
|
674
|
+
}
|
|
675
|
+
if (event.kind === 'phase_reopen' && event.payload.override_reason === null) {
|
|
676
|
+
const startedSuccessor = member.plan.schema === 'lattice.todo_plan.v4'
|
|
677
|
+
? member.plan.tasks.filter(({ phase_id }) => phase_id === event.phase_id)
|
|
678
|
+
.map(({ task_id }) => mergedTaskKey({ project_id: member.plan.project_id,
|
|
679
|
+
plan_key: member.plan.plan_key, task_id }))
|
|
680
|
+
.flatMap((key) => mergedSuccessorKeys(store, key))
|
|
681
|
+
.some((key) => states.get(key) !== 'pending')
|
|
682
|
+
: store.members.some((candidate) => candidate.plan.schema === 'lattice.todo_plan.v5'
|
|
683
|
+
&& candidate.plan.phase_accept_dependencies.some((edge) => (
|
|
684
|
+
edge.from.project_id === member.plan.project_id
|
|
685
|
+
&& edge.from.plan_key === member.plan.plan_key && edge.from.phase_id === event.phase_id
|
|
686
|
+
&& states.get(mergedTaskKey(edge.to)) !== 'pending'
|
|
687
|
+
)));
|
|
688
|
+
if (startedSuccessor) fail('STORE_INCONSISTENT', 'phase_reopen_has_started_successor');
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function evidenceVerifier(manifest, repoRoot, hard) {
|
|
693
|
+
const repositories = new Map(manifest.repositories.map((repo) => [repo.repo_id, repo.path]));
|
|
694
|
+
return (descriptor) => {
|
|
695
|
+
if (!validateEvidenceDescriptor(descriptor)) fail('STORE_INCONSISTENT', 'evidence_descriptor_invalid');
|
|
696
|
+
const repoRef = repositories.get(descriptor.repo_id);
|
|
697
|
+
if (repoRef === undefined) fail('STORE_INCONSISTENT', 'evidence_repo_missing');
|
|
698
|
+
const absoluteRepo = path.resolve(repoRoot, repoRef);
|
|
699
|
+
try {
|
|
700
|
+
const type = execFileSync('git', ['cat-file', '-t', descriptor.git_blob_oid], { cwd: absoluteRepo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
701
|
+
if (type !== 'blob') throw new Error('not blob');
|
|
702
|
+
const bytes = execFileSync('git', ['cat-file', 'blob', descriptor.git_blob_oid], { cwd: absoluteRepo, encoding: 'buffer', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: TODO_LIMITS.narrativeSectionBytes + 1 });
|
|
703
|
+
if (sha256Bytes(bytes) !== descriptor.content_digest) throw new Error('digest mismatch');
|
|
704
|
+
return true;
|
|
705
|
+
} catch {
|
|
706
|
+
if (hard) fail('STORE_INCONSISTENT', 'evidence_unverified');
|
|
707
|
+
return false;
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function pinnedSourceLine(repoRoot, source, cache = null) {
|
|
713
|
+
if (cache === null || !cache.commits.has(source.source_commit)) {
|
|
714
|
+
const commitType = execFileSync('git', ['cat-file', '-t', source.source_commit], {
|
|
715
|
+
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
716
|
+
}).trim();
|
|
717
|
+
if (commitType !== 'commit') throw new Error('not commit');
|
|
718
|
+
cache?.commits.add(source.source_commit);
|
|
719
|
+
}
|
|
720
|
+
const objectSpec = `${source.source_commit}:${source.origin_plan_ref}`;
|
|
721
|
+
let blob = cache?.blobs.get(objectSpec);
|
|
722
|
+
if (blob === undefined) {
|
|
723
|
+
const blobType = execFileSync('git', ['cat-file', '-t', objectSpec], {
|
|
724
|
+
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
725
|
+
}).trim();
|
|
726
|
+
if (blobType !== 'blob') throw new Error('not blob');
|
|
727
|
+
blob = execFileSync('git', ['cat-file', 'blob', objectSpec], {
|
|
728
|
+
cwd: repoRoot, encoding: 'buffer', stdio: ['ignore', 'pipe', 'ignore'],
|
|
729
|
+
maxBuffer: TODO_LIMITS.narrativeSectionBytes + 1,
|
|
730
|
+
});
|
|
731
|
+
cache?.blobs.set(objectSpec, blob);
|
|
732
|
+
}
|
|
733
|
+
let start = 0;
|
|
734
|
+
let line = 1;
|
|
735
|
+
for (let index = 0; index < blob.length; index += 1) {
|
|
736
|
+
if (blob[index] !== 0x0a) continue;
|
|
737
|
+
if (line === source.origin_line) return blob.subarray(start, index);
|
|
738
|
+
start = index + 1;
|
|
739
|
+
line += 1;
|
|
740
|
+
}
|
|
741
|
+
if (start < blob.length && line === source.origin_line) return blob.subarray(start);
|
|
742
|
+
throw new Error('line outside blob');
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function markdownCheckboxState(lineBytes) {
|
|
746
|
+
if (lineBytes.length >= 3 && lineBytes[0] === 0xef && lineBytes[1] === 0xbb && lineBytes[2] === 0xbf) return null;
|
|
747
|
+
let line;
|
|
748
|
+
try { line = new TextDecoder('utf-8', { fatal: true }).decode(lineBytes); } catch { return null; }
|
|
749
|
+
const match = /^[\t ]*(?:[-+*]|\d+[A-Za-z]?\.|\d+\))[\t ]+\[([ xX])\](?:[\t ]+.*?)?\r?$/u.exec(line);
|
|
750
|
+
if (match === null) return null;
|
|
751
|
+
return match[1] === ' ' ? 'unchecked' : 'checked';
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function liveReplacementPreservesListStructure(lineBytes, replacement) {
|
|
755
|
+
let line;
|
|
756
|
+
try { line = new TextDecoder('utf-8', { fatal: true }).decode(lineBytes); } catch { return false; }
|
|
757
|
+
const source = /^([\t ]*)([-+*]|\d+[A-Za-z]?\.|\d+\))[\t ]+\[[ xX]\](?:[\t ]+.*)?\r?$/u.exec(line);
|
|
758
|
+
const target = /^([\t ]*)([-+*]|\d+[A-Za-z]?\.|\d+\))[\t ]+.+$/u.exec(replacement);
|
|
759
|
+
return source !== null && target !== null && source[1] === target[1] && source[2] === target[2];
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function importSourceVerifier(repoRoot, hard, cache = null) {
|
|
763
|
+
return (descriptor) => {
|
|
764
|
+
if (!validateTodoImportSource(descriptor)) fail('STORE_INCONSISTENT', 'import_source_descriptor_invalid');
|
|
765
|
+
try {
|
|
766
|
+
pinnedSourceLine(repoRoot, descriptor, cache);
|
|
767
|
+
return true;
|
|
768
|
+
} catch {
|
|
769
|
+
if (hard) fail('STORE_INCONSISTENT', 'import_source_unverified');
|
|
770
|
+
return false;
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function narrativeAnchorSource(value) {
|
|
776
|
+
return exactRecord(value, [
|
|
777
|
+
'task_id', 'origin_plan_ref', 'origin_line', 'source_commit', 'checkbox_state',
|
|
778
|
+
]) && isTodoIdentifier(value.task_id)
|
|
779
|
+
&& ['checked', 'unchecked', 'absent', 'ambiguous'].includes(value.checkbox_state)
|
|
780
|
+
&& validateTodoImportSource({
|
|
781
|
+
schema: 'lattice.todo_import_source.v1', origin_plan_ref: value.origin_plan_ref,
|
|
782
|
+
origin_line: value.origin_line, source_commit: value.source_commit,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function materializeImportedNarrativeAnchors(repoRoot, planInput, sources) {
|
|
787
|
+
if (sources === undefined) return planInput;
|
|
788
|
+
if (planInput?.schema !== 'lattice.todo_plan.v2' || !Array.isArray(sources)
|
|
789
|
+
|| sources.length > TODO_LIMITS.tasksPerPlan || !sources.every(narrativeAnchorSource)) {
|
|
790
|
+
throw new TypeError('imported narrative anchor sources are invalid');
|
|
791
|
+
}
|
|
792
|
+
const byTask = new Map(sources.map((source) => [source.task_id, source]));
|
|
793
|
+
if (byTask.size !== sources.length
|
|
794
|
+
|| sources.some(({ task_id: taskId }) => !planInput.tasks.some((task) => task.task_id === taskId))) {
|
|
795
|
+
throw new TypeError('imported narrative anchor sources do not match plan tasks');
|
|
796
|
+
}
|
|
797
|
+
return {
|
|
798
|
+
...planInput,
|
|
799
|
+
tasks: planInput.tasks.map((task) => {
|
|
800
|
+
const source = byTask.get(task.task_id);
|
|
801
|
+
let narrativeAnchor = null;
|
|
802
|
+
if (source !== undefined && task.narrative_ref === source.origin_plan_ref
|
|
803
|
+
&& ['checked', 'unchecked'].includes(source.checkbox_state)) {
|
|
804
|
+
try {
|
|
805
|
+
const line = pinnedSourceLine(repoRoot, source);
|
|
806
|
+
if (markdownCheckboxState(line) === source.checkbox_state) {
|
|
807
|
+
narrativeAnchor = {
|
|
808
|
+
origin_plan_ref: source.origin_plan_ref,
|
|
809
|
+
origin_line: source.origin_line,
|
|
810
|
+
source_commit: source.source_commit,
|
|
811
|
+
source_line_digest: sha256Bytes(line),
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
} catch {
|
|
815
|
+
narrativeAnchor = null;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
return { ...task, narrative_anchor: narrativeAnchor };
|
|
819
|
+
}),
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function verifyPlanNarrativeAnchors(repoRoot, plan, trustedPlan = null) {
|
|
824
|
+
if (!['lattice.todo_plan.v2', 'lattice.todo_plan.v3', 'lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(plan.schema)) return;
|
|
825
|
+
const trusted = new Map((['lattice.todo_plan.v2', 'lattice.todo_plan.v3', 'lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(trustedPlan?.schema)
|
|
826
|
+
? trustedPlan.tasks : [])
|
|
827
|
+
.map((task) => [task.task_id, task.narrative_anchor]));
|
|
828
|
+
for (const task of plan.tasks) {
|
|
829
|
+
const anchor = task.narrative_anchor;
|
|
830
|
+
if (anchor === null) continue;
|
|
831
|
+
const previous = trusted.get(task.task_id);
|
|
832
|
+
if (previous !== undefined
|
|
833
|
+
&& canonicalizeTodoArtifact(previous) === canonicalizeTodoArtifact(anchor)) continue;
|
|
834
|
+
try {
|
|
835
|
+
const line = pinnedSourceLine(repoRoot, anchor);
|
|
836
|
+
if (sha256Bytes(line) !== anchor.source_line_digest || markdownCheckboxState(line) === null) {
|
|
837
|
+
throw new Error('anchor mismatch');
|
|
838
|
+
}
|
|
839
|
+
} catch {
|
|
840
|
+
fail('STORE_INCONSISTENT', 'narrative_anchor_unverified', {
|
|
841
|
+
plan_key: plan.plan_key, task_id: task.task_id,
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
export async function readTodoStore(options = {}) {
|
|
848
|
+
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
849
|
+
const barrier = await exactFileOrNull(path.resolve(repoRoot, SOURCE_CUTOVER_BARRIER_REF));
|
|
850
|
+
if (barrier !== null) {
|
|
851
|
+
let value;
|
|
852
|
+
try { value = JSON.parse(decodeUtf8(barrier, 'SOURCE_CUTOVER_RECOVERY_REQUIRED', 'barrier_invalid')); }
|
|
853
|
+
catch (error) {
|
|
854
|
+
if (error instanceof TodoStoreError) throw error;
|
|
855
|
+
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'barrier_invalid');
|
|
856
|
+
}
|
|
857
|
+
const revisionRecovery = value?.schema === 'lattice.todo_source_cutover_barrier.v1'
|
|
858
|
+
&& value.revision_digest === options.allowSourceCutoverRevisionDigest;
|
|
859
|
+
const revisionSetRecovery = value?.schema === 'lattice.todo_source_cutover_set_barrier.v1'
|
|
860
|
+
&& value.revision_set_digest === options.allowSourceCutoverRevisionSetDigest;
|
|
861
|
+
if (options.sourceCutoverRecoveryCapability !== SOURCE_CUTOVER_RECOVERY_CAPABILITY
|
|
862
|
+
|| (!revisionRecovery && !revisionSetRecovery)) {
|
|
863
|
+
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'source_cutover_recovery_required');
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
const pinnedSourceCache = { commits: new Set(), blobs: new Map() };
|
|
867
|
+
const manifest = await readArtifact(repoRoot, MANIFEST_REF, {
|
|
868
|
+
code: 'STORE_INCONSISTENT', maxBytes: TODO_LIMITS.snapshotBytes, validate: validateTodoManifest,
|
|
869
|
+
});
|
|
870
|
+
const loaded = [];
|
|
871
|
+
for (const descriptor of manifest.members) {
|
|
872
|
+
const plan = await readArtifact(repoRoot, descriptor.plan_ref, {
|
|
873
|
+
code: 'STORE_INCONSISTENT', maxBytes: TODO_LIMITS.snapshotBytes, validate: validateTodoPlan,
|
|
874
|
+
});
|
|
875
|
+
if (plan.project_id !== manifest.project_id || plan.plan_key !== descriptor.plan_key
|
|
876
|
+
|| plan.plan_version !== descriptor.active_plan_version || plan.topology_digest !== descriptor.topology_digest) {
|
|
877
|
+
fail('STORE_INCONSISTENT', 'manifest_plan_binding_mismatch');
|
|
878
|
+
}
|
|
879
|
+
const journal = await readJournal(repoRoot, descriptor.journal_ref);
|
|
880
|
+
if (journal.events.at(-1).event_digest !== descriptor.journal_head_digest) {
|
|
881
|
+
fail('STORE_INCONSISTENT', 'manifest_journal_head_mismatch');
|
|
882
|
+
}
|
|
883
|
+
let revision = null;
|
|
884
|
+
const genesis = journal.events[0];
|
|
885
|
+
if (['lattice.todo_event.v2', 'lattice.todo_event.v4'].includes(genesis.schema)) {
|
|
886
|
+
const revisionRef = path.posix.join(path.posix.dirname(descriptor.plan_ref), 'revision.json');
|
|
887
|
+
const phaseRevision = genesis.schema === 'lattice.todo_event.v4';
|
|
888
|
+
revision = await readArtifact(repoRoot, revisionRef, {
|
|
889
|
+
code: 'STORE_INCONSISTENT', maxBytes: TODO_LIMITS.snapshotBytes,
|
|
890
|
+
validate: (value) => {
|
|
891
|
+
rejectUnsupportedRevisionSchema(value, phaseRevision ? {
|
|
892
|
+
family: 'phase_todo_revision', supportedSchemas: PHASE_TODO_REVISION_SCHEMAS,
|
|
893
|
+
} : {
|
|
894
|
+
family: 'todo_revision', supportedSchemas: TODO_REVISION_SCHEMAS,
|
|
895
|
+
});
|
|
896
|
+
return phaseRevision ? validatePhaseTodoRevision(value) : validateTodoRevision(value);
|
|
897
|
+
},
|
|
898
|
+
});
|
|
899
|
+
const migrationProjection = genesis.state_migration.map((entry) => ({
|
|
900
|
+
from_task_id: entry.from_task_id, to_task_id: entry.to_task_id,
|
|
901
|
+
state_policy: entry.state_policy,
|
|
902
|
+
}));
|
|
903
|
+
if (canonicalizeTodoArtifact(revision.desired_plan) !== canonicalizeTodoArtifact(plan)
|
|
904
|
+
|| revision.revision_digest !== genesis.revision_digest
|
|
905
|
+
|| revision.predecessor.plan_digest !== genesis.payload.predecessor_plan_digest
|
|
906
|
+
|| revision.predecessor.journal_head_digest !== genesis.previous_digest
|
|
907
|
+
|| canonicalizeTodoArtifact(revision.task_migration)
|
|
908
|
+
!== canonicalizeTodoArtifact(migrationProjection)) {
|
|
909
|
+
fail('STORE_INCONSISTENT', 'revision_genesis_binding_mismatch');
|
|
910
|
+
}
|
|
911
|
+
if (manifest.schema === 'lattice.todo_manifest.v2'
|
|
912
|
+
&& descriptor.active_revision_digest !== genesis.revision_digest) {
|
|
913
|
+
fail('STORE_INCONSISTENT', 'manifest_revision_binding_mismatch');
|
|
914
|
+
}
|
|
915
|
+
if (genesis.schema === 'lattice.todo_event.v2'
|
|
916
|
+
&& revision.reconciliation.reconciliation_digest !== genesis.reconciliation_digest) {
|
|
917
|
+
fail('STORE_INCONSISTENT', 'revision_genesis_binding_mismatch');
|
|
918
|
+
}
|
|
919
|
+
if (genesis.schema === 'lattice.todo_event.v4') {
|
|
920
|
+
const phaseProjection = revision.phase_migration
|
|
921
|
+
.filter(({ to_phase_id }) => to_phase_id !== 'removed')
|
|
922
|
+
.map(({ to_phase_id, state_policy }) => ({ phase_id: to_phase_id, state_policy }))
|
|
923
|
+
.sort((left, right) => left.phase_id.localeCompare(right.phase_id));
|
|
924
|
+
const genesisProjection = genesis.phase_state_migration
|
|
925
|
+
.map(({ phase_id, state_policy }) => ({ phase_id, state_policy }));
|
|
926
|
+
if (canonicalizeTodoArtifact(phaseProjection) !== canonicalizeTodoArtifact(genesisProjection)) {
|
|
927
|
+
fail('STORE_INCONSISTENT', 'revision_genesis_binding_mismatch');
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
if (manifest.schema === 'lattice.todo_manifest.v2' && revision === null
|
|
932
|
+
&& descriptor.active_revision_digest !== plan.plan_digest) {
|
|
933
|
+
fail('STORE_INCONSISTENT', 'manifest_revision_binding_mismatch');
|
|
934
|
+
}
|
|
935
|
+
const verifyEvidence = evidenceVerifier(manifest, repoRoot, options.forWrite === true);
|
|
936
|
+
const verifyImportSource = importSourceVerifier(repoRoot, options.forWrite === true, pinnedSourceCache);
|
|
937
|
+
const tasks = replay(plan, journal.events, {
|
|
938
|
+
now: options.now ? new Date(options.now) : new Date(),
|
|
939
|
+
verifyEvidence: options.forWrite === true ? verifyEvidence : undefined,
|
|
940
|
+
verifyImportSource: options.forWrite === true ? verifyImportSource : undefined,
|
|
941
|
+
});
|
|
942
|
+
const expectedSnapshot = snapshotFor(plan, journal.events, structuredClone(tasks));
|
|
943
|
+
// Read-time evidence failure is annotation, never store rejection.
|
|
944
|
+
for (const task of tasks) if (task.evidence !== null) {
|
|
945
|
+
const verified = validateTodoImportSource(task.evidence)
|
|
946
|
+
? verifyImportSource(task.evidence) : verifyEvidence(task.evidence);
|
|
947
|
+
if (!verified) task.evidence_unverified = true;
|
|
948
|
+
}
|
|
949
|
+
let snapshot = null;
|
|
950
|
+
let snapshotStale = false;
|
|
951
|
+
try {
|
|
952
|
+
snapshot = await readSnapshotArtifact(repoRoot, descriptor.snapshot_ref);
|
|
953
|
+
snapshotStale = snapshot === null || canonicalizeTodoArtifact(snapshot) !== canonicalizeTodoArtifact(expectedSnapshot);
|
|
954
|
+
} catch (error) {
|
|
955
|
+
if (error instanceof TodoStoreError && error.code === 'SNAPSHOT_INVALID'
|
|
956
|
+
&& !['unsafe_artifact_path', 'path_alias_or_escape', 'path_outside_store'].includes(error.detail.reason)) snapshotStale = true;
|
|
957
|
+
else throw error;
|
|
958
|
+
}
|
|
959
|
+
if (options.forWrite === true && snapshotStale) fail('STORE_WRITE_REFUSED', 'snapshot_stale');
|
|
960
|
+
loaded.push({ descriptor, plan, revision, journal, snapshot: snapshotStale ? expectedSnapshot : snapshot,
|
|
961
|
+
tasks, snapshot_stale: snapshotStale });
|
|
962
|
+
}
|
|
963
|
+
validateMergedGraph(loaded);
|
|
964
|
+
return {
|
|
965
|
+
schema: 'lattice.todo_store_read.v1', project_id: manifest.project_id, manifest,
|
|
966
|
+
members: loaded, snapshot_stale: loaded.some((member) => member.snapshot_stale),
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
export async function readTodoStoreStable(options = {}) {
|
|
971
|
+
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
972
|
+
const maximumAttempts = options.maximumAttempts ?? 4;
|
|
973
|
+
if (!Number.isSafeInteger(maximumAttempts) || maximumAttempts < 1 || maximumAttempts > 16) {
|
|
974
|
+
throw new TypeError('maximumAttempts must be 1..16');
|
|
975
|
+
}
|
|
976
|
+
for (let attempt = 1; attempt <= maximumAttempts; attempt += 1) {
|
|
977
|
+
const before = await readArtifact(repoRoot, MANIFEST_REF, {
|
|
978
|
+
code: 'STORE_INCONSISTENT', maxBytes: TODO_LIMITS.snapshotBytes, validate: validateTodoManifest,
|
|
979
|
+
});
|
|
980
|
+
try {
|
|
981
|
+
const store = await readTodoStore(options);
|
|
982
|
+
const after = await readArtifact(repoRoot, MANIFEST_REF, {
|
|
983
|
+
code: 'STORE_INCONSISTENT', maxBytes: TODO_LIMITS.snapshotBytes, validate: validateTodoManifest,
|
|
984
|
+
});
|
|
985
|
+
if (before.manifest_digest === after.manifest_digest
|
|
986
|
+
&& store.manifest.manifest_digest === after.manifest_digest) return store;
|
|
987
|
+
} catch (error) {
|
|
988
|
+
if (!(error instanceof TodoStoreError)) throw error;
|
|
989
|
+
const after = await readArtifact(repoRoot, MANIFEST_REF, {
|
|
990
|
+
code: 'STORE_INCONSISTENT', maxBytes: TODO_LIMITS.snapshotBytes, validate: validateTodoManifest,
|
|
991
|
+
});
|
|
992
|
+
const transientWriteWindow = error.code === 'STORE_INCONSISTENT'
|
|
993
|
+
&& ['manifest_journal_head_mismatch', 'manifest_plan_binding_mismatch']
|
|
994
|
+
.includes(error.detail.reason);
|
|
995
|
+
if (before.manifest_digest === after.manifest_digest && !transientWriteWindow) throw error;
|
|
996
|
+
}
|
|
997
|
+
if (attempt < maximumAttempts) {
|
|
998
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(16, 2 ** attempt)));
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
fail('STORE_BUSY', 'stable_read_exhausted', { attempts: maximumAttempts });
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
async function atomicWrite(absolute, bytes) {
|
|
1005
|
+
await mkdir(path.dirname(absolute), { recursive: true });
|
|
1006
|
+
const temporary = path.join(path.dirname(absolute), `.${path.basename(absolute)}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
|
|
1007
|
+
let handle;
|
|
1008
|
+
try {
|
|
1009
|
+
handle = await open(temporary, 'wx', 0o600);
|
|
1010
|
+
await handle.writeFile(bytes); await handle.sync(); await handle.close(); handle = null;
|
|
1011
|
+
await rename(temporary, absolute);
|
|
1012
|
+
const directory = await open(path.dirname(absolute), 'r');
|
|
1013
|
+
try { await directory.sync(); } finally { await directory.close(); }
|
|
1014
|
+
} finally {
|
|
1015
|
+
if (handle) await handle.close();
|
|
1016
|
+
await rm(temporary, { force: true });
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
async function atomicWriteMode(absolute, bytes, mode) {
|
|
1021
|
+
await mkdir(path.dirname(absolute), { recursive: true });
|
|
1022
|
+
const temporary = path.join(path.dirname(absolute),
|
|
1023
|
+
`.${path.basename(absolute)}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
|
|
1024
|
+
let handle;
|
|
1025
|
+
try {
|
|
1026
|
+
handle = await open(temporary, 'wx', mode);
|
|
1027
|
+
await handle.writeFile(bytes); await handle.chmod(mode);
|
|
1028
|
+
await handle.sync(); await handle.close(); handle = null;
|
|
1029
|
+
await rename(temporary, absolute);
|
|
1030
|
+
await fsyncDirectory(path.dirname(absolute));
|
|
1031
|
+
} finally {
|
|
1032
|
+
if (handle) await handle.close();
|
|
1033
|
+
await rm(temporary, { force: true });
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
async function fsyncDirectory(absolute) {
|
|
1038
|
+
const directory = await open(absolute, 'r');
|
|
1039
|
+
try { await directory.sync(); } finally { await directory.close(); }
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
async function withLock(repoRoot, callback) {
|
|
1043
|
+
const root = path.join(repoRoot, STORE_ROOT_REF);
|
|
1044
|
+
await mkdir(root, { recursive: true });
|
|
1045
|
+
const lockRef = path.join(root, '.write.lock');
|
|
1046
|
+
let handle;
|
|
1047
|
+
try { handle = await open(lockRef, 'wx', 0o600); }
|
|
1048
|
+
catch (error) { if (error?.code === 'EEXIST') fail('STORE_WRITE_CONFLICT', 'store_locked'); throw error; }
|
|
1049
|
+
try { return await callback(); }
|
|
1050
|
+
finally { await handle.close(); await rm(lockRef, { force: true }); }
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
export async function rebuildTodoSnapshot(options = {}) {
|
|
1054
|
+
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
1055
|
+
return withLock(repoRoot, async () => {
|
|
1056
|
+
const store = await readTodoStore({ repoRoot, now: options.now });
|
|
1057
|
+
const member = store.members.find(({ descriptor }) => descriptor.plan_key === options.planKey);
|
|
1058
|
+
if (!member) fail('STORE_INCONSISTENT', 'plan_not_active');
|
|
1059
|
+
await atomicWrite(path.resolve(repoRoot, member.descriptor.snapshot_ref), canonicalLine(member.snapshot));
|
|
1060
|
+
return member.snapshot;
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function nextEvent(input, storeMember) {
|
|
1065
|
+
const previous = storeMember.journal.events.at(-1);
|
|
1066
|
+
const payload = input.kind === 'done' && exactRecord(input.payload, ['evidence'])
|
|
1067
|
+
? { done_mode: 'authored', imported: false, evidence: input.payload.evidence }
|
|
1068
|
+
: input.payload;
|
|
1069
|
+
const event = {
|
|
1070
|
+
schema: ['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(storeMember.plan.schema)
|
|
1071
|
+
? 'lattice.todo_event.v3' : 'lattice.todo_event.v1', project_id: storeMember.plan.project_id,
|
|
1072
|
+
plan_key: storeMember.plan.plan_key, plan_version: storeMember.plan.plan_version,
|
|
1073
|
+
sequence: previous.sequence + 1, previous_digest: previous.event_digest,
|
|
1074
|
+
kind: input.kind, task_id: input.task_id ?? null,
|
|
1075
|
+
...(['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(storeMember.plan.schema)
|
|
1076
|
+
? { phase_id: input.phase_id ?? null } : {}),
|
|
1077
|
+
actor: input.actor, recorded_at: input.recorded_at,
|
|
1078
|
+
provenance: input.provenance ?? null, payload, event_digest: '',
|
|
1079
|
+
};
|
|
1080
|
+
event.event_digest = todoSelfDigest(event, 'event_digest');
|
|
1081
|
+
if (!validateTodoEvent(event)) throw new TypeError('todo event input violates its declared schema');
|
|
1082
|
+
return event;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function resolveTargetedEvent(input, storeMember) {
|
|
1086
|
+
if (input.kind === 'reopen' && exactRecord(input.payload, [
|
|
1087
|
+
'reason', 'override_reason',
|
|
1088
|
+
])) {
|
|
1089
|
+
const target = [...storeMember.journal.events].reverse().find((event) => (
|
|
1090
|
+
event.kind === 'done' && event.task_id === input.task_id
|
|
1091
|
+
));
|
|
1092
|
+
if (target === undefined) fail('STORE_INCONSISTENT', 'invalid_reopen_binding');
|
|
1093
|
+
return {
|
|
1094
|
+
...input,
|
|
1095
|
+
payload: { ...input.payload, target_done_digest: target.event_digest },
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
if (input.kind === 'done' && exactRecord(input.payload, [
|
|
1099
|
+
'done_mode', 'imported', 'evidence',
|
|
1100
|
+
]) && input.payload.done_mode === 'evidence_promotion' && input.payload.imported === true) {
|
|
1101
|
+
const target = [...storeMember.journal.events].reverse().find((event) => (
|
|
1102
|
+
event.kind === 'done' && event.task_id === input.task_id
|
|
1103
|
+
));
|
|
1104
|
+
if (target === undefined) fail('STORE_INCONSISTENT', 'invalid_evidence_promotion');
|
|
1105
|
+
return {
|
|
1106
|
+
...input,
|
|
1107
|
+
payload: { ...input.payload, target_done_digest: target.event_digest },
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
if (input.kind === 'phase_reopen' && exactRecord(input.payload, ['reason', 'override_reason'])) {
|
|
1111
|
+
const target = [...storeMember.journal.events].reverse().find((event) => (
|
|
1112
|
+
['phase_accept', 'phase_reject'].includes(event.kind) && event.phase_id === input.phase_id
|
|
1113
|
+
));
|
|
1114
|
+
if (target === undefined) fail('STORE_INCONSISTENT', 'phase_reopen_binding_invalid');
|
|
1115
|
+
return { ...input, payload: { ...input.payload, target_decision_digest: target.event_digest } };
|
|
1116
|
+
}
|
|
1117
|
+
return input;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
export async function appendTodoEvent(options = {}) {
|
|
1121
|
+
requireWriter(options.writer, 'g5-authoring');
|
|
1122
|
+
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
1123
|
+
return withLock(repoRoot, async () => {
|
|
1124
|
+
const store = await readTodoStore({ repoRoot, forWrite: true, now: options.now });
|
|
1125
|
+
const member = store.members.find(({ descriptor }) => descriptor.plan_key === options.planKey);
|
|
1126
|
+
if (!member) fail('STORE_INCONSISTENT', 'plan_not_active');
|
|
1127
|
+
const input = resolveTargetedEvent({
|
|
1128
|
+
...options.event,
|
|
1129
|
+
recorded_at: options.event.recorded_at ?? new Date().toISOString(),
|
|
1130
|
+
}, member);
|
|
1131
|
+
const event = nextEvent(input, member);
|
|
1132
|
+
if ((event.kind === 'start' && event.payload.start_mode === 'historical_import')
|
|
1133
|
+
|| (event.kind === 'done' && event.payload.done_mode === 'historical_import')) {
|
|
1134
|
+
fail('STORE_WRITE_CONFLICT', 'historical_import_writer_required');
|
|
1135
|
+
}
|
|
1136
|
+
// Validate the prospective history, including hard evidence and transitions, before any write.
|
|
1137
|
+
validateMergedTransition(store, member, event);
|
|
1138
|
+
replay(member.plan, [...member.journal.events, event], {
|
|
1139
|
+
now: options.now ? new Date(options.now) : new Date(),
|
|
1140
|
+
verifyEvidence: evidenceVerifier(store.manifest, repoRoot, true),
|
|
1141
|
+
verifyImportSource: importSourceVerifier(repoRoot, true),
|
|
1142
|
+
});
|
|
1143
|
+
const eventBytes = canonicalLine(event);
|
|
1144
|
+
const activeRef = member.descriptor.journal_ref;
|
|
1145
|
+
const activeAbsolute = path.resolve(repoRoot, activeRef);
|
|
1146
|
+
if (member.journal.activeBytes.length + eventBytes.length > TODO_LIMITS.journalSegmentBytes) {
|
|
1147
|
+
const activeEvents = member.journal.segments.at(-1).events;
|
|
1148
|
+
const previousSegmentDigest = member.journal.segments.length > 1
|
|
1149
|
+
? member.journal.segments.at(-2).events.at(-1).event_digest : '0'.repeat(64);
|
|
1150
|
+
const segmentDigest = sha256Bytes(member.journal.activeBytes);
|
|
1151
|
+
const name = `${String(activeEvents[0].sequence).padStart(12, '0')}-${String(activeEvents.at(-1).sequence).padStart(12, '0')}-${previousSegmentDigest}-${segmentDigest}.jsonl`;
|
|
1152
|
+
await atomicWrite(path.join(path.dirname(activeAbsolute), 'sealed', name), member.journal.activeBytes);
|
|
1153
|
+
await atomicWrite(activeAbsolute, eventBytes);
|
|
1154
|
+
} else {
|
|
1155
|
+
await atomicWrite(activeAbsolute, Buffer.concat([member.journal.activeBytes, eventBytes]));
|
|
1156
|
+
}
|
|
1157
|
+
const tasks = replay(member.plan, [...member.journal.events, event], {
|
|
1158
|
+
now: options.now ? new Date(options.now) : new Date(),
|
|
1159
|
+
verifyEvidence: evidenceVerifier(store.manifest, repoRoot, false),
|
|
1160
|
+
verifyImportSource: importSourceVerifier(repoRoot, false),
|
|
1161
|
+
});
|
|
1162
|
+
const snapshot = snapshotFor(member.plan, [...member.journal.events, event], tasks);
|
|
1163
|
+
await atomicWrite(path.resolve(repoRoot, member.descriptor.snapshot_ref), canonicalLine(snapshot));
|
|
1164
|
+
member.descriptor.journal_head_digest = event.event_digest;
|
|
1165
|
+
store.manifest.manifest_digest = todoSelfDigest(store.manifest, 'manifest_digest');
|
|
1166
|
+
await atomicWrite(path.resolve(repoRoot, MANIFEST_REF), canonicalLine(store.manifest));
|
|
1167
|
+
return { event, snapshot };
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
export function buildTodoPlan(input) {
|
|
1172
|
+
const plan = { ...input, topology_digest: '', plan_digest: '' };
|
|
1173
|
+
plan.topology_digest = digestTodoArtifact({
|
|
1174
|
+
project_id: plan.project_id, plan_key: plan.plan_key, plan_version: plan.plan_version,
|
|
1175
|
+
tasks: plan.tasks, hard_dependencies: plan.hard_dependencies, joins: plan.joins,
|
|
1176
|
+
...(['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(plan.schema) ? { phases: plan.phases } : {}),
|
|
1177
|
+
...(plan.schema === 'lattice.todo_plan.v5'
|
|
1178
|
+
? { phase_accept_dependencies: plan.phase_accept_dependencies } : {}),
|
|
1179
|
+
});
|
|
1180
|
+
plan.plan_digest = todoSelfDigest(plan, 'plan_digest');
|
|
1181
|
+
if (!validateTodoPlan(plan)) throw new TypeError('todo plan input violates its declared schema');
|
|
1182
|
+
return plan;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
export function buildPlanGenesis(plan, input) {
|
|
1186
|
+
const event = {
|
|
1187
|
+
schema: ['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(plan.schema)
|
|
1188
|
+
? 'lattice.todo_event.v3' : 'lattice.todo_event.v1',
|
|
1189
|
+
project_id: plan.project_id, plan_key: plan.plan_key,
|
|
1190
|
+
plan_version: plan.plan_version, sequence: 0, previous_digest: input.previous_digest ?? null,
|
|
1191
|
+
kind: 'plan_genesis', task_id: null,
|
|
1192
|
+
...(['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(plan.schema) ? { phase_id: null } : {}),
|
|
1193
|
+
actor: input.actor, recorded_at: input.recorded_at,
|
|
1194
|
+
provenance: input.provenance ?? null,
|
|
1195
|
+
payload: { plan_digest: plan.plan_digest, topology_digest: plan.topology_digest,
|
|
1196
|
+
predecessor_plan_digest: plan.predecessor_plan_digest, task_migration: input.task_migration ?? [],
|
|
1197
|
+
...(input.historical_import === true ? { historical_import: true } : {}) },
|
|
1198
|
+
event_digest: '',
|
|
1199
|
+
};
|
|
1200
|
+
event.event_digest = todoSelfDigest(event, 'event_digest');
|
|
1201
|
+
if (!validateTodoEvent(event)) throw new TypeError('genesis input violates its declared schema');
|
|
1202
|
+
return event;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
export function buildRevisionGenesis(plan, input) {
|
|
1206
|
+
const event = {
|
|
1207
|
+
schema: 'lattice.todo_event.v2', project_id: plan.project_id, plan_key: plan.plan_key,
|
|
1208
|
+
plan_version: plan.plan_version, sequence: 0, previous_digest: input.previous_digest,
|
|
1209
|
+
kind: 'plan_genesis', task_id: null, actor: input.actor, recorded_at: input.recorded_at,
|
|
1210
|
+
provenance: input.provenance ?? null,
|
|
1211
|
+
payload: { plan_digest: plan.plan_digest, topology_digest: plan.topology_digest,
|
|
1212
|
+
predecessor_plan_digest: plan.predecessor_plan_digest,
|
|
1213
|
+
task_migration: input.state_migration.map(({ from_task_id, to_task_id }) => ({
|
|
1214
|
+
from_task_id, to_task_id,
|
|
1215
|
+
})) },
|
|
1216
|
+
reconciliation_state: 'reconciled', revision_digest: input.revision_digest,
|
|
1217
|
+
reconciliation_digest: input.reconciliation_digest,
|
|
1218
|
+
state_migration: input.state_migration, event_digest: '',
|
|
1219
|
+
};
|
|
1220
|
+
event.event_digest = todoSelfDigest(event, 'event_digest');
|
|
1221
|
+
if (!validateTodoEvent(event)) throw new TypeError('revision genesis violates lattice.todo_event.v2');
|
|
1222
|
+
return event;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
export function buildPhaseRevisionGenesis(plan, input) {
|
|
1226
|
+
const event = {
|
|
1227
|
+
schema: 'lattice.todo_event.v4', project_id: plan.project_id, plan_key: plan.plan_key,
|
|
1228
|
+
plan_version: plan.plan_version, sequence: 0, previous_digest: input.previous_digest,
|
|
1229
|
+
kind: 'plan_genesis', task_id: null, phase_id: null, actor: input.actor,
|
|
1230
|
+
recorded_at: input.recorded_at, provenance: input.provenance ?? null,
|
|
1231
|
+
payload: { plan_digest: plan.plan_digest, topology_digest: plan.topology_digest,
|
|
1232
|
+
predecessor_plan_digest: plan.predecessor_plan_digest,
|
|
1233
|
+
task_migration: input.state_migration.map(({ from_task_id, to_task_id }) => ({
|
|
1234
|
+
from_task_id, to_task_id,
|
|
1235
|
+
})) },
|
|
1236
|
+
revision_digest: input.revision_digest, state_migration: input.state_migration,
|
|
1237
|
+
phase_state_migration: input.phase_state_migration, event_digest: '',
|
|
1238
|
+
};
|
|
1239
|
+
event.event_digest = todoSelfDigest(event, 'event_digest');
|
|
1240
|
+
if (!validateTodoEvent(event)) throw new TypeError('phase revision genesis violates lattice.todo_event.v4');
|
|
1241
|
+
return event;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
function buildHistoricalDone(plan, previous, input, genesis) {
|
|
1245
|
+
const event = {
|
|
1246
|
+
schema: 'lattice.todo_event.v1', project_id: plan.project_id, plan_key: plan.plan_key,
|
|
1247
|
+
plan_version: plan.plan_version, sequence: previous.sequence + 1, previous_digest: previous.event_digest,
|
|
1248
|
+
kind: 'done', task_id: input.task_id, actor: input.actor ?? genesis.actor,
|
|
1249
|
+
recorded_at: input.recorded_at ?? genesis.recorded_at, provenance: input.provenance ?? null,
|
|
1250
|
+
payload: { done_mode: 'historical_import', imported: true, status: 'done',
|
|
1251
|
+
completed_at: input.completed_at, evidence: input.evidence }, event_digest: '',
|
|
1252
|
+
};
|
|
1253
|
+
event.event_digest = todoSelfDigest(event, 'event_digest');
|
|
1254
|
+
if (!validateTodoEvent(event)) throw new TypeError('historical done input violates lattice.todo_event.v1');
|
|
1255
|
+
return event;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
function buildHistoricalStart(plan, previous, input, genesis) {
|
|
1259
|
+
const event = {
|
|
1260
|
+
schema: 'lattice.todo_event.v1', project_id: plan.project_id, plan_key: plan.plan_key,
|
|
1261
|
+
plan_version: plan.plan_version, sequence: previous.sequence + 1, previous_digest: previous.event_digest,
|
|
1262
|
+
kind: 'start', task_id: input.task_id, actor: input.actor ?? genesis.actor,
|
|
1263
|
+
recorded_at: input.recorded_at ?? genesis.recorded_at, provenance: input.provenance ?? null,
|
|
1264
|
+
payload: { start_mode: 'historical_import', imported: true, status: 'in-progress',
|
|
1265
|
+
started_at: input.started_at, evidence: input.evidence }, event_digest: '',
|
|
1266
|
+
};
|
|
1267
|
+
event.event_digest = todoSelfDigest(event, 'event_digest');
|
|
1268
|
+
if (!validateTodoEvent(event)) throw new TypeError('historical start input violates lattice.todo_event.v1');
|
|
1269
|
+
return event;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
function historicalImportInputs(value, timestampKey) {
|
|
1273
|
+
const required = ['task_id', timestampKey, 'evidence'];
|
|
1274
|
+
const allowed = new Set([...required, 'actor', 'recorded_at', 'provenance']);
|
|
1275
|
+
return Array.isArray(value) && value.length <= TODO_LIMITS.tasksPerPlan
|
|
1276
|
+
&& value.every((entry) => entry !== null && typeof entry === 'object' && !Array.isArray(entry)
|
|
1277
|
+
&& Object.getPrototypeOf(entry) === Object.prototype
|
|
1278
|
+
&& required.every((key) => Object.hasOwn(entry, key))
|
|
1279
|
+
&& Object.keys(entry).every((key) => allowed.has(key)));
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
function prepareImportedArtifacts(repoRoot, options, projectId, memberPlans) {
|
|
1283
|
+
const planInput = materializeImportedNarrativeAnchors(
|
|
1284
|
+
repoRoot, options.plan, options.narrativeAnchorSources,
|
|
1285
|
+
);
|
|
1286
|
+
const plan = buildTodoPlan(planInput);
|
|
1287
|
+
if (plan.project_id !== projectId || plan.predecessor_plan_digest !== null) {
|
|
1288
|
+
throw new TypeError('imported plan must be a project-local genesis plan');
|
|
1289
|
+
}
|
|
1290
|
+
verifyPlanNarrativeAnchors(repoRoot, plan);
|
|
1291
|
+
const genesis = buildPlanGenesis(plan, { ...options.genesis, historical_import: true });
|
|
1292
|
+
const events = [genesis];
|
|
1293
|
+
const inProgressTasks = options.inProgressTasks ?? [];
|
|
1294
|
+
const completedTasks = options.completedTasks ?? [];
|
|
1295
|
+
if (!historicalImportInputs(inProgressTasks, 'started_at')
|
|
1296
|
+
|| !historicalImportInputs(completedTasks, 'completed_at')) {
|
|
1297
|
+
fail('STORE_INCONSISTENT', 'historical_import_disposition_invalid');
|
|
1298
|
+
}
|
|
1299
|
+
const inProgressIds = new Set(inProgressTasks.map(({ task_id: taskId }) => taskId));
|
|
1300
|
+
const completedIds = new Set(completedTasks.map(({ task_id: taskId }) => taskId));
|
|
1301
|
+
if (inProgressIds.size !== inProgressTasks.length
|
|
1302
|
+
|| completedIds.size !== completedTasks.length
|
|
1303
|
+
|| [...inProgressIds].some((taskId) => completedIds.has(taskId))) {
|
|
1304
|
+
fail('STORE_INCONSISTENT', 'historical_import_disposition_conflict');
|
|
1305
|
+
}
|
|
1306
|
+
for (const input of inProgressTasks) events.push(buildHistoricalStart(plan, events.at(-1), input, genesis));
|
|
1307
|
+
for (const input of completedTasks) events.push(buildHistoricalDone(plan, events.at(-1), input, genesis));
|
|
1308
|
+
const verifyImportSource = importSourceVerifier(repoRoot, true);
|
|
1309
|
+
const tasks = replay(plan, events, {
|
|
1310
|
+
now: options.now ? new Date(options.now) : new Date(), verifyImportSource,
|
|
1311
|
+
});
|
|
1312
|
+
validateMergedGraph([...memberPlans, { plan }]);
|
|
1313
|
+
return { plan, genesis, events, tasks, snapshot: snapshotFor(plan, events, tasks) };
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
async function bootstrapImportedPlan(repoRoot, options) {
|
|
1317
|
+
const initialization = options.initializeIfMissing;
|
|
1318
|
+
if (!exactRecord(initialization, ['projectId', 'repositories'])
|
|
1319
|
+
|| initialization.projectId !== options.plan?.project_id) {
|
|
1320
|
+
throw new TypeError('historical import initialization input invalid');
|
|
1321
|
+
}
|
|
1322
|
+
const prepared = prepareImportedArtifacts(repoRoot, options, initialization.projectId, []);
|
|
1323
|
+
const { plan, genesis, events, snapshot } = prepared;
|
|
1324
|
+
const base = `${STORE_ROOT_REF}/plans/${plan.plan_key}/${plan.plan_version}`;
|
|
1325
|
+
const planRef = `${base}/plan.json`;
|
|
1326
|
+
const journalRef = `${base}/journal/active.jsonl`;
|
|
1327
|
+
const snapshotRef = `${base}/snapshot.json`;
|
|
1328
|
+
const descriptor = { plan_key: plan.plan_key, active_plan_version: plan.plan_version,
|
|
1329
|
+
plan_ref: planRef, journal_ref: journalRef, snapshot_ref: snapshotRef,
|
|
1330
|
+
topology_digest: plan.topology_digest, journal_head_digest: events.at(-1).event_digest };
|
|
1331
|
+
const manifest = { schema: 'lattice.todo_manifest.v1', project_id: initialization.projectId,
|
|
1332
|
+
repositories: initialization.repositories, members: [descriptor], manifest_digest: '' };
|
|
1333
|
+
manifest.manifest_digest = todoSelfDigest(manifest, 'manifest_digest');
|
|
1334
|
+
if (!validateTodoManifest(manifest)) throw new TypeError('manifest input violates lattice.todo_manifest.v1');
|
|
1335
|
+
|
|
1336
|
+
const stage = path.join(repoRoot, `.lattice-todo-bootstrap-${process.pid}-${randomBytes(6).toString('hex')}`);
|
|
1337
|
+
const latticeRoot = path.join(repoRoot, '.lattice');
|
|
1338
|
+
const storeRoot = path.join(repoRoot, STORE_ROOT_REF);
|
|
1339
|
+
let createdLatticeRoot = false;
|
|
1340
|
+
let activated = false;
|
|
1341
|
+
try {
|
|
1342
|
+
await mkdir(stage, { mode: 0o700 });
|
|
1343
|
+
const stagedBase = path.join(stage, 'plans', plan.plan_key, plan.plan_version);
|
|
1344
|
+
await atomicWrite(path.join(stagedBase, 'plan.json'), canonicalLine(plan));
|
|
1345
|
+
await atomicWrite(path.join(stagedBase, 'journal', 'active.jsonl'), Buffer.concat(events.map(canonicalLine)));
|
|
1346
|
+
await atomicWrite(path.join(stagedBase, 'snapshot.json'), canonicalLine(snapshot));
|
|
1347
|
+
await atomicWrite(path.join(stage, 'manifest.json'), canonicalLine(manifest));
|
|
1348
|
+
await protocolStage(options, 'bootstrap_staged');
|
|
1349
|
+
|
|
1350
|
+
try {
|
|
1351
|
+
const state = await lstat(latticeRoot);
|
|
1352
|
+
if (state.isSymbolicLink() || !state.isDirectory()) fail('STORE_INCONSISTENT', 'unsafe_lattice_root');
|
|
1353
|
+
} catch (error) {
|
|
1354
|
+
if (error instanceof TodoStoreError) throw error;
|
|
1355
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
1356
|
+
await mkdir(latticeRoot, { mode: 0o700 });
|
|
1357
|
+
createdLatticeRoot = true;
|
|
1358
|
+
await fsyncDirectory(repoRoot);
|
|
1359
|
+
}
|
|
1360
|
+
await protocolStage(options, 'bootstrap_parent_prepared');
|
|
1361
|
+
try { await rename(stage, storeRoot); }
|
|
1362
|
+
catch (error) {
|
|
1363
|
+
if (['EEXIST', 'ENOTEMPTY'].includes(error?.code)) {
|
|
1364
|
+
fail('STORE_WRITE_CONFLICT', 'store_bootstrap_raced');
|
|
1365
|
+
}
|
|
1366
|
+
throw error;
|
|
1367
|
+
}
|
|
1368
|
+
activated = true;
|
|
1369
|
+
await fsyncDirectory(latticeRoot);
|
|
1370
|
+
await protocolStage(options, 'bootstrap_activated');
|
|
1371
|
+
return { plan, genesis, events, snapshot, descriptor };
|
|
1372
|
+
} finally {
|
|
1373
|
+
if (!activated) await rm(stage, { recursive: true, force: true });
|
|
1374
|
+
if (!activated && createdLatticeRoot) {
|
|
1375
|
+
try { await rmdir(latticeRoot); }
|
|
1376
|
+
catch (error) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error?.code)) throw error; }
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
async function protocolStage(options, stage) {
|
|
1382
|
+
if (typeof options.onProtocolStage === 'function') await options.onProtocolStage(stage);
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/** G4-only atomic import, with optional all-or-nothing store bootstrap. */
|
|
1386
|
+
export async function appendImportedPlan(options = {}) {
|
|
1387
|
+
requireWriter(options.writer, 'g4-migration');
|
|
1388
|
+
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
1389
|
+
if (options.initializeIfMissing !== undefined) {
|
|
1390
|
+
try { await lstat(path.join(repoRoot, STORE_ROOT_REF)); }
|
|
1391
|
+
catch (error) {
|
|
1392
|
+
if (error?.code === 'ENOENT') return bootstrapImportedPlan(repoRoot, options);
|
|
1393
|
+
throw error;
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
return withLock(repoRoot, async () => {
|
|
1397
|
+
// 1. Validate canonical manifest bytes and every current member before preparing output.
|
|
1398
|
+
const store = await readTodoStore({ repoRoot, forWrite: true, now: options.now });
|
|
1399
|
+
const expectedManifestDigest = store.manifest.manifest_digest;
|
|
1400
|
+
await protocolStage(options, 'manifest_validated');
|
|
1401
|
+
|
|
1402
|
+
// 2. A plan key is never overwritten, merged, or treated as an idempotent success.
|
|
1403
|
+
const existing = store.members.find(({ descriptor }) => descriptor.plan_key === options.plan?.plan_key);
|
|
1404
|
+
if (existing) {
|
|
1405
|
+
const imported = existing.journal.events[0]?.payload.historical_import === true;
|
|
1406
|
+
fail('STORE_WRITE_CONFLICT', imported ? 'plan_key_already_imported' : 'plan_key_already_exists');
|
|
1407
|
+
}
|
|
1408
|
+
await protocolStage(options, 'plan_key_absent');
|
|
1409
|
+
|
|
1410
|
+
const { plan, genesis, events, snapshot } = prepareImportedArtifacts(
|
|
1411
|
+
repoRoot, options, store.project_id, store.members.map(({ plan: memberPlan }) => ({ plan: memberPlan })),
|
|
1412
|
+
);
|
|
1413
|
+
|
|
1414
|
+
const base = `${STORE_ROOT_REF}/plans/${plan.plan_key}/${plan.plan_version}`;
|
|
1415
|
+
const planRef = `${base}/plan.json`;
|
|
1416
|
+
const journalRef = `${base}/journal/active.jsonl`;
|
|
1417
|
+
const snapshotRef = `${base}/snapshot.json`;
|
|
1418
|
+
const transactionRef = `${STORE_ROOT_REF}/transactions/${plan.plan_key}-${plan.plan_version}`;
|
|
1419
|
+
const transactionAbsolute = path.resolve(repoRoot, transactionRef);
|
|
1420
|
+
const finalBaseAbsolute = path.resolve(repoRoot, base);
|
|
1421
|
+
// These paths can only be leftovers from a transaction whose plan key is still absent.
|
|
1422
|
+
await rm(transactionAbsolute, { recursive: true, force: true });
|
|
1423
|
+
await rm(finalBaseAbsolute, { recursive: true, force: true });
|
|
1424
|
+
|
|
1425
|
+
// 3. All future member artifacts are durable while still outside manifest membership.
|
|
1426
|
+
const stagedPlan = path.join(transactionAbsolute, 'plan.json');
|
|
1427
|
+
const stagedJournal = path.join(transactionAbsolute, 'active.jsonl');
|
|
1428
|
+
const stagedSnapshot = path.join(transactionAbsolute, 'snapshot.json');
|
|
1429
|
+
await atomicWrite(stagedPlan, canonicalLine(plan));
|
|
1430
|
+
await atomicWrite(stagedJournal, Buffer.concat(events.map(canonicalLine)));
|
|
1431
|
+
await atomicWrite(stagedSnapshot, canonicalLine(snapshot));
|
|
1432
|
+
await protocolStage(options, 'staging_fsynced');
|
|
1433
|
+
|
|
1434
|
+
// 4. Re-read canonical manifest bytes under the lock and compare the captured digest.
|
|
1435
|
+
const currentManifest = await readArtifact(repoRoot, MANIFEST_REF, {
|
|
1436
|
+
code: 'STORE_INCONSISTENT', maxBytes: TODO_LIMITS.snapshotBytes, validate: validateTodoManifest,
|
|
1437
|
+
});
|
|
1438
|
+
if (currentManifest.manifest_digest !== expectedManifestDigest) {
|
|
1439
|
+
fail('STORE_WRITE_CONFLICT', 'manifest_digest_changed');
|
|
1440
|
+
}
|
|
1441
|
+
await protocolStage(options, 'manifest_cas_matched');
|
|
1442
|
+
|
|
1443
|
+
// 5. Pre-activation paths remain invisible until the final manifest rename.
|
|
1444
|
+
await mkdir(path.dirname(path.resolve(repoRoot, planRef)), { recursive: true });
|
|
1445
|
+
await mkdir(path.dirname(path.resolve(repoRoot, journalRef)), { recursive: true });
|
|
1446
|
+
await rename(stagedPlan, path.resolve(repoRoot, planRef));
|
|
1447
|
+
await rename(stagedJournal, path.resolve(repoRoot, journalRef));
|
|
1448
|
+
await rename(stagedSnapshot, path.resolve(repoRoot, snapshotRef));
|
|
1449
|
+
await fsyncDirectory(path.dirname(path.resolve(repoRoot, planRef)));
|
|
1450
|
+
await fsyncDirectory(path.dirname(path.resolve(repoRoot, journalRef)));
|
|
1451
|
+
await protocolStage(options, 'pre_activation_renamed');
|
|
1452
|
+
|
|
1453
|
+
const descriptor = { plan_key: plan.plan_key, active_plan_version: plan.plan_version,
|
|
1454
|
+
plan_ref: planRef, journal_ref: journalRef, snapshot_ref: snapshotRef,
|
|
1455
|
+
topology_digest: plan.topology_digest, journal_head_digest: events.at(-1).event_digest };
|
|
1456
|
+
currentManifest.members.push(descriptor);
|
|
1457
|
+
currentManifest.members.sort((left, right) => left.plan_key < right.plan_key ? -1 : left.plan_key > right.plan_key ? 1 : 0);
|
|
1458
|
+
currentManifest.manifest_digest = todoSelfDigest(currentManifest, 'manifest_digest');
|
|
1459
|
+
if (!validateTodoManifest(currentManifest)) throw new TypeError('import activation manifest invalid');
|
|
1460
|
+
await atomicWrite(path.resolve(repoRoot, MANIFEST_REF), canonicalLine(currentManifest));
|
|
1461
|
+
await protocolStage(options, 'manifest_activated');
|
|
1462
|
+
await rm(transactionAbsolute, { recursive: true, force: true });
|
|
1463
|
+
return { plan, genesis, events, snapshot, descriptor };
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
export async function initializeTodoStore(options = {}) {
|
|
1468
|
+
requireWriter(options.writer, 'g4-migration');
|
|
1469
|
+
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
1470
|
+
if (!Array.isArray(options.plans) || options.plans.length === 0) throw new TypeError('plans required');
|
|
1471
|
+
return withLock(repoRoot, async () => {
|
|
1472
|
+
try { await lstat(path.resolve(repoRoot, MANIFEST_REF)); fail('STORE_WRITE_CONFLICT', 'store_already_exists'); }
|
|
1473
|
+
catch (error) { if (error instanceof TodoStoreError) throw error; if (error?.code !== 'ENOENT') throw error; }
|
|
1474
|
+
const members = [];
|
|
1475
|
+
for (const entry of options.plans) {
|
|
1476
|
+
const plan = buildTodoPlan(entry.plan);
|
|
1477
|
+
verifyPlanNarrativeAnchors(repoRoot, plan);
|
|
1478
|
+
const genesis = buildPlanGenesis(plan, entry.genesis);
|
|
1479
|
+
const base = `${STORE_ROOT_REF}/plans/${plan.plan_key}/${plan.plan_version}`;
|
|
1480
|
+
const planRef = `${base}/plan.json`; const journalRef = `${base}/journal/active.jsonl`; const snapshotRef = `${base}/snapshot.json`;
|
|
1481
|
+
await atomicWrite(path.resolve(repoRoot, planRef), canonicalLine(plan));
|
|
1482
|
+
await atomicWrite(path.resolve(repoRoot, journalRef), canonicalLine(genesis));
|
|
1483
|
+
const snapshot = snapshotFor(plan, [genesis], replay(plan, [genesis], { now: options.now ? new Date(options.now) : new Date() }));
|
|
1484
|
+
await atomicWrite(path.resolve(repoRoot, snapshotRef), canonicalLine(snapshot));
|
|
1485
|
+
members.push({ plan_key: plan.plan_key, active_plan_version: plan.plan_version, plan_ref: planRef,
|
|
1486
|
+
journal_ref: journalRef, snapshot_ref: snapshotRef, topology_digest: plan.topology_digest,
|
|
1487
|
+
journal_head_digest: genesis.event_digest });
|
|
1488
|
+
}
|
|
1489
|
+
members.sort((left, right) => left.plan_key < right.plan_key ? -1 : left.plan_key > right.plan_key ? 1 : 0);
|
|
1490
|
+
const manifest = { schema: 'lattice.todo_manifest.v1', project_id: options.projectId,
|
|
1491
|
+
repositories: options.repositories, members, manifest_digest: '' };
|
|
1492
|
+
manifest.manifest_digest = todoSelfDigest(manifest, 'manifest_digest');
|
|
1493
|
+
if (!validateTodoManifest(manifest)) throw new TypeError('manifest input violates lattice.todo_manifest.v1');
|
|
1494
|
+
// Activation is last: a crash before this point leaves the new store/version unpublished.
|
|
1495
|
+
await atomicWrite(path.resolve(repoRoot, MANIFEST_REF), canonicalLine(manifest));
|
|
1496
|
+
return readTodoStore({ repoRoot, now: options.now });
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
/** G5-only initial authoring transaction. Store membership appears by one final directory rename. */
|
|
1501
|
+
export async function initializeAuthoredTodoStore(options = {}) {
|
|
1502
|
+
requireWriter(options.writer, 'g5-authoring');
|
|
1503
|
+
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
1504
|
+
const optionKeys = [
|
|
1505
|
+
'repoRoot', 'writer', 'projectId', 'repositories', 'plan', 'genesis',
|
|
1506
|
+
];
|
|
1507
|
+
if (!(exactRecord(options, optionKeys)
|
|
1508
|
+
|| (exactRecord(options, [...optionKeys, 'onProtocolStage'])
|
|
1509
|
+
&& typeof options.onProtocolStage === 'function'))
|
|
1510
|
+
|| options.projectId !== options.plan?.project_id
|
|
1511
|
+
|| options.plan?.predecessor_plan_digest !== null) {
|
|
1512
|
+
throw new TypeError('authored store initialization input invalid');
|
|
1513
|
+
}
|
|
1514
|
+
const plan = buildTodoPlan(options.plan);
|
|
1515
|
+
verifyPlanNarrativeAnchors(repoRoot, plan);
|
|
1516
|
+
const genesis = buildPlanGenesis(plan, options.genesis);
|
|
1517
|
+
const tasks = replay(plan, [genesis]);
|
|
1518
|
+
validateMergedGraph([{ plan }]);
|
|
1519
|
+
const snapshot = snapshotFor(plan, [genesis], tasks);
|
|
1520
|
+
const base = `plans/${plan.plan_key}/${plan.plan_version}`;
|
|
1521
|
+
const planRef = `${STORE_ROOT_REF}/${base}/plan.json`;
|
|
1522
|
+
const journalRef = `${STORE_ROOT_REF}/${base}/journal/active.jsonl`;
|
|
1523
|
+
const snapshotRef = `${STORE_ROOT_REF}/${base}/snapshot.json`;
|
|
1524
|
+
const descriptor = {
|
|
1525
|
+
plan_key: plan.plan_key, active_plan_version: plan.plan_version,
|
|
1526
|
+
plan_ref: planRef, journal_ref: journalRef, snapshot_ref: snapshotRef,
|
|
1527
|
+
topology_digest: plan.topology_digest, journal_head_digest: genesis.event_digest,
|
|
1528
|
+
};
|
|
1529
|
+
const manifest = {
|
|
1530
|
+
schema: 'lattice.todo_manifest.v1', project_id: options.projectId,
|
|
1531
|
+
repositories: options.repositories, members: [descriptor], manifest_digest: '',
|
|
1532
|
+
};
|
|
1533
|
+
manifest.manifest_digest = todoSelfDigest(manifest, 'manifest_digest');
|
|
1534
|
+
if (!validateTodoManifest(manifest)) throw new TypeError('manifest input violates lattice.todo_manifest.v1');
|
|
1535
|
+
|
|
1536
|
+
const stage = path.join(repoRoot, `.lattice-todo-authoring-${process.pid}-${randomBytes(6).toString('hex')}`);
|
|
1537
|
+
const latticeRoot = path.join(repoRoot, '.lattice');
|
|
1538
|
+
const storeRoot = path.join(repoRoot, STORE_ROOT_REF);
|
|
1539
|
+
let createdLatticeRoot = false;
|
|
1540
|
+
let renamed = false;
|
|
1541
|
+
let activated = false;
|
|
1542
|
+
let stageIdentity = null;
|
|
1543
|
+
let latticeIdentity = null;
|
|
1544
|
+
const sameIdentity = (left, right) => left !== null && right !== null
|
|
1545
|
+
&& left.dev === right.dev && left.ino === right.ino;
|
|
1546
|
+
try {
|
|
1547
|
+
try { await lstat(storeRoot); fail('STORE_WRITE_CONFLICT', 'store_already_exists'); }
|
|
1548
|
+
catch (error) {
|
|
1549
|
+
if (error instanceof TodoStoreError) throw error;
|
|
1550
|
+
if (error?.code === 'ENOTDIR') fail('STORE_INCONSISTENT', 'unsafe_lattice_root');
|
|
1551
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
1552
|
+
}
|
|
1553
|
+
await mkdir(stage, { mode: 0o700 });
|
|
1554
|
+
stageIdentity = await lstat(stage);
|
|
1555
|
+
await atomicWrite(path.join(stage, base, 'plan.json'), canonicalLine(plan));
|
|
1556
|
+
await atomicWrite(path.join(stage, base, 'journal', 'active.jsonl'), canonicalLine(genesis));
|
|
1557
|
+
await atomicWrite(path.join(stage, base, 'snapshot.json'), canonicalLine(snapshot));
|
|
1558
|
+
await atomicWrite(path.join(stage, 'manifest.json'), canonicalLine(manifest));
|
|
1559
|
+
|
|
1560
|
+
try {
|
|
1561
|
+
const state = await lstat(latticeRoot);
|
|
1562
|
+
if (state.isSymbolicLink() || !state.isDirectory()) fail('STORE_INCONSISTENT', 'unsafe_lattice_root');
|
|
1563
|
+
} catch (error) {
|
|
1564
|
+
if (error instanceof TodoStoreError) throw error;
|
|
1565
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
1566
|
+
await mkdir(latticeRoot, { mode: 0o700 });
|
|
1567
|
+
createdLatticeRoot = true;
|
|
1568
|
+
await fsyncDirectory(repoRoot);
|
|
1569
|
+
}
|
|
1570
|
+
latticeIdentity = await lstat(latticeRoot);
|
|
1571
|
+
const stagedBeforeRename = await lstat(stage);
|
|
1572
|
+
const parentBeforeRename = await lstat(latticeRoot);
|
|
1573
|
+
if (!sameIdentity(stageIdentity, stagedBeforeRename)
|
|
1574
|
+
|| !sameIdentity(latticeIdentity, parentBeforeRename)
|
|
1575
|
+
|| stagedBeforeRename.isSymbolicLink() || !stagedBeforeRename.isDirectory()
|
|
1576
|
+
|| parentBeforeRename.isSymbolicLink() || !parentBeforeRename.isDirectory()) {
|
|
1577
|
+
fail('STORE_WRITE_CONFLICT', 'authoring_activation_path_changed');
|
|
1578
|
+
}
|
|
1579
|
+
try { await rename(stage, storeRoot); }
|
|
1580
|
+
catch (error) {
|
|
1581
|
+
if (['EEXIST', 'ENOTEMPTY'].includes(error?.code)) fail('STORE_WRITE_CONFLICT', 'store_bootstrap_raced');
|
|
1582
|
+
throw error;
|
|
1583
|
+
}
|
|
1584
|
+
renamed = true;
|
|
1585
|
+
await fsyncDirectory(latticeRoot);
|
|
1586
|
+
if (typeof options.onProtocolStage === 'function') await options.onProtocolStage('authoring_renamed');
|
|
1587
|
+
const activatedStore = await lstat(storeRoot);
|
|
1588
|
+
const activatedParent = await lstat(latticeRoot);
|
|
1589
|
+
if (!sameIdentity(stageIdentity, activatedStore)
|
|
1590
|
+
|| !sameIdentity(latticeIdentity, activatedParent)
|
|
1591
|
+
|| activatedStore.isSymbolicLink() || !activatedStore.isDirectory()
|
|
1592
|
+
|| activatedParent.isSymbolicLink() || !activatedParent.isDirectory()) {
|
|
1593
|
+
fail('STORE_WRITE_CONFLICT', 'authoring_activation_path_changed');
|
|
1594
|
+
}
|
|
1595
|
+
const store = await readTodoStore({ repoRoot });
|
|
1596
|
+
const member = store.members[0];
|
|
1597
|
+
if (store.project_id !== options.projectId || store.members.length !== 1
|
|
1598
|
+
|| store.snapshot_stale !== false || member?.journal?.events?.length !== 1
|
|
1599
|
+
|| canonicalizeTodoArtifact(store.manifest) !== canonicalizeTodoArtifact(manifest)
|
|
1600
|
+
|| canonicalizeTodoArtifact(member?.descriptor) !== canonicalizeTodoArtifact(descriptor)
|
|
1601
|
+
|| canonicalizeTodoArtifact(member?.plan) !== canonicalizeTodoArtifact(plan)
|
|
1602
|
+
|| canonicalizeTodoArtifact(member?.journal?.events?.[0]) !== canonicalizeTodoArtifact(genesis)
|
|
1603
|
+
|| canonicalizeTodoArtifact(member?.snapshot) !== canonicalizeTodoArtifact(snapshot)) {
|
|
1604
|
+
fail('STORE_WRITE_CONFLICT', 'authoring_activation_verification_failed');
|
|
1605
|
+
}
|
|
1606
|
+
activated = true;
|
|
1607
|
+
return store;
|
|
1608
|
+
} finally {
|
|
1609
|
+
if (renamed && !activated) {
|
|
1610
|
+
try {
|
|
1611
|
+
const currentParent = await lstat(latticeRoot);
|
|
1612
|
+
const currentStore = await lstat(storeRoot);
|
|
1613
|
+
if (sameIdentity(latticeIdentity, currentParent) && sameIdentity(stageIdentity, currentStore)
|
|
1614
|
+
&& !currentParent.isSymbolicLink() && currentParent.isDirectory()
|
|
1615
|
+
&& !currentStore.isSymbolicLink() && currentStore.isDirectory()) {
|
|
1616
|
+
await rm(storeRoot, { recursive: true, force: true });
|
|
1617
|
+
await fsyncDirectory(latticeRoot);
|
|
1618
|
+
}
|
|
1619
|
+
} catch (error) {
|
|
1620
|
+
if (!['ENOENT', 'ENOTDIR'].includes(error?.code)) throw error;
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
if (!activated) await rm(stage, { recursive: true, force: true });
|
|
1624
|
+
if (!activated && createdLatticeRoot) {
|
|
1625
|
+
try { await rmdir(latticeRoot); }
|
|
1626
|
+
catch (error) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error?.code)) throw error; }
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
async function sourceItemBytes(repoRoot, sourceRef) {
|
|
1632
|
+
const parsed = parseTodoSourceRef(sourceRef);
|
|
1633
|
+
if (parsed === null) fail('RECONCILIATION_INCOMPLETE', 'source_ref_invalid', { source_ref: sourceRef });
|
|
1634
|
+
const canonicalRoot = await realpath(repoRoot);
|
|
1635
|
+
const absolute = path.resolve(canonicalRoot, parsed.path);
|
|
1636
|
+
if (!absolute.startsWith(`${canonicalRoot}${path.sep}`)) {
|
|
1637
|
+
fail('RECONCILIATION_INCOMPLETE', 'source_path_outside_repo', { source_ref: sourceRef });
|
|
1638
|
+
}
|
|
1639
|
+
let current = canonicalRoot;
|
|
1640
|
+
for (const part of path.relative(canonicalRoot, absolute).split(path.sep)) {
|
|
1641
|
+
current = path.join(current, part);
|
|
1642
|
+
let state;
|
|
1643
|
+
try { state = await lstat(current); }
|
|
1644
|
+
catch { fail('RECONCILIATION_INCOMPLETE', 'source_path_missing', { source_ref: sourceRef }); }
|
|
1645
|
+
if (state.isSymbolicLink()) {
|
|
1646
|
+
fail('RECONCILIATION_INCOMPLETE', 'source_path_symlink', { source_ref: sourceRef });
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
const state = await lstat(absolute);
|
|
1650
|
+
if (!state.isFile() || await realpath(absolute) !== absolute) {
|
|
1651
|
+
fail('RECONCILIATION_INCOMPLETE', 'source_path_not_regular', { source_ref: sourceRef });
|
|
1652
|
+
}
|
|
1653
|
+
const bytes = await readFile(absolute);
|
|
1654
|
+
decodeUtf8(bytes, 'RECONCILIATION_INCOMPLETE', 'source_invalid_utf8');
|
|
1655
|
+
const lines = [];
|
|
1656
|
+
let start = 0;
|
|
1657
|
+
for (let index = 0; index <= bytes.length; index += 1) {
|
|
1658
|
+
if (index === bytes.length || bytes[index] === 0x0a) {
|
|
1659
|
+
lines.push(bytes.subarray(start, index));
|
|
1660
|
+
start = index + 1;
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
const line = lines[parsed.line - 1];
|
|
1664
|
+
if (line === undefined) fail('RECONCILIATION_INCOMPLETE', 'source_line_missing', { source_ref: sourceRef });
|
|
1665
|
+
return line;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
async function verifyRevisionSources(repoRoot, inventory) {
|
|
1669
|
+
for (const entry of [...inventory.active, ...inventory.excluded_tombstones]) {
|
|
1670
|
+
const line = await sourceItemBytes(repoRoot, entry.source_ref);
|
|
1671
|
+
if (sha256Bytes(line) !== entry.source_digest) {
|
|
1672
|
+
fail('RECONCILIATION_INCOMPLETE', 'source_digest_mismatch', { source_ref: entry.source_ref });
|
|
1673
|
+
}
|
|
1674
|
+
if (markdownCheckboxState(line) === null) {
|
|
1675
|
+
fail('RECONCILIATION_INCOMPLETE', 'source_item_not_todo', { source_ref: entry.source_ref });
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
export async function verifyTodoRevisionSources(options = {}) {
|
|
1681
|
+
const revision = options.revision;
|
|
1682
|
+
if (!validateTodoRevision(revision)) fail('REVISION_INVALID', 'revision_schema_or_digest_invalid');
|
|
1683
|
+
await verifyRevisionSources(path.resolve(options.repoRoot ?? process.cwd()), revision.source_inventory);
|
|
1684
|
+
return true;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
export async function verifyPhaseTodoRevisionSources(options = {}) {
|
|
1688
|
+
const revision = options.revision;
|
|
1689
|
+
if (!validatePhaseTodoRevision(revision)) {
|
|
1690
|
+
fail('REVISION_INVALID', 'phase_revision_schema_or_digest_invalid');
|
|
1691
|
+
}
|
|
1692
|
+
if (revision.schema !== 'lattice.phase_todo_revision.v3') {
|
|
1693
|
+
fail('REVISION_INVALID', 'phase_revision_source_inventory_unsupported');
|
|
1694
|
+
}
|
|
1695
|
+
await verifyRevisionSources(path.resolve(options.repoRoot ?? process.cwd()), revision.source_inventory);
|
|
1696
|
+
return true;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
function mappedNodeRef(ref, plan, idMap) {
|
|
1700
|
+
if (ref.project_id !== plan.project_id || ref.plan_key !== plan.plan_key) return ref;
|
|
1701
|
+
return { ...ref, task_id: idMap.get(ref.task_id) ?? ref.task_id };
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
function localTaskRef(ref, plan, taskId) {
|
|
1705
|
+
return ref.project_id === plan.project_id && ref.plan_key === plan.plan_key
|
|
1706
|
+
&& ref.task_id === taskId;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
function taskSemantics(plan, taskId, idMap, { reconciliationMetadata = false } = {}) {
|
|
1710
|
+
const task = plan.tasks.find(({ task_id }) => task_id === taskId);
|
|
1711
|
+
if (!task) return null;
|
|
1712
|
+
const mapId = (id) => id === null ? null : idMap.get(id) ?? id;
|
|
1713
|
+
const normalizedTask = reconciliationMetadata ? {
|
|
1714
|
+
task_id: mapId(task.task_id), title: task.title, lane: task.lane,
|
|
1715
|
+
compile_binding: task.compile_binding,
|
|
1716
|
+
} : {
|
|
1717
|
+
task_id: mapId(task.task_id), title: task.title, lane: task.lane,
|
|
1718
|
+
narrative_ref: task.narrative_ref, narrative_anchor: task.narrative_anchor ?? null,
|
|
1719
|
+
compile_binding: task.compile_binding, parent_task_id: mapId(task.parent_task_id ?? null),
|
|
1720
|
+
};
|
|
1721
|
+
const edges = plan.hard_dependencies
|
|
1722
|
+
.filter(({ from, to }) => localTaskRef(from, plan, taskId) || localTaskRef(to, plan, taskId))
|
|
1723
|
+
.map(({ from, to }) => ({ from: mappedNodeRef(from, plan, idMap), to: mappedNodeRef(to, plan, idMap) }))
|
|
1724
|
+
.sort((left, right) => canonicalizeTodoArtifact(left) < canonicalizeTodoArtifact(right) ? -1 : 1);
|
|
1725
|
+
const joins = plan.joins
|
|
1726
|
+
.filter(({ after, before }) => localTaskRef(before, plan, taskId)
|
|
1727
|
+
|| after.some((ref) => localTaskRef(ref, plan, taskId)))
|
|
1728
|
+
.map((join) => ({ ...join,
|
|
1729
|
+
after: join.after.map((ref) => mappedNodeRef(ref, plan, idMap))
|
|
1730
|
+
.sort((left, right) => canonicalizeTodoArtifact(left) < canonicalizeTodoArtifact(right) ? -1 : 1),
|
|
1731
|
+
before: mappedNodeRef(join.before, plan, idMap),
|
|
1732
|
+
})).sort((left, right) => left.id < right.id ? -1 : 1);
|
|
1733
|
+
const phaseAcceptDependencies = plan.schema === 'lattice.todo_plan.v5'
|
|
1734
|
+
? plan.phase_accept_dependencies
|
|
1735
|
+
.filter(({ to }) => localTaskRef(to, plan, taskId))
|
|
1736
|
+
.map(({ from, to }) => ({ from, to: mappedNodeRef(to, plan, idMap) }))
|
|
1737
|
+
.sort((left, right) => canonicalizeTodoArtifact(left) < canonicalizeTodoArtifact(right) ? -1 : 1)
|
|
1738
|
+
: [];
|
|
1739
|
+
return { task: normalizedTask, hard_dependencies: edges, joins, phase_accept_dependencies: phaseAcceptDependencies };
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
function phaseV3CarrySemantics(plan, taskId, taskIdMap, phaseIdMap,
|
|
1743
|
+
{ reconciliationMetadata = false } = {}) {
|
|
1744
|
+
const task = plan.tasks.find(({ task_id: id }) => id === taskId);
|
|
1745
|
+
if (task === undefined) return null;
|
|
1746
|
+
const mapTaskRef = (ref) => ref.project_id === plan.project_id && ref.plan_key === plan.plan_key
|
|
1747
|
+
? { ...ref, task_id: taskIdMap.get(ref.task_id) ?? ref.task_id } : ref;
|
|
1748
|
+
const mapPhaseRef = (ref) => ref.project_id === plan.project_id && ref.plan_key === plan.plan_key
|
|
1749
|
+
? { ...ref, phase_id: phaseIdMap.get(ref.phase_id) ?? ref.phase_id } : ref;
|
|
1750
|
+
const mappedTask = reconciliationMetadata ? {
|
|
1751
|
+
task_id: taskIdMap.get(task.task_id) ?? task.task_id, title: task.title, lane: task.lane,
|
|
1752
|
+
compile_binding: task.compile_binding,
|
|
1753
|
+
phase_id: phaseIdMap.get(task.phase_id) ?? task.phase_id,
|
|
1754
|
+
} : { ...task,
|
|
1755
|
+
task_id: taskIdMap.get(task.task_id) ?? task.task_id,
|
|
1756
|
+
phase_id: phaseIdMap.get(task.phase_id) ?? task.phase_id,
|
|
1757
|
+
parent_task_id: task.parent_task_id === null ? null
|
|
1758
|
+
: taskIdMap.get(task.parent_task_id) ?? task.parent_task_id,
|
|
1759
|
+
};
|
|
1760
|
+
const incoming = [];
|
|
1761
|
+
const outgoing = [];
|
|
1762
|
+
for (const edge of plan.hard_dependencies) {
|
|
1763
|
+
const mapped = { kind: 'hard', from: mapTaskRef(edge.from), to: mapTaskRef(edge.to) };
|
|
1764
|
+
if (localTaskRef(edge.to, plan, taskId)) incoming.push(mapped);
|
|
1765
|
+
if (localTaskRef(edge.from, plan, taskId)) outgoing.push(mapped);
|
|
1766
|
+
}
|
|
1767
|
+
for (const join of plan.joins) {
|
|
1768
|
+
const to = mapTaskRef(join.before);
|
|
1769
|
+
for (const after of join.after) {
|
|
1770
|
+
const tuple = { kind: 'join', join_id: join.id, from: mapTaskRef(after), to };
|
|
1771
|
+
if (localTaskRef(join.before, plan, taskId)) incoming.push(tuple);
|
|
1772
|
+
if (localTaskRef(after, plan, taskId)) outgoing.push(tuple);
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
if (plan.schema === 'lattice.todo_plan.v5') for (const edge of plan.phase_accept_dependencies) {
|
|
1776
|
+
if (localTaskRef(edge.to, plan, taskId)) incoming.push({ kind: 'phase_accept',
|
|
1777
|
+
from: mapPhaseRef(edge.from), to: mapTaskRef(edge.to) });
|
|
1778
|
+
}
|
|
1779
|
+
const sort = (entries) => entries.map(canonicalizeTodoArtifact).sort();
|
|
1780
|
+
return { task: mappedTask, incoming: sort(incoming), outgoing: sort(outgoing) };
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
function validatePhaseV3Carry(previous, revision, migration, idMap, state) {
|
|
1784
|
+
const reconciliationMetadata = migration.state_policy === 'carry_reconciled_metadata';
|
|
1785
|
+
const phaseIdMap = new Map(revision.phase_migration
|
|
1786
|
+
.filter(({ from_phase_id, to_phase_id }) => from_phase_id !== null && to_phase_id !== 'removed')
|
|
1787
|
+
.map(({ from_phase_id, to_phase_id }) => [from_phase_id, to_phase_id]));
|
|
1788
|
+
const before = phaseV3CarrySemantics(previous.plan, migration.from_task_id, idMap, phaseIdMap,
|
|
1789
|
+
{ reconciliationMetadata });
|
|
1790
|
+
const after = phaseV3CarrySemantics(revision.desired_plan, migration.to_task_id,
|
|
1791
|
+
new Map(), new Map(),
|
|
1792
|
+
{ reconciliationMetadata });
|
|
1793
|
+
if (canonicalizeTodoArtifact(before.task) !== canonicalizeTodoArtifact(after.task)
|
|
1794
|
+
|| canonicalizeTodoArtifact(before.incoming) !== canonicalizeTodoArtifact(after.incoming)) {
|
|
1795
|
+
fail('REVISION_INVALID', 'carry_semantics_changed', { from_task_id: migration.from_task_id });
|
|
1796
|
+
}
|
|
1797
|
+
const successorOutgoing = new Set(after.outgoing);
|
|
1798
|
+
if (!before.outgoing.every((edge) => successorOutgoing.has(edge))) {
|
|
1799
|
+
fail('REVISION_INVALID', 'carry_outgoing_edge_removed', { from_task_id: migration.from_task_id });
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
function stateMigrationFor(previous, revision) {
|
|
1804
|
+
const oldIds = previous.plan.tasks.map(({ task_id }) => task_id);
|
|
1805
|
+
const migrationIds = revision.task_migration.map(({ from_task_id }) => from_task_id);
|
|
1806
|
+
if (canonicalizeTodoArtifact([...oldIds].sort()) !== canonicalizeTodoArtifact([...migrationIds].sort())) {
|
|
1807
|
+
fail('REVISION_INVALID', 'predecessor_task_migration_incomplete');
|
|
1808
|
+
}
|
|
1809
|
+
const idMap = new Map(revision.task_migration
|
|
1810
|
+
.filter(({ to_task_id }) => to_task_id !== 'removed')
|
|
1811
|
+
.map(({ from_task_id, to_task_id }) => [from_task_id, to_task_id]));
|
|
1812
|
+
const states = new Map(previous.tasks.map((state) => [state.task_id, state]));
|
|
1813
|
+
return revision.task_migration.map((migration) => {
|
|
1814
|
+
const carriesState = ['carry', 'carry_reconciled_metadata'].includes(migration.state_policy);
|
|
1815
|
+
if (!carriesState) return { ...migration, state: null };
|
|
1816
|
+
const reconciliationMetadata = migration.state_policy === 'carry_reconciled_metadata';
|
|
1817
|
+
const state = states.get(migration.from_task_id);
|
|
1818
|
+
if (!state) fail('STORE_INCONSISTENT', 'predecessor_task_state_missing');
|
|
1819
|
+
if (revision.schema === 'lattice.phase_todo_revision.v3') {
|
|
1820
|
+
validatePhaseV3Carry(previous, revision, migration, idMap, state);
|
|
1821
|
+
} else {
|
|
1822
|
+
const before = taskSemantics(previous.plan, migration.from_task_id, idMap,
|
|
1823
|
+
{ reconciliationMetadata });
|
|
1824
|
+
const after = taskSemantics(revision.desired_plan, migration.to_task_id, new Map(),
|
|
1825
|
+
{ reconciliationMetadata });
|
|
1826
|
+
if (canonicalizeTodoArtifact(before) !== canonicalizeTodoArtifact(after)) {
|
|
1827
|
+
fail('REVISION_INVALID', 'carry_semantics_changed', { from_task_id: migration.from_task_id });
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
return { ...migration, state: {
|
|
1831
|
+
status: state.status, started_at: state.started_at, done_at: state.done_at,
|
|
1832
|
+
blocked_reason: state.blocked_reason, evidence: state.evidence, imported: state.imported,
|
|
1833
|
+
} };
|
|
1834
|
+
});
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
function phaseStateMigrationFor(previous, revision) {
|
|
1838
|
+
const predecessorPhases = ['lattice.todo_plan.v4', 'lattice.todo_plan.v5'].includes(previous.plan.schema)
|
|
1839
|
+
? previous.plan.phases : [];
|
|
1840
|
+
const sourceIds = revision.phase_migration
|
|
1841
|
+
.filter(({ from_phase_id }) => from_phase_id !== null).map(({ from_phase_id }) => from_phase_id);
|
|
1842
|
+
if (canonicalizeTodoArtifact([...sourceIds].sort())
|
|
1843
|
+
!== canonicalizeTodoArtifact(predecessorPhases.map(({ phase_id }) => phase_id).sort())) {
|
|
1844
|
+
fail('REVISION_INVALID', 'predecessor_phase_migration_incomplete');
|
|
1845
|
+
}
|
|
1846
|
+
const idMap = new Map(revision.phase_migration
|
|
1847
|
+
.filter(({ from_phase_id, to_phase_id }) => from_phase_id !== null && to_phase_id !== 'removed')
|
|
1848
|
+
.map(({ from_phase_id, to_phase_id }) => [from_phase_id, to_phase_id]));
|
|
1849
|
+
const taskIdMap = new Map(revision.task_migration
|
|
1850
|
+
.filter(({ to_task_id }) => to_task_id !== 'removed')
|
|
1851
|
+
.map(({ from_task_id, to_task_id }) => [from_task_id, to_task_id]));
|
|
1852
|
+
const previousStates = new Map((previous.snapshot.phases ?? []).map((state) => [state.phase_id, state]));
|
|
1853
|
+
return revision.phase_migration
|
|
1854
|
+
.filter(({ to_phase_id }) => to_phase_id !== 'removed')
|
|
1855
|
+
.map((migration) => {
|
|
1856
|
+
if (migration.state_policy === 'reset') {
|
|
1857
|
+
return { phase_id: migration.to_phase_id, state_policy: 'reset', state: null };
|
|
1858
|
+
}
|
|
1859
|
+
const before = predecessorPhases.find(({ phase_id }) => phase_id === migration.from_phase_id);
|
|
1860
|
+
const after = revision.desired_plan.phases.find(({ phase_id }) => phase_id === migration.to_phase_id);
|
|
1861
|
+
const mappedBefore = { ...before, phase_id: migration.to_phase_id,
|
|
1862
|
+
predecessor_phase_ids: before.predecessor_phase_ids.map((id) => idMap.get(id) ?? id).sort() };
|
|
1863
|
+
const beforeTaskIds = previous.plan.tasks
|
|
1864
|
+
.filter(({ phase_id }) => phase_id === migration.from_phase_id)
|
|
1865
|
+
.map(({ task_id }) => taskIdMap.get(task_id) ?? 'removed').filter((id) => id !== 'removed').sort();
|
|
1866
|
+
const afterTaskIds = revision.desired_plan.tasks
|
|
1867
|
+
.filter(({ phase_id }) => phase_id === migration.to_phase_id).map(({ task_id }) => task_id).sort();
|
|
1868
|
+
if (canonicalizeTodoArtifact({ phase: mappedBefore, task_ids: beforeTaskIds })
|
|
1869
|
+
!== canonicalizeTodoArtifact({ phase: after, task_ids: afterTaskIds })) {
|
|
1870
|
+
fail('REVISION_INVALID', 'phase_carry_semantics_changed', { from_phase_id: migration.from_phase_id });
|
|
1871
|
+
}
|
|
1872
|
+
const state = previousStates.get(migration.from_phase_id);
|
|
1873
|
+
if (state === undefined) fail('STORE_INCONSISTENT', 'predecessor_phase_state_missing');
|
|
1874
|
+
return { phase_id: migration.to_phase_id, state_policy: 'carry', state: {
|
|
1875
|
+
status: state.status, review_event_digest: state.review_event_digest,
|
|
1876
|
+
decision_event_digest: state.decision_event_digest, decision_evidence: state.decision_evidence,
|
|
1877
|
+
} };
|
|
1878
|
+
}).sort((left, right) => left.phase_id.localeCompare(right.phase_id));
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
function phaseRevisionResult(revision, genesis, recovered = false) {
|
|
1882
|
+
const result = {
|
|
1883
|
+
schema: 'lattice.phase_todo_revise_result.v1', project_id: revision.project_id,
|
|
1884
|
+
plan_key: revision.plan_key, predecessor_plan_digest: revision.predecessor.plan_digest,
|
|
1885
|
+
predecessor_journal_head_digest: revision.predecessor.journal_head_digest,
|
|
1886
|
+
plan_version: revision.desired_plan.plan_version, plan_digest: revision.desired_plan.plan_digest,
|
|
1887
|
+
topology_digest: revision.desired_plan.topology_digest, journal_head_digest: genesis.event_digest,
|
|
1888
|
+
revision_digest: revision.revision_digest, recovered, result_digest: '',
|
|
1889
|
+
};
|
|
1890
|
+
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
1891
|
+
return result;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
function sourceCutoverCleanupBinding(revision, stagingRef) {
|
|
1895
|
+
return { schema: 'lattice.source_cutover_cleanup_binding.v1',
|
|
1896
|
+
revision_digest: revision.revision_digest, staging_ref: stagingRef,
|
|
1897
|
+
cleanup_state: 'cleanup_complete' };
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
function sourceCutoverArchiveRootList(revision, entries) {
|
|
1901
|
+
return { schema: 'lattice.source_cutover_archive_root_list.v1', roots: [{
|
|
1902
|
+
archive_ref: revision.source_cutover_batch.archive_ref,
|
|
1903
|
+
entry_digests: entries.map(({ entry_digest: digest }) => digest),
|
|
1904
|
+
}] };
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
async function buildPhaseV3SourceReceipt(repoRoot, revision, transactionRef) {
|
|
1908
|
+
const stagingRef = `${transactionRef}/source-originals.md`;
|
|
1909
|
+
const entries = [];
|
|
1910
|
+
for (const [operationIndex, operation] of revision.source_cutover_batch.operations.entries()) {
|
|
1911
|
+
const publishedBytes = await sourceItemBytes(repoRoot, operation.source_ref);
|
|
1912
|
+
const archiveRef = todoCutoverArchiveSourceRef(revision.source_cutover_batch, operationIndex);
|
|
1913
|
+
const archivedBytes = await sourceItemBytes(repoRoot, archiveRef);
|
|
1914
|
+
const expectedPublishedBytes = phaseV3PublishedSourceBytes(operation, archivedBytes);
|
|
1915
|
+
const entry = { operation_index: operationIndex, task_id: operation.task_id,
|
|
1916
|
+
disposition: operation.disposition, source_ref: operation.source_ref,
|
|
1917
|
+
staging_ref: `${stagingRef}#L${operationIndex + 1}`, published_ref: operation.source_ref,
|
|
1918
|
+
archive_ref: archiveRef, replacement: operation.live_replacement,
|
|
1919
|
+
staged_source_bytes_digest: operation.source_digest,
|
|
1920
|
+
published_source_bytes_digest: sha256Bytes(publishedBytes),
|
|
1921
|
+
archived_source_bytes_digest: sha256Bytes(archivedBytes), entry_digest: '' };
|
|
1922
|
+
if (entry.published_source_bytes_digest !== sha256Bytes(expectedPublishedBytes)
|
|
1923
|
+
|| entry.archived_source_bytes_digest !== operation.source_digest) {
|
|
1924
|
+
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'source_receipt_bytes_mismatch');
|
|
1925
|
+
}
|
|
1926
|
+
entry.entry_digest = todoSelfDigest(entry, 'entry_digest');
|
|
1927
|
+
entries.push(entry);
|
|
1928
|
+
}
|
|
1929
|
+
const cleanupBinding = sourceCutoverCleanupBinding(revision, stagingRef);
|
|
1930
|
+
const receipt = { schema: 'lattice.source_cutover_receipt.v1', project_id: revision.project_id,
|
|
1931
|
+
plan_key: revision.plan_key, plan_version: revision.desired_plan.plan_version,
|
|
1932
|
+
revision_digest: revision.revision_digest,
|
|
1933
|
+
source_cutover_batch_digest: revision.source_cutover_batch.batch_digest, entries,
|
|
1934
|
+
archive_root_list_digest: digestTodoArtifact(sourceCutoverArchiveRootList(revision, entries)),
|
|
1935
|
+
published_state: 'source_and_archive_published',
|
|
1936
|
+
cleanup_binding_digest: digestTodoArtifact(cleanupBinding), receipt_digest: '' };
|
|
1937
|
+
receipt.receipt_digest = todoSelfDigest(receipt, 'receipt_digest');
|
|
1938
|
+
return receipt;
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
function phaseV3PublishedSourceBytes(operation, archivedBytes) {
|
|
1942
|
+
return Buffer.from(`${operation.live_replacement}${archivedBytes.at(-1) === 0x0d ? '\r' : ''}`,
|
|
1943
|
+
'utf8');
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
function validSourceCutoverReceipt(value, revision) {
|
|
1947
|
+
if (!exactRecord(value, ['schema', 'project_id', 'plan_key', 'plan_version', 'revision_digest',
|
|
1948
|
+
'source_cutover_batch_digest', 'entries', 'archive_root_list_digest', 'published_state',
|
|
1949
|
+
'cleanup_binding_digest', 'receipt_digest'])
|
|
1950
|
+
|| value.schema !== 'lattice.source_cutover_receipt.v1'
|
|
1951
|
+
|| value.project_id !== revision.project_id || value.plan_key !== revision.plan_key
|
|
1952
|
+
|| value.plan_version !== revision.desired_plan.plan_version
|
|
1953
|
+
|| value.revision_digest !== revision.revision_digest
|
|
1954
|
+
|| value.source_cutover_batch_digest !== revision.source_cutover_batch.batch_digest
|
|
1955
|
+
|| value.published_state !== 'source_and_archive_published'
|
|
1956
|
+
|| !Array.isArray(value.entries)
|
|
1957
|
+
|| value.entries.length !== revision.source_cutover_batch.operations.length
|
|
1958
|
+
|| value.receipt_digest !== todoSelfDigest(value, 'receipt_digest')) return false;
|
|
1959
|
+
for (const [index, entry] of value.entries.entries()) {
|
|
1960
|
+
const operation = revision.source_cutover_batch.operations[index];
|
|
1961
|
+
if (!exactRecord(entry, ['operation_index', 'task_id', 'disposition', 'source_ref',
|
|
1962
|
+
'staging_ref', 'published_ref', 'archive_ref', 'replacement',
|
|
1963
|
+
'staged_source_bytes_digest', 'published_source_bytes_digest',
|
|
1964
|
+
'archived_source_bytes_digest', 'entry_digest'])
|
|
1965
|
+
|| entry.operation_index !== index || entry.task_id !== operation.task_id
|
|
1966
|
+
|| entry.disposition !== operation.disposition || entry.source_ref !== operation.source_ref
|
|
1967
|
+
|| entry.published_ref !== operation.source_ref
|
|
1968
|
+
|| entry.archive_ref !== todoCutoverArchiveSourceRef(revision.source_cutover_batch, index)
|
|
1969
|
+
|| entry.replacement !== operation.live_replacement
|
|
1970
|
+
|| entry.staged_source_bytes_digest !== operation.source_digest
|
|
1971
|
+
|| !isTodoDigest(entry.published_source_bytes_digest)
|
|
1972
|
+
|| entry.archived_source_bytes_digest !== operation.source_digest
|
|
1973
|
+
|| entry.entry_digest !== todoSelfDigest(entry, 'entry_digest')) return false;
|
|
1974
|
+
}
|
|
1975
|
+
const expectedRootDigest = digestTodoArtifact(sourceCutoverArchiveRootList(revision, value.entries));
|
|
1976
|
+
const stagingRef = value.entries[0].staging_ref.replace(/#L[1-9]\d*$/u, '');
|
|
1977
|
+
const expectedStagingRef = `${STORE_ROOT_REF}/transactions/phase-v3/${revision.plan_key}/${revision.desired_plan.plan_version}/source-originals.md`;
|
|
1978
|
+
return stagingRef === expectedStagingRef
|
|
1979
|
+
&& value.entries.every((entry, index) => entry.staging_ref === `${stagingRef}#L${index + 1}`)
|
|
1980
|
+
&& value.archive_root_list_digest === expectedRootDigest
|
|
1981
|
+
&& value.cleanup_binding_digest
|
|
1982
|
+
=== digestTodoArtifact(sourceCutoverCleanupBinding(revision, stagingRef));
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
async function verifyPhaseV3SourceReceipt(repoRoot, revision, receipt) {
|
|
1986
|
+
if (!validSourceCutoverReceipt(receipt, revision)) return false;
|
|
1987
|
+
const stagingRef = receipt.entries[0].staging_ref.replace(/#L[1-9]\d*$/u, '');
|
|
1988
|
+
if (await exactFileOrNull(path.resolve(repoRoot, stagingRef)) !== null) return false;
|
|
1989
|
+
for (const [index, entry] of receipt.entries.entries()) {
|
|
1990
|
+
const source = parseTodoSourceRef(entry.published_ref);
|
|
1991
|
+
const archive = parseTodoSourceRef(entry.archive_ref);
|
|
1992
|
+
if (source === null || archive === null) return false;
|
|
1993
|
+
await safeRepoFile(repoRoot, source.path);
|
|
1994
|
+
await safeRepoFile(repoRoot, archive.path);
|
|
1995
|
+
const publishedBytes = await sourceItemBytes(repoRoot, entry.published_ref);
|
|
1996
|
+
const archivedBytes = await sourceItemBytes(repoRoot, entry.archive_ref);
|
|
1997
|
+
if (sha256Bytes(publishedBytes) !== entry.published_source_bytes_digest
|
|
1998
|
+
|| sha256Bytes(archivedBytes) !== entry.archived_source_bytes_digest
|
|
1999
|
+
|| entry.published_source_bytes_digest !== sha256Bytes(phaseV3PublishedSourceBytes(
|
|
2000
|
+
revision.source_cutover_batch.operations[index], archivedBytes))) return false;
|
|
2001
|
+
}
|
|
2002
|
+
return true;
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
function validPhaseRevisionCommitReceipt(value, revision, descriptor, genesis, sourceReceipt) {
|
|
2006
|
+
return exactRecord(value, ['schema', 'project_id', 'plan_key', 'plan_version', 'revision_digest',
|
|
2007
|
+
'committed_member_digest', 'active_plan_digest', 'journal_genesis_digest',
|
|
2008
|
+
'reconciliation_digest', 'source_cutover_receipt_digest', 'committed_at', 'receipt_digest'])
|
|
2009
|
+
&& value.schema === 'lattice.phase_revision_commit_receipt.v1'
|
|
2010
|
+
&& value.project_id === revision.project_id && value.plan_key === revision.plan_key
|
|
2011
|
+
&& value.plan_version === revision.desired_plan.plan_version
|
|
2012
|
+
&& value.revision_digest === revision.revision_digest
|
|
2013
|
+
&& value.committed_member_digest === digestTodoArtifact(descriptor)
|
|
2014
|
+
&& value.active_plan_digest === revision.desired_plan.plan_digest
|
|
2015
|
+
&& value.journal_genesis_digest === genesis.event_digest
|
|
2016
|
+
&& value.reconciliation_digest === revision.reconciliation.reconciliation_digest
|
|
2017
|
+
&& value.source_cutover_receipt_digest === sourceReceipt.receipt_digest
|
|
2018
|
+
&& isStrictTodoTimestamp(value.committed_at)
|
|
2019
|
+
&& value.receipt_digest === todoSelfDigest(value, 'receipt_digest');
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
function revisionResult(revision, genesis, { recovered = false } = {}) {
|
|
2023
|
+
const result = {
|
|
2024
|
+
schema: revision.schema === 'lattice.todo_revision.v2'
|
|
2025
|
+
? 'lattice.todo_revise_result.v2' : 'lattice.todo_revise_result.v1',
|
|
2026
|
+
project_id: revision.project_id,
|
|
2027
|
+
plan_key: revision.plan_key, predecessor_plan_digest: revision.predecessor.plan_digest,
|
|
2028
|
+
predecessor_journal_head_digest: revision.predecessor.journal_head_digest,
|
|
2029
|
+
plan_version: revision.desired_plan.plan_version, plan_digest: revision.desired_plan.plan_digest,
|
|
2030
|
+
topology_digest: revision.desired_plan.topology_digest, journal_head_digest: genesis.event_digest,
|
|
2031
|
+
revision_digest: revision.revision_digest,
|
|
2032
|
+
reconciliation_digest: revision.reconciliation.reconciliation_digest,
|
|
2033
|
+
...(revision.schema === 'lattice.todo_revision.v2' ? { source_cutover: {
|
|
2034
|
+
batch_id: revision.source_cutover_batch.batch_id,
|
|
2035
|
+
operation_count: revision.source_cutover_batch.operations.length,
|
|
2036
|
+
archive_ref: revision.source_cutover_batch.archive_ref,
|
|
2037
|
+
recovered,
|
|
2038
|
+
} } : {}),
|
|
2039
|
+
result_digest: '',
|
|
2040
|
+
};
|
|
2041
|
+
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
2042
|
+
return result;
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
async function exactFileOrNull(absolute) {
|
|
2046
|
+
try {
|
|
2047
|
+
const state = await lstat(absolute);
|
|
2048
|
+
if (state.isSymbolicLink() || !state.isFile()) fail('REVISION_CONFLICT', 'revision_artifact_unsafe');
|
|
2049
|
+
return readFile(absolute);
|
|
2050
|
+
} catch (error) {
|
|
2051
|
+
if (error instanceof TodoStoreError) throw error;
|
|
2052
|
+
if (error?.code === 'ENOENT') return null;
|
|
2053
|
+
throw error;
|
|
2054
|
+
}
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
async function ensureSafeStoreDirectory(repoRoot, target) {
|
|
2058
|
+
const requestedStoreRoot = path.resolve(repoRoot, STORE_ROOT_REF);
|
|
2059
|
+
const requested = path.resolve(target);
|
|
2060
|
+
if (requested !== requestedStoreRoot && !requested.startsWith(`${requestedStoreRoot}${path.sep}`)) {
|
|
2061
|
+
fail('REVISION_CONFLICT', 'revision_directory_outside_store');
|
|
2062
|
+
}
|
|
2063
|
+
const canonicalRepoRoot = await realpath(repoRoot);
|
|
2064
|
+
const storeRoot = path.resolve(canonicalRepoRoot, STORE_ROOT_REF);
|
|
2065
|
+
const absolute = path.resolve(storeRoot, path.relative(requestedStoreRoot, requested));
|
|
2066
|
+
let current = storeRoot;
|
|
2067
|
+
const parts = path.relative(storeRoot, absolute).split(path.sep).filter(Boolean);
|
|
2068
|
+
for (const part of ['', ...parts]) {
|
|
2069
|
+
if (part !== '') current = path.join(current, part);
|
|
2070
|
+
let state;
|
|
2071
|
+
try { state = await lstat(current); }
|
|
2072
|
+
catch (error) {
|
|
2073
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
2074
|
+
await mkdir(current, { mode: 0o700 });
|
|
2075
|
+
await fsyncDirectory(path.dirname(current));
|
|
2076
|
+
state = await lstat(current);
|
|
2077
|
+
}
|
|
2078
|
+
if (state.isSymbolicLink() || !state.isDirectory() || await realpath(current) !== current) {
|
|
2079
|
+
fail('REVISION_CONFLICT', 'revision_directory_unsafe');
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
function parseRevisionMarker(bytes) {
|
|
2085
|
+
return parseCanonicalJsonLine(bytes, {
|
|
2086
|
+
code: 'REVISION_CONFLICT', reason: 'revision_marker_invalid',
|
|
2087
|
+
maxBytes: TODO_LIMITS.snapshotBytes,
|
|
2088
|
+
validate: (value) => exactRecord(value, ['schema', 'revision', 'genesis'])
|
|
2089
|
+
&& value.schema === 'lattice.todo_revision_transaction.v1'
|
|
2090
|
+
&& validateTodoRevision(value.revision) && validateTodoEvent(value.genesis),
|
|
2091
|
+
});
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
function parsePhaseRevisionMarker(bytes) {
|
|
2095
|
+
return parseCanonicalJsonLine(bytes, {
|
|
2096
|
+
code: 'REVISION_CONFLICT', reason: 'phase_revision_marker_invalid',
|
|
2097
|
+
maxBytes: TODO_LIMITS.snapshotBytes,
|
|
2098
|
+
validate: (value) => exactRecord(value, ['schema', 'revision', 'genesis'])
|
|
2099
|
+
&& value.schema === 'lattice.phase_todo_revision_transaction.v1'
|
|
2100
|
+
&& validatePhaseTodoRevision(value.revision) && validateTodoEvent(value.genesis),
|
|
2101
|
+
});
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
function parseRevisionSetMarker(bytes) {
|
|
2105
|
+
return parseCanonicalJsonLine(bytes, {
|
|
2106
|
+
code: 'REVISION_CONFLICT', reason: 'revision_set_marker_invalid',
|
|
2107
|
+
maxBytes: TODO_LIMITS.snapshotBytes,
|
|
2108
|
+
validate: (value) => exactRecord(value, [
|
|
2109
|
+
'schema', 'project_id', 'revision_set_digest', 'entries',
|
|
2110
|
+
]) && ['lattice.todo_revision_set_transaction.v1',
|
|
2111
|
+
'lattice.todo_revision_set_transaction.v2'].includes(value.schema)
|
|
2112
|
+
&& isTodoIdentifier(value.project_id) && isTodoDigest(value.revision_set_digest)
|
|
2113
|
+
&& Array.isArray(value.entries) && value.entries.length > 0
|
|
2114
|
+
&& value.entries.every((entry) => {
|
|
2115
|
+
const phase = ['lattice.phase_todo_revision.v1', 'lattice.phase_todo_revision.v2']
|
|
2116
|
+
.includes(entry?.revision?.schema);
|
|
2117
|
+
return exactRecord(entry, phase && value.schema.endsWith('.v2') ? [
|
|
2118
|
+
'revision', 'state_migration', 'phase_state_migration', 'genesis',
|
|
2119
|
+
] : ['revision', 'state_migration', 'genesis'])
|
|
2120
|
+
&& (phase ? validatePhaseTodoRevision(entry.revision) : validateTodoRevision(entry.revision))
|
|
2121
|
+
&& Array.isArray(entry.state_migration)
|
|
2122
|
+
&& (!phase || Array.isArray(entry.phase_state_migration))
|
|
2123
|
+
&& validateTodoEvent(entry.genesis);
|
|
2124
|
+
}),
|
|
2125
|
+
});
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
async function rejectCompetingRevisionTransaction(repoRoot, revision) {
|
|
2129
|
+
const root = path.join(repoRoot, STORE_ROOT_REF, 'transactions', 'revisions', revision.plan_key);
|
|
2130
|
+
let names;
|
|
2131
|
+
try {
|
|
2132
|
+
const state = await lstat(root);
|
|
2133
|
+
if (state.isSymbolicLink() || !state.isDirectory()) {
|
|
2134
|
+
fail('REVISION_CONFLICT', 'revision_transaction_root_unsafe');
|
|
2135
|
+
}
|
|
2136
|
+
names = await readdir(root);
|
|
2137
|
+
} catch (error) {
|
|
2138
|
+
if (error instanceof TodoStoreError) throw error;
|
|
2139
|
+
if (error?.code === 'ENOENT') return;
|
|
2140
|
+
throw error;
|
|
2141
|
+
}
|
|
2142
|
+
for (const name of names) {
|
|
2143
|
+
if (!isTodoIdentifier(name)) fail('REVISION_CONFLICT', 'revision_transaction_entry_invalid');
|
|
2144
|
+
const markerBytes = await exactFileOrNull(path.join(root, name, 'marker.json'));
|
|
2145
|
+
if (markerBytes === null) fail('REVISION_CONFLICT', 'revision_marker_missing');
|
|
2146
|
+
const marker = parseRevisionMarker(markerBytes);
|
|
2147
|
+
if (canonicalizeTodoArtifact(marker.revision.predecessor)
|
|
2148
|
+
=== canonicalizeTodoArtifact(revision.predecessor)
|
|
2149
|
+
&& marker.revision.revision_digest !== revision.revision_digest) {
|
|
2150
|
+
fail('REVISION_CONFLICT', 'revision_bytes_conflict');
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
|
|
2155
|
+
async function rejectCompetingPhaseV3Transaction(repoRoot, revision) {
|
|
2156
|
+
const root = path.join(repoRoot, STORE_ROOT_REF, 'transactions', 'phase-v3', revision.plan_key);
|
|
2157
|
+
let names;
|
|
2158
|
+
try {
|
|
2159
|
+
const state = await lstat(root);
|
|
2160
|
+
const canonicalRepoRoot = await realpath(repoRoot);
|
|
2161
|
+
const canonicalRoot = path.resolve(canonicalRepoRoot, path.relative(repoRoot, root));
|
|
2162
|
+
if (state.isSymbolicLink() || !state.isDirectory() || await realpath(root) !== canonicalRoot) {
|
|
2163
|
+
fail('REVISION_CONFLICT', 'revision_transaction_root_unsafe');
|
|
2164
|
+
}
|
|
2165
|
+
names = await readdir(root);
|
|
2166
|
+
} catch (error) {
|
|
2167
|
+
if (error instanceof TodoStoreError) throw error;
|
|
2168
|
+
if (error?.code === 'ENOENT') return;
|
|
2169
|
+
throw error;
|
|
2170
|
+
}
|
|
2171
|
+
for (const name of names) {
|
|
2172
|
+
if (!isTodoIdentifier(name)) fail('REVISION_CONFLICT', 'revision_transaction_entry_invalid');
|
|
2173
|
+
const directory = path.join(root, name);
|
|
2174
|
+
const state = await lstat(directory);
|
|
2175
|
+
const canonicalDirectory = path.join(await realpath(root), name);
|
|
2176
|
+
if (state.isSymbolicLink() || !state.isDirectory()
|
|
2177
|
+
|| await realpath(directory) !== canonicalDirectory) {
|
|
2178
|
+
fail('REVISION_CONFLICT', 'revision_transaction_entry_invalid');
|
|
2179
|
+
}
|
|
2180
|
+
const markerBytes = await exactFileOrNull(path.join(directory, 'marker.json'));
|
|
2181
|
+
if (markerBytes === null) {
|
|
2182
|
+
if (name !== revision.desired_plan.plan_version) {
|
|
2183
|
+
fail('REVISION_CONFLICT', 'revision_marker_missing');
|
|
2184
|
+
}
|
|
2185
|
+
for (const artifact of await readdir(directory, { withFileTypes: true })) {
|
|
2186
|
+
if (!artifact.isFile() || artifact.isSymbolicLink()
|
|
2187
|
+
|| !/^\.marker\.json\.\d+\.[0-9a-f]{12}\.tmp$/u.test(artifact.name)) {
|
|
2188
|
+
fail('REVISION_CONFLICT', 'revision_marker_missing');
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
continue;
|
|
2192
|
+
}
|
|
2193
|
+
const marker = parsePhaseRevisionMarker(markerBytes);
|
|
2194
|
+
if (canonicalizeTodoArtifact(marker.revision.predecessor)
|
|
2195
|
+
=== canonicalizeTodoArtifact(revision.predecessor)
|
|
2196
|
+
&& marker.revision.revision_digest !== revision.revision_digest) {
|
|
2197
|
+
fail('REVISION_CONFLICT', 'revision_bytes_conflict');
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
async function publishRevisionArtifact(staged, finalAbsolute, expected) {
|
|
2203
|
+
const finalBytes = await exactFileOrNull(finalAbsolute);
|
|
2204
|
+
if (finalBytes !== null) {
|
|
2205
|
+
if (!finalBytes.equals(expected)) fail('REVISION_CONFLICT', 'revision_bytes_conflict');
|
|
2206
|
+
return;
|
|
2207
|
+
}
|
|
2208
|
+
let stagedBytes = await exactFileOrNull(staged);
|
|
2209
|
+
if (stagedBytes === null) {
|
|
2210
|
+
await atomicWrite(staged, expected);
|
|
2211
|
+
stagedBytes = expected;
|
|
2212
|
+
}
|
|
2213
|
+
if (!stagedBytes.equals(expected)) fail('REVISION_CONFLICT', 'revision_bytes_conflict');
|
|
2214
|
+
await mkdir(path.dirname(finalAbsolute), { recursive: true });
|
|
2215
|
+
await rename(staged, finalAbsolute);
|
|
2216
|
+
await fsyncDirectory(path.dirname(finalAbsolute));
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
function splitSourceLines(bytes) {
|
|
2220
|
+
const lines = [];
|
|
2221
|
+
let start = 0;
|
|
2222
|
+
for (let index = 0; index <= bytes.length; index += 1) {
|
|
2223
|
+
if (index === bytes.length || bytes[index] === 0x0a) {
|
|
2224
|
+
lines.push({ start, end: index, bytes: bytes.subarray(start, index) });
|
|
2225
|
+
start = index + 1;
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
return lines;
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
async function safeRepoFile(repoRoot, ref, { missing = false } = {}) {
|
|
2232
|
+
const canonicalRoot = await realpath(repoRoot);
|
|
2233
|
+
const absolute = path.resolve(canonicalRoot, ref);
|
|
2234
|
+
if (!absolute.startsWith(`${canonicalRoot}${path.sep}`)) {
|
|
2235
|
+
fail('RECONCILIATION_INCOMPLETE', 'source_path_outside_repo', { ref });
|
|
2236
|
+
}
|
|
2237
|
+
let current = canonicalRoot;
|
|
2238
|
+
const parts = path.relative(canonicalRoot, absolute).split(path.sep).filter(Boolean);
|
|
2239
|
+
for (const part of parts.slice(0, -1)) {
|
|
2240
|
+
current = path.join(current, part);
|
|
2241
|
+
let state;
|
|
2242
|
+
try { state = await lstat(current); }
|
|
2243
|
+
catch (error) {
|
|
2244
|
+
if (missing && error?.code === 'ENOENT') return { absolute, missingParent: current };
|
|
2245
|
+
fail('RECONCILIATION_INCOMPLETE', 'source_path_missing', { ref });
|
|
2246
|
+
}
|
|
2247
|
+
if (state.isSymbolicLink() || !state.isDirectory() || await realpath(current) !== current) {
|
|
2248
|
+
fail('RECONCILIATION_INCOMPLETE', 'source_path_symlink', { ref });
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
let state;
|
|
2252
|
+
try { state = await lstat(absolute); }
|
|
2253
|
+
catch (error) {
|
|
2254
|
+
if (missing && error?.code === 'ENOENT') return { absolute, missing: true };
|
|
2255
|
+
fail('RECONCILIATION_INCOMPLETE', 'source_path_missing', { ref });
|
|
2256
|
+
}
|
|
2257
|
+
if (state.isSymbolicLink() || !state.isFile() || state.nlink !== 1
|
|
2258
|
+
|| await realpath(absolute) !== absolute) {
|
|
2259
|
+
fail('RECONCILIATION_INCOMPLETE', 'source_path_not_regular', { ref });
|
|
2260
|
+
}
|
|
2261
|
+
return { absolute, state, missing: false };
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
async function ensureSafeRepoParent(repoRoot, absolute) {
|
|
2265
|
+
const canonicalRoot = await realpath(repoRoot);
|
|
2266
|
+
if (!absolute.startsWith(`${canonicalRoot}${path.sep}`)) {
|
|
2267
|
+
fail('REVISION_CONFLICT', 'source_path_outside_repo');
|
|
2268
|
+
}
|
|
2269
|
+
let current = canonicalRoot;
|
|
2270
|
+
for (const part of path.relative(canonicalRoot, path.dirname(absolute)).split(path.sep).filter(Boolean)) {
|
|
2271
|
+
current = path.join(current, part);
|
|
2272
|
+
try {
|
|
2273
|
+
const state = await lstat(current);
|
|
2274
|
+
if (state.isSymbolicLink() || !state.isDirectory() || await realpath(current) !== current) {
|
|
2275
|
+
fail('REVISION_CONFLICT', 'source_directory_unsafe');
|
|
2276
|
+
}
|
|
2277
|
+
} catch (error) {
|
|
2278
|
+
if (error instanceof TodoStoreError) throw error;
|
|
2279
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
2280
|
+
await mkdir(current, { mode: 0o700 });
|
|
2281
|
+
await fsyncDirectory(path.dirname(current));
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
function sourceCutoverArchiveBytes(revision, originals) {
|
|
2287
|
+
const header = [
|
|
2288
|
+
'# Lattice ToDo archive',
|
|
2289
|
+
`Plan: ${revision.plan_key}`,
|
|
2290
|
+
`Batch: ${revision.source_cutover_batch.batch_id}`,
|
|
2291
|
+
`Revision: ${revision.revision_digest}`,
|
|
2292
|
+
'',
|
|
2293
|
+
].join('\n');
|
|
2294
|
+
return Buffer.concat([Buffer.from(`${header}\n`, 'utf8'),
|
|
2295
|
+
...originals.flatMap((line) => [line, Buffer.from('\n')])]);
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
async function buildSourceCutoverImages(repoRoot, revision) {
|
|
2299
|
+
const batch = revision.source_cutover_batch;
|
|
2300
|
+
const archiveState = await safeRepoFile(repoRoot, batch.archive_ref, { missing: true });
|
|
2301
|
+
if (!archiveState.missing && !archiveState.missingParent) {
|
|
2302
|
+
fail('REVISION_CONFLICT', 'source_cutover_archive_exists', { archive_ref: batch.archive_ref });
|
|
2303
|
+
}
|
|
2304
|
+
const groups = new Map();
|
|
2305
|
+
const originals = [];
|
|
2306
|
+
for (const operation of batch.operations) {
|
|
2307
|
+
const parsed = parseTodoSourceRef(operation.source_ref);
|
|
2308
|
+
const state = await safeRepoFile(repoRoot, parsed.path);
|
|
2309
|
+
let group = groups.get(parsed.path);
|
|
2310
|
+
if (!group) {
|
|
2311
|
+
const before = await readFile(state.absolute);
|
|
2312
|
+
decodeUtf8(before, 'RECONCILIATION_INCOMPLETE', 'source_invalid_utf8');
|
|
2313
|
+
group = { ref: parsed.path, absolute: state.absolute, before,
|
|
2314
|
+
mode: state.state.mode & 0o7777, replacements: [] };
|
|
2315
|
+
groups.set(parsed.path, group);
|
|
2316
|
+
}
|
|
2317
|
+
const line = splitSourceLines(group.before)[parsed.line - 1]?.bytes;
|
|
2318
|
+
if (line === undefined) fail('RECONCILIATION_INCOMPLETE', 'source_line_missing', {
|
|
2319
|
+
source_ref: operation.source_ref,
|
|
2320
|
+
});
|
|
2321
|
+
if (sha256Bytes(line) !== operation.source_digest) {
|
|
2322
|
+
fail('RECONCILIATION_INCOMPLETE', 'source_digest_mismatch', { source_ref: operation.source_ref });
|
|
2323
|
+
}
|
|
2324
|
+
if (markdownCheckboxState(line) === null) {
|
|
2325
|
+
fail('RECONCILIATION_INCOMPLETE', 'source_item_not_todo', { source_ref: operation.source_ref });
|
|
2326
|
+
}
|
|
2327
|
+
if (!liveReplacementPreservesListStructure(line, operation.live_replacement)) {
|
|
2328
|
+
fail('RECONCILIATION_INCOMPLETE', 'live_replacement_breaks_list_structure', {
|
|
2329
|
+
source_ref: operation.source_ref,
|
|
2330
|
+
});
|
|
2331
|
+
}
|
|
2332
|
+
originals.push(line);
|
|
2333
|
+
const replacement = Buffer.from(`${operation.live_replacement}${line.at(-1) === 0x0d ? '\r' : ''}`, 'utf8');
|
|
2334
|
+
group.replacements.push({ line: parsed.line, replacement });
|
|
2335
|
+
}
|
|
2336
|
+
const files = [...groups.values()].sort((left, right) => left.ref.localeCompare(right.ref));
|
|
2337
|
+
for (const file of files) {
|
|
2338
|
+
const lines = splitSourceLines(file.before);
|
|
2339
|
+
let after = file.before;
|
|
2340
|
+
for (const replacement of file.replacements.sort((left, right) => right.line - left.line)) {
|
|
2341
|
+
const target = lines[replacement.line - 1];
|
|
2342
|
+
after = Buffer.concat([after.subarray(0, target.start), replacement.replacement,
|
|
2343
|
+
after.subarray(target.end)]);
|
|
2344
|
+
}
|
|
2345
|
+
file.after = after;
|
|
2346
|
+
}
|
|
2347
|
+
return { files, archive: { ref: batch.archive_ref, absolute: archiveState.absolute,
|
|
2348
|
+
bytes: sourceCutoverArchiveBytes(revision, originals) } };
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
async function stageSourceCutover(transaction, revision, images) {
|
|
2352
|
+
const files = [];
|
|
2353
|
+
for (const [index, file] of images.files.entries()) {
|
|
2354
|
+
const beforeName = `source-${index}-before.bin`;
|
|
2355
|
+
const afterName = `source-${index}-after.bin`;
|
|
2356
|
+
await atomicWrite(path.join(transaction, beforeName), file.before);
|
|
2357
|
+
await atomicWrite(path.join(transaction, afterName), file.after);
|
|
2358
|
+
files.push({ ref: file.ref, before: beforeName, after: afterName, mode: file.mode,
|
|
2359
|
+
before_digest: sha256Bytes(file.before), after_digest: sha256Bytes(file.after) });
|
|
2360
|
+
}
|
|
2361
|
+
await atomicWrite(path.join(transaction, 'source-archive.bin'), images.archive.bytes);
|
|
2362
|
+
const descriptor = {
|
|
2363
|
+
schema: 'lattice.todo_source_cutover_stage.v1', revision_digest: revision.revision_digest,
|
|
2364
|
+
archive_ref: images.archive.ref, archive_digest: sha256Bytes(images.archive.bytes), files,
|
|
2365
|
+
};
|
|
2366
|
+
await atomicWrite(path.join(transaction, 'source-cutover.json'), canonicalLine(descriptor));
|
|
2367
|
+
return descriptor;
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
async function loadSourceCutoverStage(repoRoot, transaction, revision) {
|
|
2371
|
+
const bytes = await exactFileOrNull(path.join(transaction, 'source-cutover.json'));
|
|
2372
|
+
if (bytes === null) fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'source_cutover_stage_missing');
|
|
2373
|
+
let descriptor;
|
|
2374
|
+
try { descriptor = JSON.parse(decodeUtf8(bytes, 'SOURCE_CUTOVER_RECOVERY_REQUIRED', 'stage_invalid')); }
|
|
2375
|
+
catch (error) {
|
|
2376
|
+
if (error instanceof TodoStoreError) throw error;
|
|
2377
|
+
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'stage_invalid');
|
|
2378
|
+
}
|
|
2379
|
+
if (!bytes.equals(canonicalLine(descriptor))) {
|
|
2380
|
+
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'stage_invalid');
|
|
2381
|
+
}
|
|
2382
|
+
const expectedRefs = [...new Set(revision.source_cutover_batch.operations.map((operation) => (
|
|
2383
|
+
parseTodoSourceRef(operation.source_ref).path)))].sort();
|
|
2384
|
+
if (!exactRecord(descriptor, [
|
|
2385
|
+
'schema', 'revision_digest', 'archive_ref', 'archive_digest', 'files',
|
|
2386
|
+
]) || descriptor.schema !== 'lattice.todo_source_cutover_stage.v1'
|
|
2387
|
+
|| descriptor.revision_digest !== revision.revision_digest
|
|
2388
|
+
|| descriptor.archive_ref !== revision.source_cutover_batch.archive_ref
|
|
2389
|
+
|| !isTodoDigest(descriptor.archive_digest) || !Array.isArray(descriptor.files)
|
|
2390
|
+
|| descriptor.files.length !== expectedRefs.length) {
|
|
2391
|
+
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'stage_invalid');
|
|
2392
|
+
}
|
|
2393
|
+
const files = [];
|
|
2394
|
+
for (const [index, file] of descriptor.files.entries()) {
|
|
2395
|
+
const expectedBefore = `source-${index}-before.bin`;
|
|
2396
|
+
const expectedAfter = `source-${index}-after.bin`;
|
|
2397
|
+
if (!exactRecord(file, [
|
|
2398
|
+
'ref', 'before', 'after', 'mode', 'before_digest', 'after_digest',
|
|
2399
|
+
]) || file.ref !== expectedRefs[index] || file.before !== expectedBefore
|
|
2400
|
+
|| file.after !== expectedAfter || !isTodoDigest(file.before_digest)
|
|
2401
|
+
|| !isTodoDigest(file.after_digest)) {
|
|
2402
|
+
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'source_cutover_stage_invalid');
|
|
2403
|
+
}
|
|
2404
|
+
const beforeAbsolute = path.resolve(transaction, file.before);
|
|
2405
|
+
const afterAbsolute = path.resolve(transaction, file.after);
|
|
2406
|
+
if (path.dirname(beforeAbsolute) !== transaction || path.dirname(afterAbsolute) !== transaction) {
|
|
2407
|
+
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'source_cutover_stage_invalid');
|
|
2408
|
+
}
|
|
2409
|
+
const before = await exactFileOrNull(beforeAbsolute);
|
|
2410
|
+
const after = await exactFileOrNull(afterAbsolute);
|
|
2411
|
+
if (before === null || after === null || sha256Bytes(before) !== file.before_digest
|
|
2412
|
+
|| sha256Bytes(after) !== file.after_digest) {
|
|
2413
|
+
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'source_cutover_stage_invalid');
|
|
2414
|
+
}
|
|
2415
|
+
if (!Number.isSafeInteger(file.mode) || file.mode < 0 || file.mode > 0o7777) {
|
|
2416
|
+
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'source_cutover_stage_invalid');
|
|
2417
|
+
}
|
|
2418
|
+
const state = await safeRepoFile(repoRoot, file.ref);
|
|
2419
|
+
if ((state.state.mode & 0o7777) !== file.mode) {
|
|
2420
|
+
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'source_cutover_stage_invalid');
|
|
2421
|
+
}
|
|
2422
|
+
const lines = splitSourceLines(before);
|
|
2423
|
+
let expectedAfterBytes = before;
|
|
2424
|
+
const replacements = [];
|
|
2425
|
+
for (const operation of revision.source_cutover_batch.operations) {
|
|
2426
|
+
const source = parseTodoSourceRef(operation.source_ref);
|
|
2427
|
+
if (source.path !== file.ref) continue;
|
|
2428
|
+
const line = lines[source.line - 1];
|
|
2429
|
+
if (line === undefined || sha256Bytes(line.bytes) !== operation.source_digest
|
|
2430
|
+
|| markdownCheckboxState(line.bytes) === null
|
|
2431
|
+
|| !liveReplacementPreservesListStructure(line.bytes, operation.live_replacement)) {
|
|
2432
|
+
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'source_cutover_stage_invalid');
|
|
2433
|
+
}
|
|
2434
|
+
replacements.push({ line: source.line, start: line.start, end: line.end,
|
|
2435
|
+
bytes: phaseV3PublishedSourceBytes(operation, line.bytes) });
|
|
2436
|
+
}
|
|
2437
|
+
for (const replacement of replacements.sort((left, right) => right.line - left.line)) {
|
|
2438
|
+
expectedAfterBytes = Buffer.concat([expectedAfterBytes.subarray(0, replacement.start),
|
|
2439
|
+
replacement.bytes, expectedAfterBytes.subarray(replacement.end)]);
|
|
2440
|
+
}
|
|
2441
|
+
if (!after.equals(expectedAfterBytes)) {
|
|
2442
|
+
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'source_cutover_stage_invalid');
|
|
2443
|
+
}
|
|
2444
|
+
files.push({ ...file, absolute: state.absolute, beforeAbsolute, afterAbsolute, before, after });
|
|
2445
|
+
}
|
|
2446
|
+
const archiveBytes = await exactFileOrNull(path.join(transaction, 'source-archive.bin'));
|
|
2447
|
+
const originalByRef = new Map(files.map((file) => [file.ref, splitSourceLines(file.before)]));
|
|
2448
|
+
const originals = revision.source_cutover_batch.operations.map((operation) => {
|
|
2449
|
+
const source = parseTodoSourceRef(operation.source_ref);
|
|
2450
|
+
return originalByRef.get(source.path)?.[source.line - 1]?.bytes;
|
|
2451
|
+
});
|
|
2452
|
+
if (archiveBytes === null || sha256Bytes(archiveBytes) !== descriptor.archive_digest
|
|
2453
|
+
|| originals.some((line) => line === undefined)
|
|
2454
|
+
|| !archiveBytes.equals(sourceCutoverArchiveBytes(revision, originals))) {
|
|
2455
|
+
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'source_cutover_stage_invalid');
|
|
2456
|
+
}
|
|
2457
|
+
const archiveAbsolute = path.resolve(await realpath(repoRoot), descriptor.archive_ref);
|
|
2458
|
+
return { descriptor, files, archive: { absolute: archiveAbsolute, bytes: archiveBytes } };
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
async function publishSourceCutover(repoRoot, staged) {
|
|
2462
|
+
await ensureSafeRepoParent(repoRoot, staged.archive.absolute);
|
|
2463
|
+
const existingArchive = await exactFileOrNull(staged.archive.absolute);
|
|
2464
|
+
if (existingArchive === null) await atomicWriteMode(staged.archive.absolute, staged.archive.bytes, 0o644);
|
|
2465
|
+
else if (!existingArchive.equals(staged.archive.bytes)) {
|
|
2466
|
+
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'archive_bytes_conflict');
|
|
2467
|
+
}
|
|
2468
|
+
for (const file of staged.files) {
|
|
2469
|
+
const current = await readFile(file.absolute);
|
|
2470
|
+
if (current.equals(file.after)) continue;
|
|
2471
|
+
if (!current.equals(file.before)) {
|
|
2472
|
+
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'source_bytes_conflict', { source_ref: file.ref });
|
|
2473
|
+
}
|
|
2474
|
+
await atomicWriteMode(file.absolute, file.after, file.mode);
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
async function rollbackSourceCutover(staged, barrierAbsolute, { removeBarrier = true } = {}) {
|
|
2479
|
+
for (const file of staged.files) {
|
|
2480
|
+
const current = await readFile(file.absolute);
|
|
2481
|
+
if (current.equals(file.after)) await atomicWriteMode(file.absolute, file.before, file.mode);
|
|
2482
|
+
else if (!current.equals(file.before)) return false;
|
|
2483
|
+
}
|
|
2484
|
+
const archive = await exactFileOrNull(staged.archive.absolute);
|
|
2485
|
+
if (archive !== null) {
|
|
2486
|
+
if (!archive.equals(staged.archive.bytes)) return false;
|
|
2487
|
+
await rm(staged.archive.absolute);
|
|
2488
|
+
await fsyncDirectory(path.dirname(staged.archive.absolute));
|
|
2489
|
+
}
|
|
2490
|
+
if (removeBarrier) {
|
|
2491
|
+
await rm(barrierAbsolute, { force: true });
|
|
2492
|
+
await fsyncDirectory(path.dirname(barrierAbsolute));
|
|
2493
|
+
}
|
|
2494
|
+
return true;
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
async function cleanupPhaseV3Staging(transaction, staged) {
|
|
2498
|
+
const known = new Set(['source-archive.bin', 'source-cutover.json', 'source-originals.md']);
|
|
2499
|
+
await rm(path.join(transaction, 'source-cutover.json'), { force: true });
|
|
2500
|
+
await fsyncDirectory(transaction);
|
|
2501
|
+
for (const file of staged.files ?? []) {
|
|
2502
|
+
if (path.dirname(file.beforeAbsolute) !== transaction || path.dirname(file.afterAbsolute) !== transaction) {
|
|
2503
|
+
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'source_cutover_stage_invalid');
|
|
2504
|
+
}
|
|
2505
|
+
await rm(file.beforeAbsolute, { force: true });
|
|
2506
|
+
await rm(file.afterAbsolute, { force: true });
|
|
2507
|
+
}
|
|
2508
|
+
for (const name of await readdir(transaction)) {
|
|
2509
|
+
if (known.has(name) || /^source-\d+-(?:before|after)\.bin$/u.test(name)) {
|
|
2510
|
+
await rm(path.join(transaction, name), { force: true });
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
await fsyncDirectory(transaction);
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
async function predecessorSourceInventory(repoRoot, previous) {
|
|
2517
|
+
if (previous.revision?.source_inventory !== undefined) return previous.revision.source_inventory;
|
|
2518
|
+
const versionsRoot = path.join(repoRoot, STORE_ROOT_REF, 'plans', previous.plan.plan_key);
|
|
2519
|
+
const plansByDigest = new Map();
|
|
2520
|
+
for (const entry of await readdir(versionsRoot, { withFileTypes: true })) {
|
|
2521
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
|
2522
|
+
const planBytes = await exactFileOrNull(path.join(versionsRoot, entry.name, 'plan.json'));
|
|
2523
|
+
if (planBytes === null) continue;
|
|
2524
|
+
let plan;
|
|
2525
|
+
try { plan = JSON.parse(decodeUtf8(planBytes, 'STORE_INCONSISTENT', 'plan_invalid')); }
|
|
2526
|
+
catch { continue; }
|
|
2527
|
+
if (!planBytes.equals(canonicalLine(plan)) || !validateTodoPlan(plan)
|
|
2528
|
+
|| plan.plan_key !== previous.plan.plan_key) continue;
|
|
2529
|
+
plansByDigest.set(plan.plan_digest, { plan, directory: path.join(versionsRoot, entry.name) });
|
|
2530
|
+
}
|
|
2531
|
+
const visited = new Set();
|
|
2532
|
+
let digest = previous.plan.predecessor_plan_digest;
|
|
2533
|
+
while (digest !== null && !visited.has(digest) && visited.size < 512) {
|
|
2534
|
+
visited.add(digest);
|
|
2535
|
+
const candidate = plansByDigest.get(digest);
|
|
2536
|
+
if (candidate === undefined) break;
|
|
2537
|
+
const revisionBytes = await exactFileOrNull(path.join(candidate.directory, 'revision.json'));
|
|
2538
|
+
if (revisionBytes !== null) {
|
|
2539
|
+
let revision;
|
|
2540
|
+
try { revision = JSON.parse(decodeUtf8(revisionBytes,
|
|
2541
|
+
'STORE_INCONSISTENT', 'revision_invalid')); } catch { revision = null; }
|
|
2542
|
+
if (revision !== null && revisionBytes.equals(canonicalLine(revision))
|
|
2543
|
+
&& (validateTodoRevision(revision) || validatePhaseTodoRevision(revision))
|
|
2544
|
+
&& revision.project_id === candidate.plan.project_id
|
|
2545
|
+
&& revision.plan_key === candidate.plan.plan_key
|
|
2546
|
+
&& canonicalizeTodoArtifact(revision.desired_plan)
|
|
2547
|
+
=== canonicalizeTodoArtifact(candidate.plan)
|
|
2548
|
+
&& revision.source_inventory !== undefined) return revision.source_inventory;
|
|
2549
|
+
}
|
|
2550
|
+
digest = candidate.plan.predecessor_plan_digest;
|
|
2551
|
+
}
|
|
2552
|
+
return { active: [], excluded_tombstones: [] };
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
function validatePhaseV3SourceInventoryDiff(previousInventory, revision) {
|
|
2556
|
+
const desired = revision.source_inventory;
|
|
2557
|
+
const previousActive = new Map(previousInventory.active.map((entry) => [entry.task_id, entry]));
|
|
2558
|
+
const desiredTombstoneKeys = new Set(desired.excluded_tombstones.map((entry) => (
|
|
2559
|
+
`${entry.source_ref}\0${entry.source_digest}`)));
|
|
2560
|
+
for (const entry of previousInventory.active) {
|
|
2561
|
+
const active = desired.active.find(({ task_id }) => task_id === entry.task_id);
|
|
2562
|
+
const continued = active?.source_ref === entry.source_ref && active.source_digest === entry.source_digest;
|
|
2563
|
+
if (!continued && !desiredTombstoneKeys.has(`${entry.source_ref}\0${entry.source_digest}`)) {
|
|
2564
|
+
fail('REVISION_INVALID', 'predecessor_source_silently_dropped', { task_id: entry.task_id });
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
const previousTombstoneKeys = new Set(previousInventory.excluded_tombstones.map((entry) => (
|
|
2568
|
+
`${entry.source_ref}\0${entry.source_digest}`)));
|
|
2569
|
+
const previousSourceKeys = new Set([...previousTombstoneKeys,
|
|
2570
|
+
...previousInventory.active.map((entry) => `${entry.source_ref}\0${entry.source_digest}`)]);
|
|
2571
|
+
if ([...previousTombstoneKeys].some((key) => !desiredTombstoneKeys.has(key))) {
|
|
2572
|
+
fail('REVISION_INVALID', 'predecessor_source_silently_dropped');
|
|
2573
|
+
}
|
|
2574
|
+
const expected = [
|
|
2575
|
+
...desired.active.filter((entry) => {
|
|
2576
|
+
const old = previousActive.get(entry.task_id);
|
|
2577
|
+
return old?.source_ref !== entry.source_ref || old.source_digest !== entry.source_digest;
|
|
2578
|
+
}).map((entry) => `active\0${entry.task_id}\0${entry.source_ref}\0${entry.source_digest}`),
|
|
2579
|
+
...desired.excluded_tombstones.filter((entry) => (
|
|
2580
|
+
!previousSourceKeys.has(`${entry.source_ref}\0${entry.source_digest}`)
|
|
2581
|
+
)).map((entry) => `excluded\0null\0${entry.source_ref}\0${entry.source_digest}`),
|
|
2582
|
+
].sort();
|
|
2583
|
+
const actual = revision.source_cutover_batch.operations.map((operation, index) => (
|
|
2584
|
+
`${operation.disposition}\0${operation.task_id}\0${todoCutoverArchiveSourceRef(
|
|
2585
|
+
revision.source_cutover_batch, index)}\0${operation.source_digest}`)).sort();
|
|
2586
|
+
if (canonicalizeTodoArtifact(actual) !== canonicalizeTodoArtifact(expected)) {
|
|
2587
|
+
fail('REVISION_INVALID', 'source_cutover_inventory_diff_mismatch');
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
async function applyPhaseTodoRevisionV3(options, revision, repoRoot) {
|
|
2592
|
+
return withLock(repoRoot, async () => {
|
|
2593
|
+
const barrierAbsolute = path.resolve(repoRoot, SOURCE_CUTOVER_BARRIER_REF);
|
|
2594
|
+
const barrierBytes = await exactFileOrNull(barrierAbsolute);
|
|
2595
|
+
let recovering = false;
|
|
2596
|
+
if (barrierBytes !== null) {
|
|
2597
|
+
let barrier;
|
|
2598
|
+
try { barrier = JSON.parse(decodeUtf8(barrierBytes,
|
|
2599
|
+
'SOURCE_CUTOVER_RECOVERY_REQUIRED', 'barrier_invalid')); }
|
|
2600
|
+
catch (error) {
|
|
2601
|
+
if (error instanceof TodoStoreError) throw error;
|
|
2602
|
+
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'barrier_invalid');
|
|
2603
|
+
}
|
|
2604
|
+
if (barrier?.schema !== 'lattice.todo_source_cutover_barrier.v1'
|
|
2605
|
+
|| barrier.revision_digest !== revision.revision_digest) {
|
|
2606
|
+
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'source_cutover_recovery_required');
|
|
2607
|
+
}
|
|
2608
|
+
recovering = true;
|
|
2609
|
+
}
|
|
2610
|
+
const store = await readTodoStore({ repoRoot, forWrite: true, now: options.now,
|
|
2611
|
+
...(recovering ? { allowSourceCutoverRevisionDigest: revision.revision_digest,
|
|
2612
|
+
sourceCutoverRecoveryCapability: SOURCE_CUTOVER_RECOVERY_CAPABILITY } : {}) });
|
|
2613
|
+
const previous = store.members.find(({ descriptor }) => descriptor.plan_key === revision.plan_key);
|
|
2614
|
+
if (!previous || revision.project_id !== store.project_id) fail('STORE_INCONSISTENT', 'plan_not_active');
|
|
2615
|
+
const base = `${STORE_ROOT_REF}/plans/${revision.plan_key}/${revision.desired_plan.plan_version}`;
|
|
2616
|
+
const revisionRef = `${base}/revision.json`; const planRef = `${base}/plan.json`;
|
|
2617
|
+
const journalRef = `${base}/journal/active.jsonl`; const snapshotRef = `${base}/snapshot.json`;
|
|
2618
|
+
const sourceReceiptRef = `${base}/source-cutover-receipt.json`;
|
|
2619
|
+
const commitReceiptRef = `${base}/phase-revision-commit-receipt.json`;
|
|
2620
|
+
const transactionRef = `${STORE_ROOT_REF}/transactions/phase-v3/${revision.plan_key}/${revision.desired_plan.plan_version}`;
|
|
2621
|
+
const transaction = path.resolve(repoRoot, transactionRef);
|
|
2622
|
+
const activeGenesis = previous.journal.events[0];
|
|
2623
|
+
if (previous.plan.plan_digest === revision.desired_plan.plan_digest
|
|
2624
|
+
&& activeGenesis.schema === 'lattice.todo_event.v4'
|
|
2625
|
+
&& activeGenesis.revision_digest === revision.revision_digest) {
|
|
2626
|
+
const sourceReceiptBytes = await exactFileOrNull(path.resolve(repoRoot, sourceReceiptRef));
|
|
2627
|
+
const commitReceiptBytes = await exactFileOrNull(path.resolve(repoRoot, commitReceiptRef));
|
|
2628
|
+
if (sourceReceiptBytes === null || commitReceiptBytes === null
|
|
2629
|
+
|| previous.descriptor.active_revision_digest !== revision.revision_digest) {
|
|
2630
|
+
fail('STORE_INCONSISTENT', 'phase_revision_receipt_missing');
|
|
2631
|
+
}
|
|
2632
|
+
const sourceReceipt = JSON.parse(decodeUtf8(sourceReceiptBytes,
|
|
2633
|
+
'STORE_INCONSISTENT', 'phase_revision_receipt_invalid'));
|
|
2634
|
+
const commitReceipt = JSON.parse(decodeUtf8(commitReceiptBytes,
|
|
2635
|
+
'STORE_INCONSISTENT', 'phase_revision_receipt_invalid'));
|
|
2636
|
+
if (!sourceReceiptBytes.equals(canonicalLine(sourceReceipt))
|
|
2637
|
+
|| !commitReceiptBytes.equals(canonicalLine(commitReceipt))
|
|
2638
|
+
|| !await verifyPhaseV3SourceReceipt(repoRoot, revision, sourceReceipt)
|
|
2639
|
+
|| !validPhaseRevisionCommitReceipt(commitReceipt, revision, previous.descriptor,
|
|
2640
|
+
activeGenesis, sourceReceipt)) fail('STORE_INCONSISTENT', 'phase_revision_receipt_invalid');
|
|
2641
|
+
await rm(barrierAbsolute, { force: true });
|
|
2642
|
+
await rm(transaction, { recursive: true, force: true });
|
|
2643
|
+
return commitReceipt;
|
|
2644
|
+
}
|
|
2645
|
+
if (previous.plan.plan_digest !== revision.predecessor.plan_digest
|
|
2646
|
+
|| previous.plan.plan_version !== revision.predecessor.plan_version
|
|
2647
|
+
|| previous.journal.events.at(-1).event_digest !== revision.predecessor.journal_head_digest) {
|
|
2648
|
+
fail('STORE_WRITE_CONFLICT', 'stale_predecessor');
|
|
2649
|
+
}
|
|
2650
|
+
const predecessorGenesis = previous.journal.events[0];
|
|
2651
|
+
const predecessorReconciliationDigest = predecessorGenesis.schema === 'lattice.todo_event.v2'
|
|
2652
|
+
? predecessorGenesis.reconciliation_digest
|
|
2653
|
+
: previous.revision?.reconciliation?.reconciliation_digest
|
|
2654
|
+
?? todoLegacyReconciliationDigest({ planDigest: previous.plan.plan_digest,
|
|
2655
|
+
journalHeadDigest: previous.journal.events.at(-1).event_digest });
|
|
2656
|
+
if (revision.reconciliation.predecessor_reconciliation_digest
|
|
2657
|
+
!== predecessorReconciliationDigest) fail('STORE_WRITE_CONFLICT', 'stale_predecessor');
|
|
2658
|
+
validatePhaseV3SourceInventoryDiff(await predecessorSourceInventory(repoRoot, previous), revision);
|
|
2659
|
+
await rejectCompetingPhaseV3Transaction(repoRoot, revision);
|
|
2660
|
+
verifyPlanNarrativeAnchors(repoRoot, revision.desired_plan, previous.plan);
|
|
2661
|
+
const stateMigration = stateMigrationFor(previous, revision);
|
|
2662
|
+
const phaseStateMigration = phaseStateMigrationFor(previous, revision);
|
|
2663
|
+
validateMergedGraph(store.members.map((member) => member === previous
|
|
2664
|
+
? { ...member, plan: revision.desired_plan } : member));
|
|
2665
|
+
const candidateGenesis = buildPhaseRevisionGenesis(revision.desired_plan, {
|
|
2666
|
+
previous_digest: revision.predecessor.journal_head_digest, actor: options.actor,
|
|
2667
|
+
recorded_at: options.recordedAt, provenance: options.provenance ?? null,
|
|
2668
|
+
revision_digest: revision.revision_digest, state_migration: stateMigration,
|
|
2669
|
+
phase_state_migration: phaseStateMigration,
|
|
2670
|
+
});
|
|
2671
|
+
await ensureSafeStoreDirectory(repoRoot, transaction);
|
|
2672
|
+
for (const ref of [revisionRef, planRef, journalRef, snapshotRef,
|
|
2673
|
+
sourceReceiptRef, commitReceiptRef]) {
|
|
2674
|
+
await ensureSafeStoreDirectory(repoRoot, path.dirname(path.resolve(repoRoot, ref)));
|
|
2675
|
+
}
|
|
2676
|
+
const markerAbsolute = path.join(transaction, 'marker.json');
|
|
2677
|
+
const markerBytes = await exactFileOrNull(markerAbsolute);
|
|
2678
|
+
let genesis = candidateGenesis;
|
|
2679
|
+
if (markerBytes === null) {
|
|
2680
|
+
await atomicWrite(markerAbsolute, canonicalLine({
|
|
2681
|
+
schema: 'lattice.phase_todo_revision_transaction.v1', revision, genesis,
|
|
2682
|
+
}));
|
|
2683
|
+
} else {
|
|
2684
|
+
const marker = parsePhaseRevisionMarker(markerBytes);
|
|
2685
|
+
if (canonicalizeTodoArtifact(marker.revision) !== canonicalizeTodoArtifact(revision)
|
|
2686
|
+
|| canonicalizeTodoArtifact(marker.genesis.state_migration)
|
|
2687
|
+
!== canonicalizeTodoArtifact(stateMigration)
|
|
2688
|
+
|| canonicalizeTodoArtifact(marker.genesis.phase_state_migration)
|
|
2689
|
+
!== canonicalizeTodoArtifact(phaseStateMigration)) {
|
|
2690
|
+
fail('REVISION_CONFLICT', 'revision_bytes_conflict');
|
|
2691
|
+
}
|
|
2692
|
+
genesis = marker.genesis;
|
|
2693
|
+
}
|
|
2694
|
+
await protocolStage(options, 'phase_v3_marker_durable');
|
|
2695
|
+
const tasks = replay(revision.desired_plan, [genesis], {
|
|
2696
|
+
now: options.now ? new Date(options.now) : new Date(),
|
|
2697
|
+
verifyEvidence: evidenceVerifier(store.manifest, repoRoot, true),
|
|
2698
|
+
verifyImportSource: importSourceVerifier(repoRoot, true),
|
|
2699
|
+
});
|
|
2700
|
+
const snapshot = snapshotFor(revision.desired_plan, [genesis], tasks);
|
|
2701
|
+
for (const [stage, ref, bytes] of [
|
|
2702
|
+
['phase_v3_revision_durable', revisionRef, canonicalLine(revision)],
|
|
2703
|
+
['phase_v3_plan_durable', planRef, canonicalLine(revision.desired_plan)],
|
|
2704
|
+
['phase_v3_genesis_durable', journalRef, canonicalLine(genesis)],
|
|
2705
|
+
['phase_v3_snapshot_durable', snapshotRef, canonicalLine(snapshot)],
|
|
2706
|
+
]) {
|
|
2707
|
+
await publishRevisionArtifact(path.join(transaction, path.basename(ref)),
|
|
2708
|
+
path.resolve(repoRoot, ref), bytes);
|
|
2709
|
+
await protocolStage(options, stage);
|
|
2710
|
+
}
|
|
2711
|
+
let sourceReceiptBytes = await exactFileOrNull(path.resolve(repoRoot, sourceReceiptRef));
|
|
2712
|
+
let sourceReceipt;
|
|
2713
|
+
if (sourceReceiptBytes === null) {
|
|
2714
|
+
let staged;
|
|
2715
|
+
if (recovering) {
|
|
2716
|
+
try { staged = await loadSourceCutoverStage(repoRoot, transaction, revision); }
|
|
2717
|
+
catch (error) {
|
|
2718
|
+
if (!(error instanceof TodoStoreError)
|
|
2719
|
+
|| error.code !== 'SOURCE_CUTOVER_RECOVERY_REQUIRED'
|
|
2720
|
+
|| error.detail?.reason !== 'source_cutover_stage_missing') throw error;
|
|
2721
|
+
staged = { descriptor: { files: [] }, files: [] };
|
|
2722
|
+
}
|
|
2723
|
+
} else {
|
|
2724
|
+
const images = await buildSourceCutoverImages(repoRoot, revision);
|
|
2725
|
+
const originals = await Promise.all(revision.source_cutover_batch.operations
|
|
2726
|
+
.map((operation) => sourceItemBytes(repoRoot, operation.source_ref)));
|
|
2727
|
+
await atomicWrite(path.join(transaction, 'source-originals.md'),
|
|
2728
|
+
Buffer.concat(originals.flatMap((bytes) => [bytes, Buffer.from('\n')])));
|
|
2729
|
+
await stageSourceCutover(transaction, revision, images);
|
|
2730
|
+
staged = await loadSourceCutoverStage(repoRoot, transaction, revision);
|
|
2731
|
+
await atomicWrite(barrierAbsolute, canonicalLine({
|
|
2732
|
+
schema: 'lattice.todo_source_cutover_barrier.v1', revision_digest: revision.revision_digest,
|
|
2733
|
+
}));
|
|
2734
|
+
await protocolStage(options, 'phase_v3_cutover_barrier_durable');
|
|
2735
|
+
}
|
|
2736
|
+
if (staged.descriptor.files.length > 0) await publishSourceCutover(repoRoot, staged);
|
|
2737
|
+
await protocolStage(options, 'phase_v3_source_published');
|
|
2738
|
+
try {
|
|
2739
|
+
await verifyRevisionSources(repoRoot, revision.source_inventory);
|
|
2740
|
+
sourceReceipt = await buildPhaseV3SourceReceipt(repoRoot, revision, transactionRef);
|
|
2741
|
+
} catch (error) {
|
|
2742
|
+
if (!(error instanceof TodoStoreError)) throw error;
|
|
2743
|
+
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'source_cutover_postimage_invalid');
|
|
2744
|
+
}
|
|
2745
|
+
await cleanupPhaseV3Staging(transaction, staged);
|
|
2746
|
+
await protocolStage(options, 'phase_v3_source_cleanup');
|
|
2747
|
+
await publishRevisionArtifact(path.join(transaction, 'source-cutover-receipt.json'),
|
|
2748
|
+
path.resolve(repoRoot, sourceReceiptRef), canonicalLine(sourceReceipt));
|
|
2749
|
+
await protocolStage(options, 'phase_v3_source_receipt_durable');
|
|
2750
|
+
} else {
|
|
2751
|
+
sourceReceipt = JSON.parse(decodeUtf8(sourceReceiptBytes,
|
|
2752
|
+
'SOURCE_CUTOVER_RECOVERY_REQUIRED', 'source_receipt_invalid'));
|
|
2753
|
+
if (!sourceReceiptBytes.equals(canonicalLine(sourceReceipt))
|
|
2754
|
+
|| !await verifyPhaseV3SourceReceipt(repoRoot, revision, sourceReceipt)) {
|
|
2755
|
+
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'source_receipt_invalid');
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
const currentManifest = await readArtifact(repoRoot, MANIFEST_REF, {
|
|
2759
|
+
code: 'STORE_INCONSISTENT', maxBytes: TODO_LIMITS.snapshotBytes, validate: validateTodoManifest,
|
|
2760
|
+
});
|
|
2761
|
+
if (currentManifest.manifest_digest !== store.manifest.manifest_digest) {
|
|
2762
|
+
fail('STORE_WRITE_CONFLICT', 'manifest_digest_changed');
|
|
2763
|
+
}
|
|
2764
|
+
const members = currentManifest.members.map((member) => ({ ...member,
|
|
2765
|
+
active_revision_digest: member.plan_key === revision.plan_key ? revision.revision_digest
|
|
2766
|
+
: store.members.find(({ descriptor }) => descriptor.plan_key === member.plan_key)
|
|
2767
|
+
?.revision?.revision_digest ?? store.members.find(({ descriptor }) => (
|
|
2768
|
+
descriptor.plan_key === member.plan_key))?.plan.plan_digest }));
|
|
2769
|
+
const descriptor = members.find(({ plan_key: key }) => key === revision.plan_key);
|
|
2770
|
+
Object.assign(descriptor, { active_plan_version: revision.desired_plan.plan_version,
|
|
2771
|
+
plan_ref: planRef, journal_ref: journalRef, snapshot_ref: snapshotRef,
|
|
2772
|
+
topology_digest: revision.desired_plan.topology_digest,
|
|
2773
|
+
journal_head_digest: genesis.event_digest, active_revision_digest: revision.revision_digest });
|
|
2774
|
+
const commitReceipt = { schema: 'lattice.phase_revision_commit_receipt.v1',
|
|
2775
|
+
project_id: revision.project_id, plan_key: revision.plan_key,
|
|
2776
|
+
plan_version: revision.desired_plan.plan_version, revision_digest: revision.revision_digest,
|
|
2777
|
+
committed_member_digest: digestTodoArtifact(descriptor),
|
|
2778
|
+
active_plan_digest: revision.desired_plan.plan_digest,
|
|
2779
|
+
journal_genesis_digest: genesis.event_digest,
|
|
2780
|
+
reconciliation_digest: revision.reconciliation.reconciliation_digest,
|
|
2781
|
+
source_cutover_receipt_digest: sourceReceipt.receipt_digest,
|
|
2782
|
+
committed_at: genesis.recorded_at, receipt_digest: '' };
|
|
2783
|
+
commitReceipt.receipt_digest = todoSelfDigest(commitReceipt, 'receipt_digest');
|
|
2784
|
+
await publishRevisionArtifact(path.join(transaction, 'phase-revision-commit-receipt.json'),
|
|
2785
|
+
path.resolve(repoRoot, commitReceiptRef), canonicalLine(commitReceipt));
|
|
2786
|
+
await protocolStage(options, 'phase_v3_commit_receipt_durable');
|
|
2787
|
+
const nextManifest = { schema: 'lattice.todo_manifest.v2', project_id: currentManifest.project_id,
|
|
2788
|
+
repositories: currentManifest.repositories, members, manifest_digest: '' };
|
|
2789
|
+
nextManifest.manifest_digest = todoSelfDigest(nextManifest, 'manifest_digest');
|
|
2790
|
+
if (!validateTodoManifest(nextManifest)) fail('REVISION_INVALID', 'manifest_v2_invalid');
|
|
2791
|
+
await atomicWrite(path.resolve(repoRoot, MANIFEST_REF), canonicalLine(nextManifest));
|
|
2792
|
+
await protocolStage(options, 'phase_v3_manifest_activated');
|
|
2793
|
+
await rm(barrierAbsolute, { force: true });
|
|
2794
|
+
await fsyncDirectory(path.dirname(barrierAbsolute));
|
|
2795
|
+
await rm(transaction, { recursive: true, force: true });
|
|
2796
|
+
return commitReceipt;
|
|
2797
|
+
});
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
/** Native successor transaction for first-class Phase plans; no Markdown source inventory is involved. */
|
|
2801
|
+
export async function applyPhaseTodoRevision(options = {}) {
|
|
2802
|
+
requireWriter(options.writer, 'g5-authoring');
|
|
2803
|
+
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
2804
|
+
const revision = options.revision;
|
|
2805
|
+
if (!validatePhaseTodoRevision(revision)) fail('REVISION_INVALID', 'phase_revision_schema_or_digest_invalid');
|
|
2806
|
+
if (revision.schema === 'lattice.phase_todo_revision.v3') {
|
|
2807
|
+
return applyPhaseTodoRevisionV3(options, revision, repoRoot);
|
|
2808
|
+
}
|
|
2809
|
+
return withLock(repoRoot, async () => {
|
|
2810
|
+
const store = await readTodoStore({ repoRoot, forWrite: true, now: options.now });
|
|
2811
|
+
const previous = store.members.find(({ descriptor }) => descriptor.plan_key === revision.plan_key);
|
|
2812
|
+
if (!previous || revision.project_id !== store.project_id) fail('STORE_INCONSISTENT', 'plan_not_active');
|
|
2813
|
+
const transaction = path.join(repoRoot, STORE_ROOT_REF, 'transactions', 'phase-revisions',
|
|
2814
|
+
revision.plan_key, revision.desired_plan.plan_version);
|
|
2815
|
+
const markerAbsolute = path.join(transaction, 'marker.json');
|
|
2816
|
+
const activeGenesis = previous.journal.events[0];
|
|
2817
|
+
if (previous.plan.plan_digest === revision.desired_plan.plan_digest
|
|
2818
|
+
&& activeGenesis.schema === 'lattice.todo_event.v4'
|
|
2819
|
+
&& activeGenesis.revision_digest === revision.revision_digest) {
|
|
2820
|
+
await rm(transaction, { recursive: true, force: true });
|
|
2821
|
+
return phaseRevisionResult(revision, activeGenesis, true);
|
|
2822
|
+
}
|
|
2823
|
+
if (previous.plan.plan_digest !== revision.predecessor.plan_digest
|
|
2824
|
+
|| previous.plan.plan_version !== revision.predecessor.plan_version
|
|
2825
|
+
|| previous.journal.events.at(-1).event_digest !== revision.predecessor.journal_head_digest) {
|
|
2826
|
+
fail('STORE_WRITE_CONFLICT', 'stale_predecessor');
|
|
2827
|
+
}
|
|
2828
|
+
verifyPlanNarrativeAnchors(repoRoot, revision.desired_plan, previous.plan);
|
|
2829
|
+
const stateMigration = stateMigrationFor(previous, revision);
|
|
2830
|
+
const phaseStateMigration = phaseStateMigrationFor(previous, revision);
|
|
2831
|
+
const prospective = store.members.map((member) => member === previous
|
|
2832
|
+
? { ...member, plan: revision.desired_plan } : member);
|
|
2833
|
+
validateMergedGraph(prospective);
|
|
2834
|
+
const candidateGenesis = buildPhaseRevisionGenesis(revision.desired_plan, {
|
|
2835
|
+
previous_digest: revision.predecessor.journal_head_digest,
|
|
2836
|
+
actor: options.actor, recorded_at: options.recordedAt, provenance: options.provenance ?? null,
|
|
2837
|
+
revision_digest: revision.revision_digest, state_migration: stateMigration,
|
|
2838
|
+
phase_state_migration: phaseStateMigration,
|
|
2839
|
+
});
|
|
2840
|
+
await ensureSafeStoreDirectory(repoRoot, transaction);
|
|
2841
|
+
const markerBytes = await exactFileOrNull(markerAbsolute);
|
|
2842
|
+
let genesis = candidateGenesis;
|
|
2843
|
+
if (markerBytes !== null) {
|
|
2844
|
+
const marker = parsePhaseRevisionMarker(markerBytes);
|
|
2845
|
+
if (canonicalizeTodoArtifact(marker.revision) !== canonicalizeTodoArtifact(revision)
|
|
2846
|
+
|| canonicalizeTodoArtifact(marker.genesis.state_migration)
|
|
2847
|
+
!== canonicalizeTodoArtifact(stateMigration)
|
|
2848
|
+
|| canonicalizeTodoArtifact(marker.genesis.phase_state_migration)
|
|
2849
|
+
!== canonicalizeTodoArtifact(phaseStateMigration)) {
|
|
2850
|
+
fail('REVISION_CONFLICT', 'revision_bytes_conflict');
|
|
2851
|
+
}
|
|
2852
|
+
genesis = marker.genesis;
|
|
2853
|
+
} else {
|
|
2854
|
+
await atomicWrite(markerAbsolute, canonicalLine({
|
|
2855
|
+
schema: 'lattice.phase_todo_revision_transaction.v1', revision, genesis,
|
|
2856
|
+
}));
|
|
2857
|
+
}
|
|
2858
|
+
await protocolStage(options, 'phase_revision_marker_durable');
|
|
2859
|
+
const base = `${STORE_ROOT_REF}/plans/${revision.plan_key}/${revision.desired_plan.plan_version}`;
|
|
2860
|
+
const revisionRef = `${base}/revision.json`; const planRef = `${base}/plan.json`;
|
|
2861
|
+
const journalRef = `${base}/journal/active.jsonl`; const snapshotRef = `${base}/snapshot.json`;
|
|
2862
|
+
const tasks = replay(revision.desired_plan, [genesis], {
|
|
2863
|
+
now: options.now ? new Date(options.now) : new Date(),
|
|
2864
|
+
verifyEvidence: evidenceVerifier(store.manifest, repoRoot, true),
|
|
2865
|
+
verifyImportSource: importSourceVerifier(repoRoot, true),
|
|
2866
|
+
});
|
|
2867
|
+
const snapshot = snapshotFor(revision.desired_plan, [genesis], tasks);
|
|
2868
|
+
const artifacts = [
|
|
2869
|
+
['phase_revision_input_durable', revisionRef, canonicalLine(revision)],
|
|
2870
|
+
['phase_revision_plan_durable', planRef, canonicalLine(revision.desired_plan)],
|
|
2871
|
+
['phase_revision_genesis_durable', journalRef, canonicalLine(genesis)],
|
|
2872
|
+
['phase_revision_snapshot_durable', snapshotRef, canonicalLine(snapshot)],
|
|
2873
|
+
];
|
|
2874
|
+
for (const [stage, ref, bytes] of artifacts) {
|
|
2875
|
+
const absolute = path.resolve(repoRoot, ref);
|
|
2876
|
+
const existing = await exactFileOrNull(absolute);
|
|
2877
|
+
if (existing === null) await atomicWrite(absolute, bytes);
|
|
2878
|
+
else if (!existing.equals(bytes)) fail('REVISION_CONFLICT', 'revision_bytes_conflict');
|
|
2879
|
+
await protocolStage(options, stage);
|
|
2880
|
+
}
|
|
2881
|
+
const currentManifest = await readArtifact(repoRoot, MANIFEST_REF, {
|
|
2882
|
+
code: 'STORE_INCONSISTENT', maxBytes: TODO_LIMITS.snapshotBytes, validate: validateTodoManifest,
|
|
2883
|
+
});
|
|
2884
|
+
if (currentManifest.manifest_digest !== store.manifest.manifest_digest) {
|
|
2885
|
+
fail('STORE_WRITE_CONFLICT', 'manifest_digest_changed');
|
|
2886
|
+
}
|
|
2887
|
+
const descriptor = currentManifest.members.find(({ plan_key }) => plan_key === revision.plan_key);
|
|
2888
|
+
Object.assign(descriptor, { active_plan_version: revision.desired_plan.plan_version,
|
|
2889
|
+
plan_ref: planRef, journal_ref: journalRef, snapshot_ref: snapshotRef,
|
|
2890
|
+
topology_digest: revision.desired_plan.topology_digest, journal_head_digest: genesis.event_digest });
|
|
2891
|
+
currentManifest.manifest_digest = todoSelfDigest(currentManifest, 'manifest_digest');
|
|
2892
|
+
await atomicWrite(path.resolve(repoRoot, MANIFEST_REF), canonicalLine(currentManifest));
|
|
2893
|
+
await protocolStage(options, 'phase_revision_manifest_activated');
|
|
2894
|
+
await rm(transaction, { recursive: true, force: true });
|
|
2895
|
+
return phaseRevisionResult(revision, genesis, false);
|
|
2896
|
+
});
|
|
2897
|
+
}
|
|
2898
|
+
|
|
2899
|
+
/** G5 revision transaction: exact successor, state migration, and manifest-CAS activation. */
|
|
2900
|
+
export async function applyTodoRevision(options = {}) {
|
|
2901
|
+
requireWriter(options.writer, 'g5-authoring');
|
|
2902
|
+
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
2903
|
+
const revision = options.revision;
|
|
2904
|
+
if (!validateTodoRevision(revision)) fail('REVISION_INVALID', 'revision_schema_or_digest_invalid');
|
|
2905
|
+
return withLock(repoRoot, async () => {
|
|
2906
|
+
const barrierAbsolute = path.resolve(repoRoot, SOURCE_CUTOVER_BARRIER_REF);
|
|
2907
|
+
const barrierBytes = await exactFileOrNull(barrierAbsolute);
|
|
2908
|
+
let recovering = false;
|
|
2909
|
+
if (barrierBytes !== null) {
|
|
2910
|
+
let barrier;
|
|
2911
|
+
try { barrier = JSON.parse(decodeUtf8(barrierBytes, 'SOURCE_CUTOVER_RECOVERY_REQUIRED', 'barrier_invalid')); }
|
|
2912
|
+
catch (error) {
|
|
2913
|
+
if (error instanceof TodoStoreError) throw error;
|
|
2914
|
+
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'barrier_invalid');
|
|
2915
|
+
}
|
|
2916
|
+
if (revision.schema !== 'lattice.todo_revision.v2'
|
|
2917
|
+
|| barrier?.schema !== 'lattice.todo_source_cutover_barrier.v1'
|
|
2918
|
+
|| barrier.revision_digest !== revision.revision_digest) {
|
|
2919
|
+
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'source_cutover_recovery_required');
|
|
2920
|
+
}
|
|
2921
|
+
recovering = true;
|
|
2922
|
+
}
|
|
2923
|
+
const store = await readTodoStore({ repoRoot, forWrite: true, now: options.now,
|
|
2924
|
+
...(recovering ? { allowSourceCutoverRevisionDigest: revision.revision_digest,
|
|
2925
|
+
sourceCutoverRecoveryCapability: SOURCE_CUTOVER_RECOVERY_CAPABILITY } : {}) });
|
|
2926
|
+
const previous = store.members.find(({ descriptor }) => descriptor.plan_key === revision.plan_key);
|
|
2927
|
+
if (!previous || revision.project_id !== store.project_id) fail('STORE_INCONSISTENT', 'plan_not_active');
|
|
2928
|
+
const activeGenesis = previous.journal.events[0];
|
|
2929
|
+
if (previous.plan.plan_digest === revision.desired_plan.plan_digest
|
|
2930
|
+
&& activeGenesis.schema === 'lattice.todo_event.v2'
|
|
2931
|
+
&& activeGenesis.revision_digest === revision.revision_digest) {
|
|
2932
|
+
const transaction = path.join(repoRoot, STORE_ROOT_REF, 'transactions', 'revisions',
|
|
2933
|
+
revision.plan_key, revision.desired_plan.plan_version);
|
|
2934
|
+
if (revision.schema === 'lattice.todo_revision.v2' && recovering) {
|
|
2935
|
+
const staged = await loadSourceCutoverStage(repoRoot, transaction, revision);
|
|
2936
|
+
await publishSourceCutover(repoRoot, staged);
|
|
2937
|
+
await rm(barrierAbsolute, { force: true });
|
|
2938
|
+
await fsyncDirectory(path.dirname(barrierAbsolute));
|
|
2939
|
+
}
|
|
2940
|
+
await rm(transaction, { recursive: true, force: true });
|
|
2941
|
+
return revisionResult(revision, activeGenesis, { recovered: recovering });
|
|
2942
|
+
}
|
|
2943
|
+
if (activeGenesis.schema === 'lattice.todo_event.v2'
|
|
2944
|
+
&& activeGenesis.payload.predecessor_plan_digest === revision.predecessor.plan_digest
|
|
2945
|
+
&& activeGenesis.previous_digest === revision.predecessor.journal_head_digest) {
|
|
2946
|
+
fail('REVISION_CONFLICT', 'revision_bytes_conflict');
|
|
2947
|
+
}
|
|
2948
|
+
if (previous.plan.plan_digest !== revision.predecessor.plan_digest
|
|
2949
|
+
|| previous.plan.plan_version !== revision.predecessor.plan_version
|
|
2950
|
+
|| previous.journal.events.at(-1).event_digest !== revision.predecessor.journal_head_digest) {
|
|
2951
|
+
fail('STORE_WRITE_CONFLICT', 'stale_predecessor');
|
|
2952
|
+
}
|
|
2953
|
+
const predecessorReconciliationDigest = activeGenesis.schema === 'lattice.todo_event.v2'
|
|
2954
|
+
? activeGenesis.reconciliation_digest
|
|
2955
|
+
: todoLegacyReconciliationDigest({
|
|
2956
|
+
planDigest: previous.plan.plan_digest,
|
|
2957
|
+
journalHeadDigest: previous.journal.events.at(-1).event_digest,
|
|
2958
|
+
});
|
|
2959
|
+
if (revision.reconciliation.predecessor_reconciliation_digest
|
|
2960
|
+
!== predecessorReconciliationDigest) fail('STORE_WRITE_CONFLICT', 'stale_predecessor');
|
|
2961
|
+
|
|
2962
|
+
await rejectCompetingRevisionTransaction(repoRoot, revision);
|
|
2963
|
+
const cutoverImages = revision.schema === 'lattice.todo_revision.v2' && !recovering
|
|
2964
|
+
? await buildSourceCutoverImages(repoRoot, revision) : null;
|
|
2965
|
+
if (revision.schema === 'lattice.todo_revision.v1') {
|
|
2966
|
+
await verifyRevisionSources(repoRoot, revision.source_inventory);
|
|
2967
|
+
}
|
|
2968
|
+
verifyPlanNarrativeAnchors(repoRoot, revision.desired_plan, previous.plan);
|
|
2969
|
+
const stateMigration = stateMigrationFor(previous, revision);
|
|
2970
|
+
const prospective = store.members.map((member) => member === previous
|
|
2971
|
+
? { ...member, plan: revision.desired_plan } : member);
|
|
2972
|
+
validateMergedGraph(prospective);
|
|
2973
|
+
|
|
2974
|
+
const candidateGenesis = buildRevisionGenesis(revision.desired_plan, {
|
|
2975
|
+
previous_digest: revision.predecessor.journal_head_digest,
|
|
2976
|
+
actor: options.actor, recorded_at: options.recordedAt,
|
|
2977
|
+
provenance: options.provenance ?? null, state_migration: stateMigration,
|
|
2978
|
+
revision_digest: revision.revision_digest,
|
|
2979
|
+
reconciliation_digest: revision.reconciliation.reconciliation_digest,
|
|
2980
|
+
});
|
|
2981
|
+
const base = `${STORE_ROOT_REF}/plans/${revision.plan_key}/${revision.desired_plan.plan_version}`;
|
|
2982
|
+
const revisionRef = `${base}/revision.json`;
|
|
2983
|
+
const planRef = `${base}/plan.json`;
|
|
2984
|
+
const journalRef = `${base}/journal/active.jsonl`;
|
|
2985
|
+
const snapshotRef = `${base}/snapshot.json`;
|
|
2986
|
+
const transactionRef = `${STORE_ROOT_REF}/transactions/revisions/${revision.plan_key}/${revision.desired_plan.plan_version}`;
|
|
2987
|
+
const transaction = path.resolve(repoRoot, transactionRef);
|
|
2988
|
+
const markerAbsolute = path.join(transaction, 'marker.json');
|
|
2989
|
+
await ensureSafeStoreDirectory(repoRoot, transaction);
|
|
2990
|
+
await ensureSafeStoreDirectory(repoRoot, path.dirname(path.resolve(repoRoot, planRef)));
|
|
2991
|
+
const markerBytes = await exactFileOrNull(markerAbsolute);
|
|
2992
|
+
let genesis = candidateGenesis;
|
|
2993
|
+
if (markerBytes !== null) {
|
|
2994
|
+
const marker = parseRevisionMarker(markerBytes);
|
|
2995
|
+
if (canonicalizeTodoArtifact(marker.revision) !== canonicalizeTodoArtifact(revision)
|
|
2996
|
+
|| canonicalizeTodoArtifact(marker.genesis.state_migration)
|
|
2997
|
+
!== canonicalizeTodoArtifact(stateMigration)) {
|
|
2998
|
+
fail('REVISION_CONFLICT', 'revision_bytes_conflict');
|
|
2999
|
+
}
|
|
3000
|
+
genesis = marker.genesis;
|
|
3001
|
+
} else {
|
|
3002
|
+
await atomicWrite(markerAbsolute, canonicalLine({
|
|
3003
|
+
schema: 'lattice.todo_revision_transaction.v1', revision, genesis,
|
|
3004
|
+
}));
|
|
3005
|
+
}
|
|
3006
|
+
await protocolStage(options, 'revision_marker_durable');
|
|
3007
|
+
|
|
3008
|
+
const verifyEvidence = evidenceVerifier(store.manifest, repoRoot, true);
|
|
3009
|
+
const verifyImportSource = importSourceVerifier(repoRoot, true);
|
|
3010
|
+
const tasks = replay(revision.desired_plan, [genesis], {
|
|
3011
|
+
now: options.now ? new Date(options.now) : new Date(), verifyEvidence, verifyImportSource,
|
|
3012
|
+
});
|
|
3013
|
+
const snapshot = snapshotFor(revision.desired_plan, [genesis], tasks);
|
|
3014
|
+
const planBytes = canonicalLine(revision.desired_plan);
|
|
3015
|
+
const journalBytes = canonicalLine(genesis);
|
|
3016
|
+
const snapshotBytes = canonicalLine(snapshot);
|
|
3017
|
+
await publishRevisionArtifact(path.join(transaction, 'revision.json'),
|
|
3018
|
+
path.resolve(repoRoot, revisionRef), canonicalLine(revision));
|
|
3019
|
+
await protocolStage(options, 'revision_input_durable');
|
|
3020
|
+
await publishRevisionArtifact(path.join(transaction, 'plan.json'), path.resolve(repoRoot, planRef), planBytes);
|
|
3021
|
+
await protocolStage(options, 'revision_plan_durable');
|
|
3022
|
+
await publishRevisionArtifact(path.join(transaction, 'active.jsonl'), path.resolve(repoRoot, journalRef), journalBytes);
|
|
3023
|
+
await protocolStage(options, 'revision_genesis_durable');
|
|
3024
|
+
await publishRevisionArtifact(path.join(transaction, 'snapshot.json'), path.resolve(repoRoot, snapshotRef), snapshotBytes);
|
|
3025
|
+
await protocolStage(options, 'revision_snapshot_durable');
|
|
3026
|
+
|
|
3027
|
+
let stagedCutover = null;
|
|
3028
|
+
if (revision.schema === 'lattice.todo_revision.v2') {
|
|
3029
|
+
if (recovering) stagedCutover = await loadSourceCutoverStage(repoRoot, transaction, revision);
|
|
3030
|
+
else {
|
|
3031
|
+
const descriptor = await stageSourceCutover(transaction, revision, cutoverImages);
|
|
3032
|
+
stagedCutover = await loadSourceCutoverStage(repoRoot, transaction, revision);
|
|
3033
|
+
if (descriptor.revision_digest !== revision.revision_digest) {
|
|
3034
|
+
fail('REVISION_CONFLICT', 'source_cutover_stage_invalid');
|
|
3035
|
+
}
|
|
3036
|
+
await protocolStage(options, 'source_cutover_staged');
|
|
3037
|
+
await atomicWrite(barrierAbsolute, canonicalLine({
|
|
3038
|
+
schema: 'lattice.todo_source_cutover_barrier.v1',
|
|
3039
|
+
revision_digest: revision.revision_digest,
|
|
3040
|
+
}));
|
|
3041
|
+
await protocolStage(options, 'source_cutover_barrier_durable');
|
|
3042
|
+
}
|
|
3043
|
+
await publishSourceCutover(repoRoot, stagedCutover);
|
|
3044
|
+
await protocolStage(options, 'source_cutover_published');
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
try {
|
|
3048
|
+
const currentManifest = await readArtifact(repoRoot, MANIFEST_REF, {
|
|
3049
|
+
code: 'STORE_INCONSISTENT', maxBytes: TODO_LIMITS.snapshotBytes, validate: validateTodoManifest,
|
|
3050
|
+
});
|
|
3051
|
+
if (currentManifest.manifest_digest !== store.manifest.manifest_digest) {
|
|
3052
|
+
fail('STORE_WRITE_CONFLICT', 'manifest_digest_changed');
|
|
3053
|
+
}
|
|
3054
|
+
const descriptor = currentManifest.members.find(({ plan_key }) => plan_key === revision.plan_key);
|
|
3055
|
+
if (!descriptor || descriptor.active_plan_version !== revision.predecessor.plan_version
|
|
3056
|
+
|| descriptor.journal_head_digest !== revision.predecessor.journal_head_digest) {
|
|
3057
|
+
fail('STORE_WRITE_CONFLICT', 'stale_predecessor');
|
|
3058
|
+
}
|
|
3059
|
+
Object.assign(descriptor, {
|
|
3060
|
+
active_plan_version: revision.desired_plan.plan_version, plan_ref: planRef,
|
|
3061
|
+
journal_ref: journalRef, snapshot_ref: snapshotRef,
|
|
3062
|
+
topology_digest: revision.desired_plan.topology_digest,
|
|
3063
|
+
journal_head_digest: genesis.event_digest,
|
|
3064
|
+
});
|
|
3065
|
+
currentManifest.manifest_digest = todoSelfDigest(currentManifest, 'manifest_digest');
|
|
3066
|
+
await atomicWrite(path.resolve(repoRoot, MANIFEST_REF), canonicalLine(currentManifest));
|
|
3067
|
+
await protocolStage(options, 'revision_manifest_activated');
|
|
3068
|
+
} catch (error) {
|
|
3069
|
+
if (stagedCutover !== null && error instanceof TodoStoreError) {
|
|
3070
|
+
const rolledBack = await rollbackSourceCutover(stagedCutover, barrierAbsolute);
|
|
3071
|
+
if (!rolledBack) fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'rollback_incomplete');
|
|
3072
|
+
}
|
|
3073
|
+
throw error;
|
|
3074
|
+
}
|
|
3075
|
+
if (revision.schema === 'lattice.todo_revision.v2') {
|
|
3076
|
+
await rm(barrierAbsolute, { force: true });
|
|
3077
|
+
await fsyncDirectory(path.dirname(barrierAbsolute));
|
|
3078
|
+
await protocolStage(options, 'source_cutover_cleanup');
|
|
3079
|
+
}
|
|
3080
|
+
await rm(transaction, { recursive: true, force: true });
|
|
3081
|
+
return revisionResult(revision, genesis, { recovered: recovering });
|
|
3082
|
+
});
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
function revisionSetResult(projectId, revisionSetDigest, entries, { recovered }) {
|
|
3086
|
+
const result = {
|
|
3087
|
+
schema: 'lattice.todo_revision_set_result.v1',
|
|
3088
|
+
project_id: projectId,
|
|
3089
|
+
revision_set_digest: revisionSetDigest,
|
|
3090
|
+
members: entries.map(({ revision, genesis }) => ({
|
|
3091
|
+
plan_key: revision.plan_key,
|
|
3092
|
+
plan_version: revision.desired_plan.plan_version,
|
|
3093
|
+
revision_digest: revision.revision_digest,
|
|
3094
|
+
journal_head_digest: genesis.event_digest,
|
|
3095
|
+
})),
|
|
3096
|
+
recovered,
|
|
3097
|
+
result_digest: '',
|
|
3098
|
+
};
|
|
3099
|
+
result.result_digest = todoSelfDigest(result, 'result_digest');
|
|
3100
|
+
return result;
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
/**
|
|
3104
|
+
* Multiple plan successors are prepared independently but become active through one manifest replace.
|
|
3105
|
+
* Set v2 additionally binds all Markdown cutovers behind one recovery barrier.
|
|
3106
|
+
*/
|
|
3107
|
+
export async function applyTodoRevisionSet(options = {}) {
|
|
3108
|
+
requireWriter(options.writer, 'g5-authoring');
|
|
3109
|
+
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
3110
|
+
const revisionSet = options.revisionSet ?? null;
|
|
3111
|
+
if (revisionSet !== null && !validateTodoRevisionSet(revisionSet)) {
|
|
3112
|
+
fail('REVISION_SET_INVALID', 'revision_set_schema_invalid');
|
|
3113
|
+
}
|
|
3114
|
+
const revisions = revisionSet?.revisions ?? options.revisions;
|
|
3115
|
+
if (!Array.isArray(revisions) || revisions.length < 2 || revisions.length > 64
|
|
3116
|
+
|| revisions.some((revision) => !validateTodoRevision(revision)
|
|
3117
|
+
&& !validatePhaseTodoRevision(revision))) {
|
|
3118
|
+
fail('REVISION_SET_INVALID', 'revision_set_schema_invalid');
|
|
3119
|
+
}
|
|
3120
|
+
if (revisionSet === null && revisions.some((revision) => revision.schema !== 'lattice.todo_revision.v1')) {
|
|
3121
|
+
fail('REVISION_SET_INVALID', 'revision_set_v2_required');
|
|
3122
|
+
}
|
|
3123
|
+
if (revisions.some((revision, index) => index > 0
|
|
3124
|
+
&& revisions[index - 1].plan_key >= revision.plan_key)) {
|
|
3125
|
+
fail('REVISION_SET_INVALID', 'revision_set_order_invalid');
|
|
3126
|
+
}
|
|
3127
|
+
const projectId = revisions[0].project_id;
|
|
3128
|
+
if (revisions.some((revision) => revision.project_id !== projectId)) {
|
|
3129
|
+
fail('REVISION_SET_INVALID', 'revision_set_project_mismatch');
|
|
3130
|
+
}
|
|
3131
|
+
const revisionSetDigest = revisionSet?.revision_set_digest ?? digestTodoArtifact({
|
|
3132
|
+
schema: 'lattice.todo_revision_set.v1',
|
|
3133
|
+
project_id: projectId,
|
|
3134
|
+
revision_digests: revisions.map(({ plan_key, revision_digest }) => ({ plan_key, revision_digest })),
|
|
3135
|
+
});
|
|
3136
|
+
|
|
3137
|
+
return withLock(repoRoot, async () => {
|
|
3138
|
+
const transactionRef = `${STORE_ROOT_REF}/transactions/revision-sets/${revisionSetDigest}`;
|
|
3139
|
+
const transaction = path.resolve(repoRoot, transactionRef);
|
|
3140
|
+
const barrierAbsolute = path.resolve(repoRoot, SOURCE_CUTOVER_BARRIER_REF);
|
|
3141
|
+
const barrierBytes = await exactFileOrNull(barrierAbsolute);
|
|
3142
|
+
let recovering = false;
|
|
3143
|
+
if (barrierBytes !== null) {
|
|
3144
|
+
let barrier;
|
|
3145
|
+
try { barrier = JSON.parse(decodeUtf8(barrierBytes, 'SOURCE_CUTOVER_RECOVERY_REQUIRED', 'barrier_invalid')); }
|
|
3146
|
+
catch (error) {
|
|
3147
|
+
if (error instanceof TodoStoreError) throw error;
|
|
3148
|
+
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'barrier_invalid');
|
|
3149
|
+
}
|
|
3150
|
+
if (barrier?.schema !== 'lattice.todo_source_cutover_set_barrier.v1'
|
|
3151
|
+
|| barrier.revision_set_digest !== revisionSetDigest) {
|
|
3152
|
+
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'source_cutover_recovery_required');
|
|
3153
|
+
}
|
|
3154
|
+
recovering = true;
|
|
3155
|
+
}
|
|
3156
|
+
const store = await readTodoStore({ repoRoot, forWrite: true, now: options.now,
|
|
3157
|
+
...(recovering ? {
|
|
3158
|
+
allowSourceCutoverRevisionSetDigest: revisionSetDigest,
|
|
3159
|
+
sourceCutoverRecoveryCapability: SOURCE_CUTOVER_RECOVERY_CAPABILITY,
|
|
3160
|
+
} : {}) });
|
|
3161
|
+
if (store.project_id !== projectId) fail('REVISION_SET_INVALID', 'revision_set_project_mismatch');
|
|
3162
|
+
const byPlan = new Map(store.members.map((member) => [member.descriptor.plan_key, member]));
|
|
3163
|
+
const activeDesired = revisions.map((revision) => {
|
|
3164
|
+
const member = byPlan.get(revision.plan_key);
|
|
3165
|
+
const genesis = member?.journal.events[0];
|
|
3166
|
+
const expectedGenesisSchema = ['lattice.phase_todo_revision.v1', 'lattice.phase_todo_revision.v2']
|
|
3167
|
+
.includes(revision.schema)
|
|
3168
|
+
? 'lattice.todo_event.v4' : 'lattice.todo_event.v2';
|
|
3169
|
+
return member !== undefined
|
|
3170
|
+
&& member.plan.plan_digest === revision.desired_plan.plan_digest
|
|
3171
|
+
&& genesis.schema === expectedGenesisSchema
|
|
3172
|
+
&& genesis.revision_digest === revision.revision_digest;
|
|
3173
|
+
});
|
|
3174
|
+
if (activeDesired.every(Boolean)) {
|
|
3175
|
+
const entries = revisions.map((revision) => ({
|
|
3176
|
+
revision,
|
|
3177
|
+
genesis: byPlan.get(revision.plan_key).journal.events[0],
|
|
3178
|
+
}));
|
|
3179
|
+
if (recovering) {
|
|
3180
|
+
for (const entry of entries.filter(({ revision }) => revision.schema === 'lattice.todo_revision.v2')) {
|
|
3181
|
+
const staged = await loadSourceCutoverStage(repoRoot,
|
|
3182
|
+
path.join(transaction, entry.revision.plan_key, 'source'), entry.revision);
|
|
3183
|
+
await publishSourceCutover(repoRoot, staged);
|
|
3184
|
+
}
|
|
3185
|
+
await rm(barrierAbsolute, { force: true });
|
|
3186
|
+
await fsyncDirectory(path.dirname(barrierAbsolute));
|
|
3187
|
+
await rm(transaction, { recursive: true, force: true });
|
|
3188
|
+
}
|
|
3189
|
+
return revisionSetResult(projectId, revisionSetDigest, entries, { recovered: true });
|
|
3190
|
+
}
|
|
3191
|
+
if (activeDesired.some(Boolean)) fail('REVISION_SET_RECOVERY_REQUIRED', 'partial_manifest_activation');
|
|
3192
|
+
|
|
3193
|
+
const prepared = [];
|
|
3194
|
+
for (const revision of revisions) {
|
|
3195
|
+
const previous = byPlan.get(revision.plan_key);
|
|
3196
|
+
if (previous === undefined) fail('STORE_INCONSISTENT', 'plan_not_active');
|
|
3197
|
+
if (previous.plan.plan_digest !== revision.predecessor.plan_digest
|
|
3198
|
+
|| previous.plan.plan_version !== revision.predecessor.plan_version
|
|
3199
|
+
|| previous.journal.events.at(-1).event_digest !== revision.predecessor.journal_head_digest) {
|
|
3200
|
+
fail('STORE_WRITE_CONFLICT', 'stale_predecessor');
|
|
3201
|
+
}
|
|
3202
|
+
const phaseRevision = ['lattice.phase_todo_revision.v1', 'lattice.phase_todo_revision.v2']
|
|
3203
|
+
.includes(revision.schema);
|
|
3204
|
+
const activeGenesis = previous.journal.events[0];
|
|
3205
|
+
if (!phaseRevision) {
|
|
3206
|
+
const predecessorReconciliationDigest = activeGenesis.schema === 'lattice.todo_event.v2'
|
|
3207
|
+
? activeGenesis.reconciliation_digest
|
|
3208
|
+
: todoLegacyReconciliationDigest({
|
|
3209
|
+
planDigest: previous.plan.plan_digest,
|
|
3210
|
+
journalHeadDigest: previous.journal.events.at(-1).event_digest,
|
|
3211
|
+
});
|
|
3212
|
+
if (revision.reconciliation.predecessor_reconciliation_digest
|
|
3213
|
+
!== predecessorReconciliationDigest) fail('STORE_WRITE_CONFLICT', 'stale_predecessor');
|
|
3214
|
+
await rejectCompetingRevisionTransaction(repoRoot, revision);
|
|
3215
|
+
}
|
|
3216
|
+
const cutoverImages = revision.schema === 'lattice.todo_revision.v2' && !recovering
|
|
3217
|
+
? await buildSourceCutoverImages(repoRoot, revision) : null;
|
|
3218
|
+
if (revision.schema === 'lattice.todo_revision.v1') {
|
|
3219
|
+
await verifyRevisionSources(repoRoot, revision.source_inventory);
|
|
3220
|
+
}
|
|
3221
|
+
verifyPlanNarrativeAnchors(repoRoot, revision.desired_plan, previous.plan);
|
|
3222
|
+
const stateMigration = stateMigrationFor(previous, revision);
|
|
3223
|
+
const phaseStateMigration = phaseRevision ? phaseStateMigrationFor(previous, revision) : null;
|
|
3224
|
+
const commonGenesis = {
|
|
3225
|
+
previous_digest: revision.predecessor.journal_head_digest, actor: options.actor,
|
|
3226
|
+
recorded_at: options.recordedAt, provenance: options.provenance ?? null,
|
|
3227
|
+
state_migration: stateMigration, revision_digest: revision.revision_digest,
|
|
3228
|
+
};
|
|
3229
|
+
const genesis = phaseRevision
|
|
3230
|
+
? buildPhaseRevisionGenesis(revision.desired_plan, {
|
|
3231
|
+
...commonGenesis, phase_state_migration: phaseStateMigration,
|
|
3232
|
+
})
|
|
3233
|
+
: buildRevisionGenesis(revision.desired_plan, {
|
|
3234
|
+
...commonGenesis, reconciliation_digest: revision.reconciliation.reconciliation_digest,
|
|
3235
|
+
});
|
|
3236
|
+
prepared.push({ revision, previous, stateMigration, phaseStateMigration, genesis, cutoverImages });
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3239
|
+
const desiredByPlan = new Map(prepared.map((entry) => [entry.revision.plan_key,
|
|
3240
|
+
entry.revision.desired_plan]));
|
|
3241
|
+
validateMergedGraph(store.members.map((member) => desiredByPlan.has(member.descriptor.plan_key)
|
|
3242
|
+
? { ...member, plan: desiredByPlan.get(member.descriptor.plan_key) } : member));
|
|
3243
|
+
|
|
3244
|
+
await ensureSafeStoreDirectory(repoRoot, transaction);
|
|
3245
|
+
const mixedPhaseSet = prepared.some(({ revision }) => ([
|
|
3246
|
+
'lattice.phase_todo_revision.v1', 'lattice.phase_todo_revision.v2',
|
|
3247
|
+
].includes(revision.schema)));
|
|
3248
|
+
let marker = {
|
|
3249
|
+
schema: mixedPhaseSet
|
|
3250
|
+
? 'lattice.todo_revision_set_transaction.v2' : 'lattice.todo_revision_set_transaction.v1',
|
|
3251
|
+
project_id: projectId,
|
|
3252
|
+
revision_set_digest: revisionSetDigest,
|
|
3253
|
+
entries: prepared.map(({ revision, stateMigration, phaseStateMigration, genesis }) => ({
|
|
3254
|
+
revision, state_migration: stateMigration,
|
|
3255
|
+
...(phaseStateMigration === null ? {} : { phase_state_migration: phaseStateMigration }), genesis,
|
|
3256
|
+
})),
|
|
3257
|
+
};
|
|
3258
|
+
const markerAbsolute = path.join(transaction, 'marker.json');
|
|
3259
|
+
const existingMarker = await exactFileOrNull(markerAbsolute);
|
|
3260
|
+
if (existingMarker === null) await atomicWrite(markerAbsolute, canonicalLine(marker));
|
|
3261
|
+
else {
|
|
3262
|
+
const durable = parseRevisionSetMarker(existingMarker);
|
|
3263
|
+
const expectedIdentity = marker.entries.map(({ revision, state_migration: stateMigration,
|
|
3264
|
+
phase_state_migration: phaseStateMigration }) => ({
|
|
3265
|
+
revision, state_migration: stateMigration,
|
|
3266
|
+
...(phaseStateMigration === undefined ? {} : { phase_state_migration: phaseStateMigration }),
|
|
3267
|
+
}));
|
|
3268
|
+
const durableIdentity = durable.entries.map(({ revision, state_migration: stateMigration,
|
|
3269
|
+
phase_state_migration: phaseStateMigration }) => ({
|
|
3270
|
+
revision, state_migration: stateMigration,
|
|
3271
|
+
...(phaseStateMigration === undefined ? {} : { phase_state_migration: phaseStateMigration }),
|
|
3272
|
+
}));
|
|
3273
|
+
if (durable.project_id !== projectId || durable.revision_set_digest !== revisionSetDigest
|
|
3274
|
+
|| canonicalizeTodoArtifact(durableIdentity) !== canonicalizeTodoArtifact(expectedIdentity)) {
|
|
3275
|
+
fail('REVISION_CONFLICT', 'revision_set_bytes_conflict');
|
|
3276
|
+
}
|
|
3277
|
+
marker = durable;
|
|
3278
|
+
for (const [index, entry] of prepared.entries()) entry.genesis = marker.entries[index].genesis;
|
|
3279
|
+
}
|
|
3280
|
+
await protocolStage(options, 'revision_set_marker_durable');
|
|
3281
|
+
|
|
3282
|
+
const verifyEvidence = evidenceVerifier(store.manifest, repoRoot, true);
|
|
3283
|
+
const verifyImportSource = importSourceVerifier(repoRoot, true);
|
|
3284
|
+
for (const entry of prepared) {
|
|
3285
|
+
const { revision, genesis } = entry;
|
|
3286
|
+
const tasks = replay(revision.desired_plan, [genesis], {
|
|
3287
|
+
now: options.now ? new Date(options.now) : new Date(), verifyEvidence, verifyImportSource,
|
|
3288
|
+
});
|
|
3289
|
+
const snapshot = snapshotFor(revision.desired_plan, [genesis], tasks);
|
|
3290
|
+
const base = `${STORE_ROOT_REF}/plans/${revision.plan_key}/${revision.desired_plan.plan_version}`;
|
|
3291
|
+
entry.refs = {
|
|
3292
|
+
revision_ref: `${base}/revision.json`,
|
|
3293
|
+
plan_ref: `${base}/plan.json`,
|
|
3294
|
+
journal_ref: `${base}/journal/active.jsonl`,
|
|
3295
|
+
snapshot_ref: `${base}/snapshot.json`,
|
|
3296
|
+
};
|
|
3297
|
+
const staged = path.join(transaction, revision.plan_key);
|
|
3298
|
+
await publishRevisionArtifact(path.join(staged, 'revision.json'),
|
|
3299
|
+
path.resolve(repoRoot, entry.refs.revision_ref), canonicalLine(revision));
|
|
3300
|
+
await publishRevisionArtifact(path.join(staged, 'plan.json'),
|
|
3301
|
+
path.resolve(repoRoot, entry.refs.plan_ref), canonicalLine(revision.desired_plan));
|
|
3302
|
+
await publishRevisionArtifact(path.join(staged, 'active.jsonl'),
|
|
3303
|
+
path.resolve(repoRoot, entry.refs.journal_ref), canonicalLine(genesis));
|
|
3304
|
+
await publishRevisionArtifact(path.join(staged, 'snapshot.json'),
|
|
3305
|
+
path.resolve(repoRoot, entry.refs.snapshot_ref), canonicalLine(snapshot));
|
|
3306
|
+
}
|
|
3307
|
+
await protocolStage(options, 'revision_set_artifacts_durable');
|
|
3308
|
+
|
|
3309
|
+
const stagedCutovers = [];
|
|
3310
|
+
if (prepared.some(({ revision }) => revision.schema === 'lattice.todo_revision.v2')) {
|
|
3311
|
+
const sourceRefs = new Set();
|
|
3312
|
+
const archiveRefs = new Set();
|
|
3313
|
+
for (const entry of prepared.filter(({ revision }) => revision.schema === 'lattice.todo_revision.v2')) {
|
|
3314
|
+
if (archiveRefs.has(entry.revision.source_cutover_batch.archive_ref)) {
|
|
3315
|
+
fail('REVISION_SET_INVALID', 'source_cutover_archive_conflict');
|
|
3316
|
+
}
|
|
3317
|
+
archiveRefs.add(entry.revision.source_cutover_batch.archive_ref);
|
|
3318
|
+
for (const operation of entry.revision.source_cutover_batch.operations) {
|
|
3319
|
+
if (sourceRefs.has(operation.source_ref)) {
|
|
3320
|
+
fail('REVISION_SET_INVALID', 'source_cutover_source_conflict');
|
|
3321
|
+
}
|
|
3322
|
+
sourceRefs.add(operation.source_ref);
|
|
3323
|
+
}
|
|
3324
|
+
const sourceTransaction = path.join(transaction, entry.revision.plan_key, 'source');
|
|
3325
|
+
if (!recovering) await stageSourceCutover(sourceTransaction, entry.revision, entry.cutoverImages);
|
|
3326
|
+
stagedCutovers.push(await loadSourceCutoverStage(repoRoot, sourceTransaction, entry.revision));
|
|
3327
|
+
}
|
|
3328
|
+
if (!recovering) {
|
|
3329
|
+
await atomicWrite(barrierAbsolute, canonicalLine({
|
|
3330
|
+
schema: 'lattice.todo_source_cutover_set_barrier.v1',
|
|
3331
|
+
revision_set_digest: revisionSetDigest,
|
|
3332
|
+
revision_digests: prepared.filter(({ revision }) => revision.schema === 'lattice.todo_revision.v2')
|
|
3333
|
+
.map(({ revision }) => revision.revision_digest),
|
|
3334
|
+
}));
|
|
3335
|
+
await protocolStage(options, 'revision_set_source_barrier_durable');
|
|
3336
|
+
}
|
|
3337
|
+
for (const [index, staged] of stagedCutovers.entries()) {
|
|
3338
|
+
await publishSourceCutover(repoRoot, staged);
|
|
3339
|
+
await protocolStage(options, `revision_set_source_published_${index}`);
|
|
3340
|
+
}
|
|
3341
|
+
}
|
|
3342
|
+
|
|
3343
|
+
try {
|
|
3344
|
+
const currentManifest = await readArtifact(repoRoot, MANIFEST_REF, {
|
|
3345
|
+
code: 'STORE_INCONSISTENT', maxBytes: TODO_LIMITS.snapshotBytes, validate: validateTodoManifest,
|
|
3346
|
+
});
|
|
3347
|
+
if (currentManifest.manifest_digest !== store.manifest.manifest_digest) {
|
|
3348
|
+
fail('STORE_WRITE_CONFLICT', 'manifest_digest_changed');
|
|
3349
|
+
}
|
|
3350
|
+
for (const entry of prepared) {
|
|
3351
|
+
const descriptor = currentManifest.members.find(({ plan_key }) => plan_key === entry.revision.plan_key);
|
|
3352
|
+
if (descriptor === undefined
|
|
3353
|
+
|| descriptor.active_plan_version !== entry.revision.predecessor.plan_version
|
|
3354
|
+
|| descriptor.journal_head_digest !== entry.revision.predecessor.journal_head_digest) {
|
|
3355
|
+
fail('STORE_WRITE_CONFLICT', 'stale_predecessor');
|
|
3356
|
+
}
|
|
3357
|
+
Object.assign(descriptor, {
|
|
3358
|
+
active_plan_version: entry.revision.desired_plan.plan_version,
|
|
3359
|
+
plan_ref: entry.refs.plan_ref,
|
|
3360
|
+
journal_ref: entry.refs.journal_ref,
|
|
3361
|
+
snapshot_ref: entry.refs.snapshot_ref,
|
|
3362
|
+
topology_digest: entry.revision.desired_plan.topology_digest,
|
|
3363
|
+
journal_head_digest: entry.genesis.event_digest,
|
|
3364
|
+
});
|
|
3365
|
+
}
|
|
3366
|
+
currentManifest.manifest_digest = todoSelfDigest(currentManifest, 'manifest_digest');
|
|
3367
|
+
await atomicWrite(path.resolve(repoRoot, MANIFEST_REF), canonicalLine(currentManifest));
|
|
3368
|
+
await protocolStage(options, 'revision_set_manifest_activated');
|
|
3369
|
+
} catch (error) {
|
|
3370
|
+
if (stagedCutovers.length > 0 && error instanceof TodoStoreError) {
|
|
3371
|
+
let rolledBack = true;
|
|
3372
|
+
for (const staged of [...stagedCutovers].reverse()) {
|
|
3373
|
+
rolledBack = await rollbackSourceCutover(staged, barrierAbsolute, { removeBarrier: false })
|
|
3374
|
+
&& rolledBack;
|
|
3375
|
+
}
|
|
3376
|
+
if (!rolledBack) fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'rollback_incomplete');
|
|
3377
|
+
await rm(barrierAbsolute, { force: true });
|
|
3378
|
+
await fsyncDirectory(path.dirname(barrierAbsolute));
|
|
3379
|
+
}
|
|
3380
|
+
throw error;
|
|
3381
|
+
}
|
|
3382
|
+
if (stagedCutovers.length > 0) {
|
|
3383
|
+
await rm(barrierAbsolute, { force: true });
|
|
3384
|
+
await fsyncDirectory(path.dirname(barrierAbsolute));
|
|
3385
|
+
await protocolStage(options, 'revision_set_source_cleanup');
|
|
3386
|
+
}
|
|
3387
|
+
await rm(transaction, { recursive: true, force: true });
|
|
3388
|
+
return revisionSetResult(projectId, revisionSetDigest, prepared, { recovered: recovering });
|
|
3389
|
+
});
|
|
3390
|
+
}
|
|
3391
|
+
|
|
3392
|
+
/** G5 revise primitive: topology changes only by publishing a successor version. */
|
|
3393
|
+
export async function createSuccessorTodoPlan(options = {}) {
|
|
3394
|
+
requireWriter(options.writer, 'g5-authoring');
|
|
3395
|
+
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
3396
|
+
return withLock(repoRoot, async () => {
|
|
3397
|
+
const store = await readTodoStore({ repoRoot, forWrite: true, now: options.now });
|
|
3398
|
+
const previous = store.members.find(({ descriptor }) => descriptor.plan_key === options.planKey);
|
|
3399
|
+
if (!previous) fail('STORE_INCONSISTENT', 'plan_not_active');
|
|
3400
|
+
if (options.plan.predecessor_plan_digest !== previous.plan.plan_digest
|
|
3401
|
+
|| options.plan.plan_version === previous.plan.plan_version) throw new TypeError('successor predecessor/version binding invalid');
|
|
3402
|
+
const plan = buildTodoPlan(options.plan);
|
|
3403
|
+
if (plan.project_id !== store.project_id || plan.plan_key !== previous.plan.plan_key) {
|
|
3404
|
+
throw new TypeError('successor identity must match active plan');
|
|
3405
|
+
}
|
|
3406
|
+
const schemaRank = new Map([
|
|
3407
|
+
['lattice.todo_plan.v1', 1], ['lattice.todo_plan.v2', 2], ['lattice.todo_plan.v3', 3],
|
|
3408
|
+
['lattice.todo_plan.v4', 4], ['lattice.todo_plan.v5', 5],
|
|
3409
|
+
]);
|
|
3410
|
+
if (schemaRank.get(plan.schema) < schemaRank.get(previous.plan.schema)) {
|
|
3411
|
+
throw new TypeError(previous.plan.schema === 'lattice.todo_plan.v2'
|
|
3412
|
+
&& plan.schema === 'lattice.todo_plan.v1'
|
|
3413
|
+
? 'todo plan schema cannot regress from v2 to v1'
|
|
3414
|
+
: 'todo plan schema cannot regress');
|
|
3415
|
+
}
|
|
3416
|
+
verifyPlanNarrativeAnchors(repoRoot, plan, previous.plan);
|
|
3417
|
+
const migration = options.genesis.task_migration ?? [];
|
|
3418
|
+
const oldIds = previous.plan.tasks.map(({ task_id }) => task_id).sort();
|
|
3419
|
+
const migrationIds = migration.map(({ from_task_id }) => from_task_id).sort();
|
|
3420
|
+
if (canonicalizeTodoArtifact(oldIds) !== canonicalizeTodoArtifact(migrationIds)
|
|
3421
|
+
|| new Set(migrationIds).size !== migrationIds.length) throw new TypeError('every predecessor task requires one migration entry');
|
|
3422
|
+
const newIds = new Set(plan.tasks.map(({ task_id }) => task_id));
|
|
3423
|
+
if (migration.some(({ to_task_id }) => to_task_id !== 'removed' && !newIds.has(to_task_id))) {
|
|
3424
|
+
throw new TypeError('task migration targets missing successor task');
|
|
3425
|
+
}
|
|
3426
|
+
const genesis = buildPlanGenesis(plan, {
|
|
3427
|
+
...options.genesis, previous_digest: previous.journal.events.at(-1).event_digest,
|
|
3428
|
+
});
|
|
3429
|
+
const prospective = store.members.map((member) => member === previous ? { ...member, plan } : member);
|
|
3430
|
+
validateMergedGraph(prospective);
|
|
3431
|
+
const base = `${STORE_ROOT_REF}/plans/${plan.plan_key}/${plan.plan_version}`;
|
|
3432
|
+
const planRef = `${base}/plan.json`; const journalRef = `${base}/journal/active.jsonl`; const snapshotRef = `${base}/snapshot.json`;
|
|
3433
|
+
try { await lstat(path.resolve(repoRoot, planRef)); fail('STORE_WRITE_CONFLICT', 'successor_version_exists'); }
|
|
3434
|
+
catch (error) { if (error instanceof TodoStoreError) throw error; if (error?.code !== 'ENOENT') throw error; }
|
|
3435
|
+
const tasks = replay(plan, [genesis], { now: options.now ? new Date(options.now) : new Date() });
|
|
3436
|
+
const snapshot = snapshotFor(plan, [genesis], tasks);
|
|
3437
|
+
// Required order: plan, genesis journal, then manifest activation. Snapshot is
|
|
3438
|
+
// prepared before activation so a newly active version is immediately writable.
|
|
3439
|
+
await atomicWrite(path.resolve(repoRoot, planRef), canonicalLine(plan));
|
|
3440
|
+
await atomicWrite(path.resolve(repoRoot, journalRef), canonicalLine(genesis));
|
|
3441
|
+
await atomicWrite(path.resolve(repoRoot, snapshotRef), canonicalLine(snapshot));
|
|
3442
|
+
Object.assign(previous.descriptor, {
|
|
3443
|
+
active_plan_version: plan.plan_version, plan_ref: planRef, journal_ref: journalRef,
|
|
3444
|
+
snapshot_ref: snapshotRef, topology_digest: plan.topology_digest,
|
|
3445
|
+
journal_head_digest: genesis.event_digest,
|
|
3446
|
+
});
|
|
3447
|
+
store.manifest.manifest_digest = todoSelfDigest(store.manifest, 'manifest_digest');
|
|
3448
|
+
await atomicWrite(path.resolve(repoRoot, MANIFEST_REF), canonicalLine(store.manifest));
|
|
3449
|
+
return { plan, genesis, snapshot };
|
|
3450
|
+
});
|
|
3451
|
+
}
|