akm-cli 0.9.0-rc.0 → 0.9.0-rc.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1283 -22
- package/README.md +62 -37
- package/SECURITY.md +46 -31
- package/dist/akm +162 -38
- package/dist/akm-migrate +44 -0
- package/dist/assets/backends/schtasks-template.xml +2 -1
- package/dist/assets/hints/cli-hints-full.md +268 -118
- package/dist/assets/hints/cli-hints-short.md +87 -24
- package/dist/assets/{profiles → improve-strategies}/catchup.json +3 -1
- package/dist/assets/{profiles → improve-strategies}/consolidate.json +3 -1
- package/dist/assets/{profiles → improve-strategies}/default.json +6 -7
- package/dist/assets/improve-strategies/frequent.json +15 -0
- package/dist/assets/{profiles → improve-strategies}/graph-refresh.json +4 -2
- package/dist/assets/{profiles → improve-strategies}/memory-focus.json +4 -1
- package/dist/assets/{profiles → improve-strategies}/proactive-maintenance.json +5 -5
- package/dist/assets/{profiles → improve-strategies}/quick.json +4 -2
- package/dist/assets/improve-strategies/reflect-distill.json +30 -0
- package/dist/assets/{profiles → improve-strategies}/thorough.json +1 -1
- package/dist/assets/prompts/consolidate-system.md +5 -5
- package/dist/assets/prompts/extract-session.md +2 -6
- package/dist/assets/prompts/memory-infer-user.md +2 -3
- package/dist/assets/prompts/reflect-llm-framed-contract.md +11 -0
- package/dist/assets/prompts/reflect-llm-schema-contract.md +3 -0
- package/dist/assets/prompts/reflect-output-repair.md +3 -0
- package/dist/assets/prompts/workflow-unit-preamble.md +26 -0
- package/dist/assets/stash-skeleton/README.md +38 -10
- package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +8 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +8 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +14 -1
- package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +13 -1
- package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +9 -1
- package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +11 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +9 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +9 -0
- package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +8 -0
- package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +100 -0
- package/dist/assets/stash-skeleton/facts/conventions/domains.md +64 -0
- package/dist/assets/stash-skeleton/facts/conventions/organization.md +136 -0
- package/dist/assets/tasks/core/extract.yml +3 -2
- package/dist/assets/tasks/core/improve.yml +2 -1
- package/dist/assets/tasks/core/index-refresh.yml +1 -0
- package/dist/assets/tasks/core/sync.yml +1 -0
- package/dist/assets/tasks/core/version-check.yml +2 -1
- package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +5 -0
- package/dist/assets/tasks/improve/akm-improve-catchup.yml +8 -0
- package/dist/assets/tasks/improve/akm-improve-consolidate.yml +5 -0
- package/dist/assets/tasks/improve/akm-improve-frequent.yml +5 -0
- package/dist/assets/tasks/improve/akm-improve-nightly.yml +5 -0
- package/dist/assets/templates/html/health.html +5 -4
- package/dist/assets/workflows/workflow-template.md +31 -15
- package/dist/cli/invocation.js +279 -0
- package/dist/cli/parse-args.js +5 -90
- package/dist/cli/retired-commands.js +78 -0
- package/dist/cli/shared.js +158 -48
- package/dist/cli-node.mjs +2 -1
- package/dist/cli.js +747 -293
- package/dist/commands/agent/agent-dispatch.js +19 -18
- package/dist/commands/agent/agent-support.js +0 -24
- package/dist/commands/agent/contribute-cli.js +43 -97
- package/dist/commands/completions.js +80 -23
- package/dist/commands/config-cli.js +44 -281
- package/dist/commands/env/env-binding.js +99 -0
- package/dist/commands/env/env-cli.js +84 -224
- package/dist/commands/env/env.js +12 -163
- package/dist/commands/env/marker-path.js +6 -0
- package/dist/commands/env/secret-cli.js +45 -61
- package/dist/commands/env/secret.js +32 -62
- package/dist/commands/feedback-cli.js +179 -85
- package/dist/commands/health/accept-rate.js +58 -0
- package/dist/commands/health/advisories.js +7 -8
- package/dist/commands/health/checks.js +279 -94
- package/dist/commands/health/html-report.js +197 -578
- package/dist/commands/health/improve-metrics.js +277 -246
- package/dist/commands/health/llm-usage.js +19 -19
- package/dist/commands/health/md-report.js +16 -7
- package/dist/commands/health/metrics.js +67 -32
- package/dist/commands/health/renderers.js +47 -0
- package/dist/commands/health/report-view-model.js +508 -0
- package/dist/commands/health/stash-exposure.js +1 -1
- package/dist/commands/health/surfaces.js +16 -56
- package/dist/commands/health/task-runs.js +3 -67
- package/dist/{migrate-storage-node.mjs → commands/health/types-checks.js} +1 -5
- package/dist/commands/health/types-improve.js +29 -0
- package/dist/{output/text/save.js → commands/health/types-metrics.js} +1 -2
- package/dist/commands/health/types-result.js +7 -0
- package/dist/commands/health/types-runs.js +4 -0
- package/dist/commands/health/types-session-log.js +4 -0
- package/dist/commands/health/types-windows.js +4 -0
- package/dist/commands/health/types.js +26 -21
- package/dist/commands/health/windows.js +2 -3
- package/dist/commands/health.js +296 -167
- package/dist/commands/improve/anti-collapse.js +5 -5
- package/dist/commands/improve/autonomy-gate.js +68 -0
- package/dist/commands/improve/collapse-detector.js +65 -52
- package/dist/commands/improve/consolidate/chunking.js +9 -7
- package/dist/commands/improve/consolidate/eligibility.js +1 -23
- package/dist/commands/improve/consolidate/merge.js +4 -0
- package/dist/commands/improve/consolidate.js +454 -1354
- package/dist/commands/improve/content-hash.js +39 -0
- package/dist/commands/improve/distill/content-repair.js +4 -10
- package/dist/commands/improve/distill/promote-memory.js +89 -64
- package/dist/commands/improve/distill/quality-gate.js +118 -42
- package/dist/commands/improve/distill-guards.js +1 -1
- package/dist/commands/improve/distill-promotion-policy.js +33 -888
- package/dist/commands/improve/distill.js +607 -363
- package/dist/commands/improve/eligibility.js +165 -79
- package/dist/commands/improve/extract-cli.js +35 -126
- package/dist/commands/improve/extract-prompt.js +6 -35
- package/dist/commands/improve/extract.js +640 -391
- package/dist/commands/improve/feedback-valence.js +2 -12
- package/dist/commands/improve/improve-cli.js +134 -135
- package/dist/commands/improve/improve-result-file.js +30 -50
- package/dist/commands/improve/improve-run-types.js +4 -0
- package/dist/commands/improve/improve-strategies.js +135 -0
- package/dist/commands/improve/improve.js +904 -701
- package/dist/commands/improve/locks.js +64 -111
- package/dist/commands/improve/loop-stages.js +1110 -923
- package/dist/commands/improve/memory/derived-ref.js +124 -0
- package/dist/commands/improve/memory/memory-belief.js +79 -7
- package/dist/commands/improve/memory/memory-contradiction-detect.js +49 -52
- package/dist/commands/improve/memory/memory-improve.js +25 -37
- package/dist/commands/improve/outcome-loop.js +25 -88
- package/dist/commands/improve/preparation.js +1034 -813
- package/dist/commands/improve/proactive-maintenance.js +34 -9
- package/dist/commands/improve/proposal-envelope.js +31 -0
- package/dist/commands/improve/reflect.js +983 -794
- package/dist/commands/improve/run-context.js +119 -0
- package/dist/commands/improve/salience.js +24 -127
- package/dist/commands/improve/session-asset.js +7 -3
- package/dist/commands/improve/shared.js +14 -34
- package/dist/commands/improve/source-identity.js +28 -0
- package/dist/commands/improve/triage.js +20 -17
- package/dist/commands/lint/base-linter.js +340 -313
- package/dist/commands/lint/env-key-rules.js +31 -47
- package/dist/commands/lint/index.js +185 -30
- package/dist/commands/{events.js → log.js} +28 -38
- package/dist/commands/migrate-cli.js +54 -0
- package/dist/commands/migration-tool.js +55 -0
- package/dist/commands/observability-cli.js +70 -208
- package/dist/commands/proposal/diff-format.js +50 -0
- package/dist/commands/proposal/drain-policies.js +0 -6
- package/dist/commands/proposal/drain.js +91 -40
- package/dist/commands/proposal/proposal-cli.js +134 -132
- package/dist/commands/proposal/proposal-types.js +56 -0
- package/dist/commands/proposal/proposal.js +83 -65
- package/dist/commands/proposal/propose-cli.js +88 -0
- package/dist/commands/proposal/propose.js +105 -88
- package/dist/commands/proposal/repository.js +1303 -278
- package/dist/commands/proposal/validators/proposal-quality-validators.js +16 -6
- package/dist/commands/proposal/validators/proposal-validators.js +61 -12
- package/dist/commands/proposal/validators/proposals.js +6 -8
- package/dist/commands/read/curate.js +78 -73
- package/dist/commands/read/knowledge.js +510 -13
- package/dist/commands/read/registry-search.js +2 -2
- package/dist/commands/read/remember-cli.js +84 -15
- package/dist/commands/read/search-cli.js +203 -96
- package/dist/commands/read/search.js +126 -94
- package/dist/commands/read/show.js +226 -250
- package/dist/commands/registry-cli.js +34 -60
- package/dist/commands/remember.js +18 -57
- package/dist/commands/sources/add-cli.js +104 -49
- package/dist/commands/sources/bundle-cli.js +166 -0
- package/dist/commands/sources/bundle-config-ops.js +63 -0
- package/dist/commands/sources/info.js +27 -15
- package/dist/commands/sources/init.js +30 -40
- package/dist/commands/sources/installed-stashes.js +469 -172
- package/dist/commands/sources/migration-help.js +7 -4
- package/dist/commands/sources/schema-repair.js +10 -9
- package/dist/commands/sources/self-update.js +182 -121
- package/dist/commands/sources/source-add.js +169 -178
- package/dist/commands/sources/source-clone.js +144 -41
- package/dist/commands/sources/source-manage.js +94 -59
- package/dist/commands/sources/sources-cli.js +64 -205
- package/dist/commands/sources/stash-cli.js +91 -54
- package/dist/commands/sources/stash-skeleton.js +1 -1
- package/dist/commands/tasks/tasks-cli.js +106 -104
- package/dist/commands/tasks/tasks.js +445 -262
- package/dist/commands/workflow-cli.js +232 -121
- package/dist/core/action-contributors.js +1 -1
- package/dist/core/activation-policy.js +49 -0
- package/dist/core/adapter/adapters/agent-skills-adapter.js +181 -0
- package/dist/core/adapter/adapters/akm-adapter.js +528 -0
- package/dist/core/adapter/adapters/akm-lint.js +392 -0
- package/dist/core/adapter/adapters/akm-metadata.js +387 -0
- package/dist/core/adapter/adapters/akm-task-adapter.js +149 -0
- package/dist/core/adapter/adapters/akm-workflow-adapter.js +180 -0
- package/dist/core/adapter/adapters/claude-adapter.js +61 -0
- package/dist/core/adapter/adapters/dotenv-adapter.js +187 -0
- package/dist/core/adapter/adapters/generic-files-adapter.js +119 -0
- package/dist/core/adapter/adapters/index.js +80 -0
- package/dist/core/adapter/adapters/llm-wiki-adapter.js +419 -0
- package/dist/core/adapter/adapters/okf-adapter.js +391 -0
- package/dist/core/adapter/adapters/opencode-adapter.js +68 -0
- package/dist/core/adapter/adapters/shared.js +286 -0
- package/dist/core/adapter/adapters/tool-dir-shared.js +217 -0
- package/dist/core/adapter/adapters/website-snapshot-adapter.js +155 -0
- package/dist/core/adapter/bundle-adapter.js +4 -0
- package/dist/core/adapter/detect-adapter.js +17 -0
- package/dist/core/adapter/recognize-match.js +44 -0
- package/dist/core/adapter/registry.js +56 -0
- package/dist/core/adapter/types.js +4 -0
- package/dist/core/asset/akm-markdown.js +30 -0
- package/dist/core/asset/asset-placement.js +243 -0
- package/dist/core/asset/asset-ref.js +110 -79
- package/dist/core/asset/asset-serialize.js +20 -0
- package/dist/core/asset/frontmatter.js +28 -12
- package/dist/core/asset/markdown.js +40 -51
- package/dist/core/asset/resolve-ref.js +274 -0
- package/dist/core/asset/stash-meta.js +2 -2
- package/dist/core/bundle-id.js +51 -0
- package/dist/core/common.js +281 -86
- package/dist/core/config/config-io.js +42 -128
- package/dist/core/config/config-schema.js +233 -834
- package/dist/core/config/config-sources.js +162 -39
- package/dist/core/config/config-types.js +16 -11
- package/dist/core/config/config-version.js +29 -0
- package/dist/core/config/config-walker.js +126 -37
- package/dist/core/config/config.js +154 -331
- package/dist/core/config/deep-merge.js +41 -0
- package/dist/core/config/engine-semantics.js +28 -0
- package/dist/core/config/experimental.js +21 -0
- package/dist/core/config/schema/embedding.js +38 -0
- package/dist/core/config/schema/engines.js +116 -0
- package/dist/core/config/schema/experimental.js +47 -0
- package/dist/core/config/schema/feedback.js +31 -0
- package/dist/core/config/schema/improve-processes.js +389 -0
- package/dist/core/config/schema/improve.js +94 -0
- package/dist/core/config/schema/index-config.js +176 -0
- package/dist/core/config/schema/output.js +18 -0
- package/dist/core/config/schema/primitives.js +94 -0
- package/dist/core/config/schema/search.js +30 -0
- package/dist/core/config/schema/setup.js +18 -0
- package/dist/core/config/schema/sources-bundles.js +169 -0
- package/dist/core/config/schema/workflow.js +29 -0
- package/dist/core/env-secret-ref.js +155 -20
- package/dist/core/errors.js +17 -15
- package/dist/core/events-types.js +4 -0
- package/dist/core/events.js +46 -128
- package/dist/core/extra-params.js +62 -0
- package/dist/core/file-change.js +17 -0
- package/dist/core/file-lock.js +202 -57
- package/dist/core/fs-txn.js +392 -0
- package/dist/core/git-message.js +59 -0
- package/dist/core/improve-result.js +167 -0
- package/dist/core/json-schema.js +142 -0
- package/dist/core/lesson-lint.js +1 -17
- package/dist/core/logs-db.js +1 -1
- package/dist/core/maintenance-barrier.js +135 -0
- package/dist/core/migration-operation.js +44 -0
- package/dist/core/mutation-target.js +78 -0
- package/dist/core/paths.js +22 -25
- package/dist/core/platform.js +10 -0
- package/dist/core/recognition-util.js +128 -0
- package/dist/core/redaction.js +392 -0
- package/dist/core/standards/resolve-standards-context.js +36 -65
- package/dist/core/standards/resolve-stash-standards.js +2 -2
- package/dist/core/standards/resolve-type-conventions.js +5 -5
- package/dist/core/state/migrations.js +242 -11
- package/dist/core/state-db.js +98 -10
- package/dist/core/structured.js +1 -1
- package/dist/core/subprocess.js +303 -0
- package/dist/core/text-truncation.js +9 -5
- package/dist/core/time.js +20 -0
- package/dist/core/type-presentation.js +130 -0
- package/dist/core/warn.js +0 -3
- package/dist/core/write-source.js +834 -118
- package/dist/indexer/bundle-identity-guard.js +92 -0
- package/dist/indexer/db/graph-db.js +1 -25
- package/dist/indexer/db/llm-cache.js +1 -1
- package/dist/indexer/ensure-index.js +30 -9
- package/dist/indexer/graph/graph-boost.js +9 -30
- package/dist/indexer/graph/graph-extraction.js +41 -27
- package/dist/indexer/graph/graph-types.js +4 -0
- package/dist/indexer/index-writer-lock.js +93 -49
- package/dist/indexer/index-written-assets.js +100 -53
- package/dist/indexer/indexer.js +746 -329
- package/dist/indexer/init.js +18 -25
- package/dist/indexer/installations.js +142 -0
- package/dist/indexer/passes/dir-staleness.js +18 -10
- package/dist/indexer/passes/memory-inference.js +25 -15
- package/dist/indexer/passes/metadata.js +412 -243
- package/dist/indexer/scan/doc-to-entry.js +160 -0
- package/dist/indexer/scan/drain-dir.js +134 -0
- package/dist/indexer/search/db-search.js +292 -108
- package/dist/indexer/search/fts-query.js +64 -0
- package/dist/indexer/search/ranking-contributors.js +145 -25
- package/dist/indexer/search/ranking-types.js +4 -0
- package/dist/indexer/search/ranking.js +28 -71
- package/dist/indexer/search/search-attribution.js +67 -0
- package/dist/indexer/search/search-fields.js +18 -3
- package/dist/indexer/search/search-hit-enrichers.js +30 -40
- package/dist/indexer/search/search-source.js +157 -111
- package/dist/indexer/search/semantic-status.js +4 -1
- package/dist/indexer/usage/usage-events.js +10 -30
- package/dist/indexer/walk/file-context.js +3 -45
- package/dist/indexer/walk/matchers.js +42 -34
- package/dist/indexer/walk/path-resolver.js +11 -5
- package/dist/indexer/walk/walker.js +42 -14
- package/dist/integrations/agent/builder-shared.js +7 -0
- package/dist/integrations/agent/builders.js +5 -56
- package/dist/integrations/agent/config.js +3 -143
- package/dist/integrations/agent/detect.js +17 -2
- package/dist/integrations/agent/engine-resolution.js +231 -0
- package/dist/integrations/agent/index.js +1 -2
- package/dist/integrations/agent/model-aliases.js +16 -2
- package/dist/integrations/agent/profiles.js +36 -62
- package/dist/integrations/agent/prompts.js +46 -18
- package/dist/integrations/agent/runner-dispatch.js +93 -4
- package/dist/integrations/agent/runner.js +76 -208
- package/dist/integrations/agent/spawn.js +88 -196
- package/dist/integrations/harnesses/aider/agent-builder.js +114 -0
- package/dist/integrations/harnesses/aider/index.js +48 -0
- package/dist/integrations/harnesses/aider/result-extractor.js +53 -0
- package/dist/integrations/harnesses/amazonq/agent-builder.js +147 -0
- package/dist/integrations/harnesses/amazonq/index.js +45 -0
- package/dist/integrations/harnesses/amazonq/result-extractor.js +48 -0
- package/dist/integrations/harnesses/claude/agent-builder.js +46 -8
- package/dist/integrations/harnesses/claude/config-import.js +1 -3
- package/dist/integrations/harnesses/claude/index.js +24 -35
- package/dist/integrations/harnesses/claude/result-extractor.js +52 -0
- package/dist/integrations/harnesses/claude/session-log.js +27 -75
- package/dist/integrations/harnesses/codex/agent-builder.js +138 -0
- package/dist/integrations/harnesses/codex/index.js +52 -0
- package/dist/integrations/harnesses/codex/result-extractor.js +73 -0
- package/dist/integrations/harnesses/copilot/agent-builder.js +122 -0
- package/dist/integrations/harnesses/copilot/index.js +48 -0
- package/dist/integrations/harnesses/copilot/result-extractor.js +151 -0
- package/dist/integrations/harnesses/gemini/agent-builder.js +120 -0
- package/dist/integrations/harnesses/gemini/index.js +48 -0
- package/dist/integrations/harnesses/gemini/result-extractor.js +121 -0
- package/dist/integrations/harnesses/ids.js +24 -0
- package/dist/integrations/harnesses/index.js +54 -34
- package/dist/integrations/harnesses/opencode/agent-builder.js +23 -5
- package/dist/integrations/harnesses/opencode/config-import.js +1 -3
- package/dist/integrations/harnesses/opencode/index.js +14 -32
- package/dist/integrations/harnesses/opencode/session-log.js +67 -125
- package/dist/integrations/harnesses/opencode-sdk/harness.js +51 -0
- package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +681 -108
- package/dist/integrations/harnesses/openhands/agent-builder.js +128 -0
- package/dist/integrations/harnesses/openhands/index.js +48 -0
- package/dist/integrations/harnesses/openhands/result-extractor.js +103 -0
- package/dist/integrations/harnesses/pi/agent-builder.js +97 -0
- package/dist/integrations/harnesses/pi/index.js +45 -0
- package/dist/integrations/harnesses/pi/result-extractor.js +135 -0
- package/dist/integrations/harnesses/shared.js +17 -0
- package/dist/integrations/harnesses/types.js +43 -32
- package/dist/integrations/lockfile.js +211 -24
- package/dist/integrations/session-logs/index.js +36 -39
- package/dist/integrations/session-logs/provider-base.js +113 -0
- package/dist/llm/client.js +182 -110
- package/dist/llm/embedders/deterministic.js +2 -2
- package/dist/llm/embedders/remote.js +21 -9
- package/dist/llm/feature-gate.js +17 -57
- package/dist/llm/graph-extract.js +12 -13
- package/dist/llm/index-passes.js +8 -42
- package/dist/llm/memory-infer.js +144 -1
- package/dist/llm/metadata-enhance.js +45 -30
- package/dist/llm/structured-call.js +16 -8
- package/dist/llm/usage-persist.js +30 -5
- package/dist/llm/usage-telemetry.js +59 -6
- package/dist/output/cli-hints.js +1 -2
- package/dist/output/command-registry.js +27 -0
- package/dist/output/context.js +22 -7
- package/dist/output/format-exempt.js +80 -0
- package/dist/output/generic-render.js +251 -0
- package/dist/output/html-render.js +11 -16
- package/dist/output/render-registry.js +57 -0
- package/dist/output/renderers.js +14 -279
- package/dist/output/shapes/curate.js +10 -1
- package/dist/output/shapes/events.js +12 -7
- package/dist/output/shapes/helpers.js +58 -84
- package/dist/output/shapes/passthrough.js +11 -39
- package/dist/output/shapes/proposal/producer.js +15 -7
- package/dist/output/shapes/registry.js +12 -6
- package/dist/output/shapes.js +0 -9
- package/dist/output/text/{init.js → bundle-create.js} +3 -1
- package/dist/output/text/bundle-show.js +7 -0
- package/dist/output/text/command-format.js +562 -0
- package/dist/output/text/env.js +1 -3
- package/dist/output/text/events.js +8 -7
- package/dist/output/text/helpers.js +15 -1164
- package/dist/output/text/proposal/producer.js +4 -2
- package/dist/output/text/proposal-format.js +202 -0
- package/dist/output/text/registry-commands.js +1 -2
- package/dist/output/text/registry.js +12 -6
- package/dist/output/text/show-directives.js +117 -0
- package/dist/output/text/show-format.js +103 -0
- package/dist/output/text/sync.js +5 -0
- package/dist/output/text/workflow-format.js +332 -0
- package/dist/output/text/workflow.js +3 -2
- package/dist/output/text.js +10 -19
- package/dist/registry/factory.js +4 -6
- package/dist/registry/origin-resolve.js +16 -27
- package/dist/registry/providers/skills-sh.js +3 -3
- package/dist/registry/providers/static-index.js +15 -25
- package/dist/registry/resolve.js +43 -94
- package/dist/registry/semver.js +43 -0
- package/dist/runtime.js +81 -12
- package/dist/scripts/akm-migrate.js +35529 -0
- package/dist/setup/detect.js +5 -7
- package/dist/setup/detected-engines.js +136 -0
- package/dist/setup/engine-config.js +100 -0
- package/dist/setup/registry-stash-loader.js +3 -3
- package/dist/setup/semantic-assets.js +12 -9
- package/dist/setup/setup.js +444 -208
- package/dist/setup/steps/connection-shared.js +120 -0
- package/dist/setup/steps/connection.js +108 -305
- package/dist/setup/steps/platforms.js +13 -12
- package/dist/setup/steps/semantic.js +15 -3
- package/dist/setup/steps/sources.js +21 -15
- package/dist/setup/steps/stashdir.js +6 -4
- package/dist/setup/steps/tasks.js +236 -119
- package/dist/setup/steps.js +3 -2
- package/dist/sources/freshness.js +39 -0
- package/dist/sources/provider-factory.js +11 -17
- package/dist/sources/providers/filesystem.js +2 -3
- package/dist/sources/providers/git-install.js +278 -34
- package/dist/sources/providers/git-provider.js +54 -56
- package/dist/sources/providers/git-stash.js +420 -91
- package/dist/sources/providers/git.js +2 -2
- package/dist/sources/providers/npm.js +16 -19
- package/dist/sources/providers/provider-utils.js +47 -22
- package/dist/sources/providers/sync-from-ref.js +3 -9
- package/dist/sources/providers/website.js +2 -2
- package/dist/sources/resolve.js +11 -10
- package/dist/sources/snapshot-fetchers/types.js +4 -0
- package/dist/sources/{website-ingest.js → snapshot-fetchers/website-ingest.js} +110 -41
- package/dist/storage/database.js +60 -4
- package/dist/storage/engines/sqlite-migrations.js +156 -5
- package/dist/storage/locations.js +1 -2
- package/dist/storage/repositories/canaries-repository.js +1 -1
- package/dist/storage/repositories/events-repository.js +51 -11
- package/dist/storage/repositories/improve-runs-repository.js +6 -32
- package/dist/storage/repositories/index-connection.js +79 -0
- package/dist/storage/repositories/index-db.js +4 -3
- package/dist/storage/repositories/index-entries-repository.js +863 -0
- package/dist/{indexer/db/entry-mapper.js → storage/repositories/index-entry-mapper.js} +19 -2
- package/dist/storage/repositories/index-entry-types.js +4 -0
- package/dist/storage/repositories/index-fts-repository.js +167 -0
- package/dist/storage/repositories/index-llm-cache-repository.js +108 -0
- package/dist/storage/repositories/index-meta-repository.js +49 -0
- package/dist/{indexer/db/schema.js → storage/repositories/index-schema.js} +226 -100
- package/dist/storage/repositories/index-sql.js +12 -0
- package/dist/storage/repositories/index-utility-repository.js +356 -0
- package/dist/storage/repositories/index-vec-repository.js +250 -0
- package/dist/storage/repositories/outcome-repository.js +119 -0
- package/dist/storage/repositories/proposals-repository.js +317 -75
- package/dist/storage/repositories/registry-cache.js +1 -1
- package/dist/storage/repositories/salience-repository.js +172 -0
- package/dist/storage/repositories/task-history-repository.js +110 -3
- package/dist/storage/repositories/workflow-runs-repository.js +240 -19
- package/dist/tasks/backends/cron.js +169 -46
- package/dist/tasks/backends/exec-utils.js +76 -3
- package/dist/tasks/backends/index.js +6 -9
- package/dist/tasks/backends/launchd.js +292 -55
- package/dist/tasks/backends/schtasks.js +557 -70
- package/dist/tasks/backends/types.js +4 -0
- package/dist/tasks/command-executable.js +93 -0
- package/dist/tasks/embedded.js +56 -38
- package/dist/tasks/parser.js +156 -64
- package/dist/tasks/resolve-akm-bin.js +144 -51
- package/dist/tasks/runner.js +377 -209
- package/dist/tasks/schedule.js +108 -19
- package/dist/tasks/scheduler-invocation.js +296 -0
- package/dist/tasks/schema.js +1 -1
- package/dist/tasks/task-id.js +35 -0
- package/dist/tasks/validator.js +30 -16
- package/dist/text-import-hook.mjs +1 -1
- package/dist/workflows/authoring/authoring.js +104 -43
- package/dist/workflows/authoring/scope-key.js +1 -1
- package/dist/workflows/cli.js +0 -16
- package/dist/workflows/concurrency-policy.js +15 -0
- package/dist/workflows/exec/brief.js +450 -0
- package/dist/workflows/exec/frozen-judge.js +47 -0
- package/dist/workflows/exec/native-executor.js +1038 -0
- package/dist/workflows/exec/param-secrets.js +115 -0
- package/dist/workflows/exec/report.js +1460 -0
- package/dist/workflows/exec/run-workflow.js +602 -0
- package/dist/workflows/exec/scheduler.js +71 -0
- package/dist/workflows/exec/step-work.js +1190 -0
- package/dist/workflows/exec/unit-writer.js +23 -0
- package/dist/workflows/exec/workflow-engine-gate.js +67 -0
- package/dist/workflows/exec/worktree.js +171 -0
- package/dist/workflows/ir/compile.js +246 -0
- package/dist/workflows/ir/freeze.js +233 -0
- package/dist/workflows/ir/params.js +54 -0
- package/dist/workflows/ir/plan-hash.js +68 -0
- package/dist/workflows/ir/schema.js +540 -0
- package/dist/workflows/parser.js +878 -304
- package/dist/workflows/program/expressions.js +181 -0
- package/dist/workflows/program/schema.js +51 -0
- package/dist/workflows/renderer.js +100 -45
- package/dist/workflows/resource-limits.js +22 -0
- package/dist/workflows/runtime/agent-identity.js +59 -14
- package/dist/workflows/runtime/checkin.js +1 -1
- package/dist/workflows/runtime/plan-classifier.js +131 -0
- package/dist/workflows/runtime/runs.js +376 -119
- package/dist/workflows/runtime/unit-checkin.js +45 -0
- package/dist/workflows/runtime/unit-phases.js +20 -0
- package/dist/workflows/runtime/workflow-asset-loader.js +241 -40
- package/dist/workflows/schema.js +1 -11
- package/dist/workflows/validate-summary.js +2 -3
- package/dist/workflows/validator.js +52 -30
- package/docs/README.md +42 -78
- package/docs/migration/README.md +8 -0
- package/docs/migration/release-notes/0.6.0.md +1 -1
- package/docs/migration/release-notes/0.7.0.md +9 -8
- package/docs/migration/release-notes/0.9.0.md +158 -14
- package/docs/migration/v0.7-to-v0.8.md +46 -47
- package/docs/migration/v0.8-to-v0.9.md +844 -0
- package/docs/reference/README.md +12 -0
- package/docs/reference/data-and-telemetry.md +333 -0
- package/package.json +21 -17
- package/schemas/akm-asset-envelope.json +93 -0
- package/schemas/akm-config.json +4636 -0
- package/schemas/akm-task.json +87 -0
- package/schemas/akm-workflow.json +373 -0
- package/dist/akm-migrate-storage +0 -38
- package/dist/assets/help/help-accept.md +0 -12
- package/dist/assets/help/help-improve.md +0 -84
- package/dist/assets/help/help-proposals.md +0 -17
- package/dist/assets/help/help-propose.md +0 -17
- package/dist/assets/help/help-reject.md +0 -11
- package/dist/assets/profiles/frequent.json +0 -13
- package/dist/assets/profiles/recombine-only.json +0 -21
- package/dist/assets/profiles/reflect-distill.json +0 -30
- package/dist/assets/profiles/synthesize.json +0 -15
- package/dist/assets/prompts/procedural-system.md +0 -44
- package/dist/assets/prompts/recombine-system.md +0 -40
- package/dist/assets/prompts/staleness-detect-system.md +0 -6
- package/dist/assets/tasks/core/backup.yml +0 -4
- package/dist/assets/tasks/graph-refresh-weekly.yml +0 -10
- package/dist/assets/templates/html/default.html +0 -78
- package/dist/assets/templates/html/vendor/echarts.min.js +0 -45
- package/dist/assets/wiki/index-template.md +0 -12
- package/dist/assets/wiki/ingest-workflow-template.md +0 -83
- package/dist/assets/wiki/log-template.md +0 -8
- package/dist/assets/wiki/schema-template.md +0 -61
- package/dist/cli/config-migrate.js +0 -150
- package/dist/cli/config-validate.js +0 -39
- package/dist/commands/graph/graph-cli.js +0 -124
- package/dist/commands/graph/graph.js +0 -487
- package/dist/commands/improve/calibration.js +0 -161
- package/dist/commands/improve/dedup.js +0 -482
- package/dist/commands/improve/extract-watch.js +0 -140
- package/dist/commands/improve/hot-probation.js +0 -45
- package/dist/commands/improve/improve-auto-accept.js +0 -276
- package/dist/commands/improve/improve-profiles.js +0 -168
- package/dist/commands/improve/procedural.js +0 -398
- package/dist/commands/improve/recombine.js +0 -818
- package/dist/commands/improve/schema-similarity-gate.js +0 -168
- package/dist/commands/lint/agent-linter.js +0 -44
- package/dist/commands/lint/command-linter.js +0 -44
- package/dist/commands/lint/default-linter.js +0 -16
- package/dist/commands/lint/fact-linter.js +0 -39
- package/dist/commands/lint/knowledge-linter.js +0 -16
- package/dist/commands/lint/memory-linter.js +0 -61
- package/dist/commands/lint/registry.js +0 -41
- package/dist/commands/lint/skill-linter.js +0 -45
- package/dist/commands/lint/task-linter.js +0 -50
- package/dist/commands/lint/workflow-linter.js +0 -81
- package/dist/commands/proposal/legacy-import.js +0 -115
- package/dist/commands/sources/history.js +0 -196
- package/dist/commands/tasks/default-tasks.js +0 -186
- package/dist/commands/wiki-cli.js +0 -292
- package/dist/core/asset/asset-registry.js +0 -76
- package/dist/core/asset/asset-spec.js +0 -259
- package/dist/core/config/config-migration.js +0 -602
- package/dist/core/deep-merge.js +0 -38
- package/dist/core/eval/rank-metrics.js +0 -113
- package/dist/core/ripgrep/install.js +0 -163
- package/dist/core/ripgrep/resolve.js +0 -81
- package/dist/indexer/db/db.js +0 -1413
- package/dist/indexer/manifest.js +0 -170
- package/dist/indexer/passes/metadata-contributors.js +0 -31
- package/dist/indexer/usage/unmigrated-vaults-guard.js +0 -94
- package/dist/integrations/harnesses/opencode-sdk/index.js +0 -49
- package/dist/llm/call-ai.js +0 -62
- package/dist/llm/memory-infer-impl.js +0 -138
- package/dist/output/shapes/distill.js +0 -14
- package/dist/output/shapes/history.js +0 -11
- package/dist/output/text/distill.js +0 -6
- package/dist/output/text/enable-disable.js +0 -8
- package/dist/output/text/history.js +0 -6
- package/dist/output/text/wiki.js +0 -16
- package/dist/registry/build-index.js +0 -386
- package/dist/scripts/migrate-storage.js +0 -19108
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +0 -9411
- package/dist/scripts/migrations/v16-to-v17.js +0 -141
- package/dist/setup/legacy-config.js +0 -106
- package/dist/storage/repositories/consolidation-repository.js +0 -38
- package/dist/storage/repositories/recombine-repository.js +0 -213
- package/dist/wiki/wiki-templates.js +0 -15
- package/dist/wiki/wiki.js +0 -1012
- package/dist/workflows/db.js +0 -215
- package/docs/data-and-telemetry.md +0 -226
- /package/dist/sources/{wiki-fetchers → snapshot-fetchers}/registry.js +0 -0
- /package/dist/sources/{wiki-fetchers → snapshot-fetchers}/youtube.js +0 -0
|
@@ -21,20 +21,12 @@
|
|
|
21
21
|
* is a status flip, and the full audit trail (review outcome, reason, backup
|
|
22
22
|
* content for revert) lives on the row.
|
|
23
23
|
*
|
|
24
|
-
* ## Legacy filesystem import
|
|
25
|
-
*
|
|
26
|
-
* Before 0.9.0 proposals lived as per-uuid JSON directories under
|
|
27
|
-
* `<stashDir>/.akm/proposals/`. The first proposal operation against a stash
|
|
28
|
-
* imports any legacy `proposal.json` files into the table — see
|
|
29
|
-
* `./legacy-import.ts` (`importLegacyProposalFiles`), funnelled through
|
|
30
|
-
* {@link withProposalsDb}.
|
|
31
|
-
*
|
|
32
24
|
* # Why the queue bypasses `writeAssetToSource`
|
|
33
25
|
*
|
|
34
26
|
* The architectural rule "all writes go through `writeAssetToSource`" applies
|
|
35
27
|
* to *assets*. Proposals are **not** assets — they live outside the asset
|
|
36
28
|
* tree (in state.db, parallel to how events do). Routing them through
|
|
37
|
-
* `writeAssetToSource` would force them into a
|
|
29
|
+
* `writeAssetToSource` would force them into a placement stash-subdir slot, would commit
|
|
38
30
|
* them to git, and would leak unaccepted drafts through the normal indexer.
|
|
39
31
|
* The {@link promoteProposal} step is the bridge: it routes the accepted
|
|
40
32
|
* payload through `writeAssetToSource` so the actual asset write still
|
|
@@ -42,88 +34,84 @@
|
|
|
42
34
|
*/
|
|
43
35
|
import { createHash, randomUUID } from "node:crypto";
|
|
44
36
|
import fs from "node:fs";
|
|
37
|
+
import os from "node:os";
|
|
45
38
|
import path from "node:path";
|
|
46
|
-
import {
|
|
47
|
-
import {
|
|
48
|
-
import {
|
|
39
|
+
import { parse as parseYaml } from "yaml";
|
|
40
|
+
import { ensureAkmMarkdownType } from "../../core/asset/akm-markdown.js";
|
|
41
|
+
import { assetPathForName, placementTypes, stashDirFor } from "../../core/asset/asset-placement.js";
|
|
42
|
+
import { isBundleSlug, parseBundleRef } from "../../core/asset/asset-ref.js";
|
|
43
|
+
import { assembleAsset, serializeFrontmatter } from "../../core/asset/asset-serialize.js";
|
|
44
|
+
import { parseFrontmatter } from "../../core/asset/frontmatter.js";
|
|
45
|
+
import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-ref.js";
|
|
46
|
+
import { isWithin } from "../../core/common.js";
|
|
47
|
+
import { loadConfig } from "../../core/config/config.js";
|
|
48
|
+
import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
|
|
49
49
|
import { appendEvent } from "../../core/events.js";
|
|
50
|
+
import { proposalContent } from "../../core/file-change.js";
|
|
51
|
+
import { _setTxnMutationHookForTests, advanceTxn, beginTxn, canonicalTxnRoot, cleanupTxn, fsyncTxnDir, fsyncTxnFile, listTxnJournals, mintTxnId, registerTxnKind, sweepJournallessTxnDir, txnDirFor, txnMutationHook, txnNamespaceDir, } from "../../core/fs-txn.js";
|
|
52
|
+
import { canonicalBundleIdForTarget, resolveBundleWriteTarget } from "../../core/mutation-target.js";
|
|
50
53
|
import { withImmediateTransaction, withStateDb } from "../../core/state-db.js";
|
|
51
54
|
import { warn } from "../../core/warn.js";
|
|
52
|
-
import {
|
|
53
|
-
import {
|
|
54
|
-
import {
|
|
55
|
+
import { assertAkmAssetWrite, assertWriteTargetPathsClean, captureGitPublication, captureWriteTargetPathSnapshot, prepareWriteTargetForMutation, publishWriteTargetTransaction, resolveWriteTarget, } from "../../core/write-source.js";
|
|
56
|
+
import { withAssetMutationLease } from "../../indexer/index-writer-lock.js";
|
|
57
|
+
import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
|
|
58
|
+
import { deriveInstallations } from "../../indexer/installations.js";
|
|
59
|
+
import { resolveSourceEntries } from "../../indexer/search/search-source.js";
|
|
60
|
+
import { insertEventOnce } from "../../storage/repositories/events-repository.js";
|
|
61
|
+
import { getStateProposal, getStateProposalLenient, listStateProposalIdsByPrefix, listStateProposals, upsertProposal, } from "../../storage/repositories/proposals-repository.js";
|
|
62
|
+
import { pkgVersion } from "../../version.js";
|
|
63
|
+
import { runBaseChecks } from "../lint/base-linter.js";
|
|
64
|
+
import { formatNewAssetDiff, formatUnifiedDiff } from "./diff-format.js";
|
|
65
|
+
import { isAutomatedProposalSource, isValidProposalSource, PROPOSAL_SOURCES, } from "./proposal-types.js";
|
|
66
|
+
import { hasCanonicalProposalValidator } from "./validators/proposal-validators.js";
|
|
55
67
|
import { repairProposalContent, validateProposal } from "./validators/proposals.js";
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
export
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
"extract",
|
|
75
|
-
"improve",
|
|
76
|
-
"recombine",
|
|
77
|
-
"procedural",
|
|
78
|
-
// Semi-automated / tool-driven.
|
|
79
|
-
"feedback",
|
|
80
|
-
// Human-initiated / CLI-driven.
|
|
81
|
-
"propose",
|
|
82
|
-
"remember",
|
|
83
|
-
"import",
|
|
84
|
-
// Internal / system.
|
|
85
|
-
"distill_quality_rejected",
|
|
86
|
-
"schema-repair",
|
|
87
|
-
];
|
|
88
|
-
/** Automated sources that SHOULD include a `sourceRun` for PROV-DM traceability. */
|
|
89
|
-
export const AUTOMATED_PROPOSAL_SOURCES = [
|
|
90
|
-
"reflect",
|
|
91
|
-
"distill",
|
|
92
|
-
"consolidate",
|
|
93
|
-
"extract",
|
|
94
|
-
"improve",
|
|
95
|
-
"recombine",
|
|
96
|
-
"procedural",
|
|
97
|
-
"schema-repair",
|
|
98
|
-
];
|
|
99
|
-
/**
|
|
100
|
-
* Check whether a string is a valid {@link ProposalSource}.
|
|
101
|
-
* Unknown source values are accepted with a runtime warning rather than a hard
|
|
102
|
-
* error, to allow extensions without breaking existing callers.
|
|
103
|
-
*/
|
|
104
|
-
export function isValidProposalSource(source) {
|
|
105
|
-
return PROPOSAL_SOURCES.includes(source);
|
|
106
|
-
}
|
|
68
|
+
const PROMOTION_LINT_BLOCKERS = new Set(["unquoted-colon", "missing-ref", "stale-path"]);
|
|
69
|
+
// ── Proposal domain types (moved to ./proposal-types.ts, WI-9.8 KILL 1) ─────
|
|
70
|
+
//
|
|
71
|
+
// Proposal / ProposalStatus / ProposalPayload / ProposalReview /
|
|
72
|
+
// ProposalGateDecision(Outcome) / ProposalSource / PROPOSAL_SOURCES /
|
|
73
|
+
// AUTOMATED_PROPOSAL_SOURCES / isValidProposalSource / isAutomatedProposalSource
|
|
74
|
+
// moved to the dependency-free leaf so validators/proposals.ts,
|
|
75
|
+
// validators/proposal-validators.ts, storage/repositories/proposals-repository.ts,
|
|
76
|
+
// and storage repositories can import the `Proposal` type without importing
|
|
77
|
+
// this heavier transaction-engine module back. That back-edge was the
|
|
78
|
+
// repository↔validators import cycle (plan §10.7 D.3). Every symbol this
|
|
79
|
+
// module used to export directly is re-exported here verbatim so existing
|
|
80
|
+
// import sites (`from "../proposal/repository.js"`) are unchanged.
|
|
81
|
+
export { AUTOMATED_PROPOSAL_SOURCES, isAutomatedProposalSource, isValidProposalSource, PROPOSAL_SOURCES, } from "./proposal-types.js";
|
|
82
|
+
// The envelope's primary-content accessor lives in the dependency-free
|
|
83
|
+
// core/file-change module; re-exported here so proposal consumers get it
|
|
84
|
+
// alongside the repository API.
|
|
85
|
+
export { proposalContent };
|
|
107
86
|
/**
|
|
108
|
-
*
|
|
87
|
+
* Copy of `p` with `content` replacing BOTH the payload's content and the
|
|
88
|
+
* primary change's `after` — every in-memory content mutation must keep the
|
|
89
|
+
* WI-6.2 invariant (`changes[0].after === payload.content`) intact.
|
|
109
90
|
*/
|
|
110
|
-
|
|
111
|
-
return
|
|
91
|
+
function withProposalContent(p, content) {
|
|
92
|
+
return {
|
|
93
|
+
...p,
|
|
94
|
+
payload: { ...p.payload, content },
|
|
95
|
+
changes: p.changes.map((c, i) =>
|
|
96
|
+
// A delete-op primary change carries no `after` (file-change.ts contract).
|
|
97
|
+
i === 0 && c.op !== "delete" ? { ...c, after: content } : c),
|
|
98
|
+
};
|
|
112
99
|
}
|
|
113
100
|
/** Type guard: true when createProposal returned a skipped record. */
|
|
114
101
|
export function isProposalSkipped(result) {
|
|
115
102
|
return result.skipped === true;
|
|
116
103
|
}
|
|
117
|
-
// ──
|
|
104
|
+
// ── Fingerprint / rejection-backoff constants ────────────────────────────────
|
|
118
105
|
const MS_PER_DAY = 86_400_000;
|
|
119
106
|
/**
|
|
120
|
-
* Post-rejection
|
|
121
|
-
*
|
|
122
|
-
* until the window expires (unless
|
|
107
|
+
* Post-rejection backoff windows by source (the RETAINED cooldown semantics,
|
|
108
|
+
* plan §4.5). After a proposal is rejected, `createProposal` silently skips
|
|
109
|
+
* new proposals for the same `ref+source` until the window expires (unless
|
|
110
|
+
* `force: true` is passed).
|
|
123
111
|
*
|
|
124
112
|
* Rationale (Settles 2009 active-learning survey; Argilla/Label Studio HITL):
|
|
125
|
-
* Reviewer fatigue is a blocker for the human-in-the-loop guarantee.
|
|
126
|
-
*
|
|
113
|
+
* Reviewer fatigue is a blocker for the human-in-the-loop guarantee. Backoff
|
|
114
|
+
* prevents nightly improve runs from re-flooding the queue with near-identical
|
|
127
115
|
* proposals the reviewer just declined.
|
|
128
116
|
*
|
|
129
117
|
* - reflect: 14 days (agent-based; slower feedback loops)
|
|
@@ -152,49 +140,170 @@ function newId(ctx) {
|
|
|
152
140
|
return fn();
|
|
153
141
|
}
|
|
154
142
|
/**
|
|
155
|
-
* Open the state database (honouring the `ctx.dbPath` test seam),
|
|
156
|
-
* legacy filesystem import for `stashDir` if it has not happened yet, hand the
|
|
143
|
+
* Open the state database (honouring the `ctx.dbPath` test seam), hand the
|
|
157
144
|
* connection to `fn`, and close it in a `finally`. Every public function in
|
|
158
|
-
* this module funnels its store access through here
|
|
159
|
-
*
|
|
145
|
+
* this module funnels its store access through here.
|
|
146
|
+
*
|
|
147
|
+
* `stashDir` is threaded through the public API for the store's per-stash
|
|
148
|
+
* partition.
|
|
160
149
|
*/
|
|
161
|
-
function withProposalsDb(
|
|
162
|
-
return withStateDb(
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
150
|
+
function withProposalsDb(_stashDir, ctx, fn) {
|
|
151
|
+
return withStateDb(fn, { path: ctx?.dbPath });
|
|
152
|
+
}
|
|
153
|
+
function persistProposalUpdate(db, proposal, stashDir) {
|
|
154
|
+
// Only take the full re-serialize (upsertProposal -> proposalToRowValues)
|
|
155
|
+
// path when `changes` is well-formed enough to survive it — a non-empty
|
|
156
|
+
// path on every entry, matching proposalToRowValues' own validation. A
|
|
157
|
+
// legacy row with a present `proposedTarget` but an empty/missing
|
|
158
|
+
// `changes[].path` (e.g. a malformed 0.8-era row) would otherwise die in
|
|
159
|
+
// proposalToRowValues; falling through to the targeted UPDATE below keeps
|
|
160
|
+
// `changes`/`proposedTarget` untouched in metadata_json and only flips the
|
|
161
|
+
// status/review/etc. fields the terminal transition actually needs.
|
|
162
|
+
const changesAreWellFormed = proposal.changes.length > 0 && proposal.changes.every((change) => change.path.length > 0);
|
|
163
|
+
if (changesAreWellFormed && proposal.proposedTarget) {
|
|
164
|
+
upsertProposal(db, proposal, stashDir);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const row = db
|
|
168
|
+
.prepare("SELECT metadata_json FROM proposals WHERE id = ? AND stash_dir = ?")
|
|
169
|
+
.get(proposal.id, stashDir);
|
|
170
|
+
if (!row)
|
|
171
|
+
throw new NotFoundError(`Proposal "${proposal.id}" not found.`, "FILE_NOT_FOUND");
|
|
172
|
+
const metadata = JSON.parse(row.metadata_json);
|
|
173
|
+
for (const field of [
|
|
174
|
+
"sourceRun",
|
|
175
|
+
"beforeHash",
|
|
176
|
+
"review",
|
|
177
|
+
"confidence",
|
|
178
|
+
"gateDecision",
|
|
179
|
+
"backupContent",
|
|
180
|
+
"acceptedTarget",
|
|
181
|
+
"eligibilitySource",
|
|
182
|
+
]) {
|
|
183
|
+
const value = proposal[field];
|
|
184
|
+
if (value === undefined)
|
|
185
|
+
delete metadata[field];
|
|
186
|
+
else
|
|
187
|
+
metadata[field] = value;
|
|
188
|
+
}
|
|
189
|
+
db.prepare(`UPDATE proposals
|
|
190
|
+
SET ref = ?, status = ?, source = ?, updated_at = ?, content = ?, frontmatter_json = ?, metadata_json = ?
|
|
191
|
+
WHERE id = ? AND stash_dir = ?`).run(proposal.ref, proposal.status, proposal.source, proposal.updatedAt, proposal.payload.content, proposal.payload.frontmatter ? JSON.stringify(proposal.payload.frontmatter) : null, JSON.stringify(metadata), proposal.id, stashDir);
|
|
192
|
+
}
|
|
193
|
+
function proposalRefIdentity(ref) {
|
|
194
|
+
try {
|
|
195
|
+
const parsed = parseBundleRef(ref);
|
|
196
|
+
if (parsed.fragment !== undefined || isRetiredProposalConceptId(parsed.conceptId))
|
|
197
|
+
return undefined;
|
|
198
|
+
return {
|
|
199
|
+
conceptId: parsed.conceptId,
|
|
200
|
+
...(parsed.bundle !== undefined ? { bundle: parsed.bundle } : {}),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function filterRefIdentity(ref) {
|
|
208
|
+
try {
|
|
209
|
+
const p = parseBundleRef(ref);
|
|
210
|
+
if (p.fragment !== undefined || isRetiredProposalConceptId(p.conceptId)) {
|
|
211
|
+
throw new Error("not a current proposal identity");
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
conceptId: p.conceptId,
|
|
215
|
+
...(p.bundle !== undefined ? { bundle: p.bundle } : {}),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
throw new UsageError(`Invalid asset-ref filter "${ref}". Use the 0.9.0 grammar [bundle//]conceptId, e.g. knowledge/guide.md or lessons/deploy.`, "INVALID_FLAG_VALUE");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function isRetiredProposalConceptId(conceptId) {
|
|
223
|
+
const colon = conceptId.indexOf(":");
|
|
224
|
+
return colon > 0 && stashDirFor(conceptId.slice(0, colon)) !== undefined;
|
|
225
|
+
}
|
|
226
|
+
function proposalMatchesRef(proposalRef, filter) {
|
|
227
|
+
const proposal = proposalRefIdentity(proposalRef);
|
|
228
|
+
return (proposal !== undefined &&
|
|
229
|
+
proposal.conceptId === filter.conceptId &&
|
|
230
|
+
(filter.bundle === undefined || proposal.bundle === filter.bundle));
|
|
231
|
+
}
|
|
232
|
+
function proposalDurableRef(parsedRef, target) {
|
|
233
|
+
const conceptId = conceptIdFromTypeName(parsedRef.type, parsedRef.name);
|
|
234
|
+
const { origin } = parsedRef;
|
|
235
|
+
if (!isBundleSlug(target.source)) {
|
|
236
|
+
throw new UsageError(`Proposal target source "${target.source}" is not a valid bundle name.`, "INVALID_FLAG_VALUE");
|
|
237
|
+
}
|
|
238
|
+
if (origin !== undefined) {
|
|
239
|
+
if (target.source !== origin) {
|
|
240
|
+
throw new UsageError(`Proposal ref bundle "${origin}" conflicts with target source "${target.source}".`, "INVALID_FLAG_VALUE");
|
|
241
|
+
}
|
|
242
|
+
return `${origin}//${conceptId}`;
|
|
243
|
+
}
|
|
244
|
+
return `${target.source}//${conceptId}`;
|
|
245
|
+
}
|
|
246
|
+
function resolveCreateProposalTarget(stashDir, explicit, bundle) {
|
|
247
|
+
if (explicit) {
|
|
248
|
+
if (!isBundleSlug(explicit.source) || !explicit.root.trim()) {
|
|
249
|
+
throw new UsageError("Proposal targets require a current bundle name and materialized root.", "INVALID_FLAG_VALUE");
|
|
250
|
+
}
|
|
251
|
+
return { source: explicit.source, root: path.resolve(explicit.root) };
|
|
252
|
+
}
|
|
253
|
+
const config = loadConfig();
|
|
254
|
+
const local = resolveProposalQueueTarget(stashDir, config);
|
|
255
|
+
if (!bundle || bundle === local.source)
|
|
256
|
+
return local;
|
|
257
|
+
if (bundle) {
|
|
258
|
+
const target = resolveBundleWriteTarget(config, bundle);
|
|
259
|
+
return { source: target.source.name, root: target.source.path };
|
|
260
|
+
}
|
|
261
|
+
return local;
|
|
262
|
+
}
|
|
263
|
+
export function resolveProposalQueueTarget(stashDir, config = loadConfig()) {
|
|
264
|
+
const root = path.resolve(stashDir);
|
|
265
|
+
const sources = resolveSourceEntries(root, config);
|
|
266
|
+
const sourceIndex = sources.findIndex((source) => path.resolve(source.path) === root);
|
|
267
|
+
const source = sources[sourceIndex];
|
|
268
|
+
const bundleId = sourceIndex >= 0 ? deriveInstallations(sources)[sourceIndex]?.id : undefined;
|
|
269
|
+
if (!source || !bundleId) {
|
|
270
|
+
throw new ConfigError(`No bundle owns proposal queue ${root}.`, "INVALID_CONFIG_FILE");
|
|
271
|
+
}
|
|
272
|
+
if (!source.registryId && Object.keys(config.bundles ?? {}).length > 0) {
|
|
273
|
+
throw new ConfigError(`No configured bundle owns proposal queue ${root}.`, "INVALID_CONFIG_FILE");
|
|
274
|
+
}
|
|
275
|
+
if (source.writable !== true) {
|
|
276
|
+
throw new UsageError(`Proposal bundle "${bundleId}" is not writable.`, "INVALID_FLAG_VALUE");
|
|
277
|
+
}
|
|
278
|
+
return { source: bundleId, root };
|
|
166
279
|
}
|
|
167
280
|
// ── Public API ──────────────────────────────────────────────────────────────
|
|
168
281
|
/**
|
|
169
282
|
* Create a new pending proposal. The id is a stable random UUID, so two
|
|
170
283
|
* proposals with the same `ref` never collide.
|
|
171
284
|
*
|
|
172
|
-
* **
|
|
285
|
+
* **Input-fingerprint / rejection-backoff guard** (§23.6, WI-6.4):
|
|
173
286
|
*
|
|
174
287
|
* Before writing, this function checks:
|
|
175
|
-
* 1. `
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
288
|
+
* 1. `fingerprint_match` — the §23.6 input fingerprint (scheme version,
|
|
289
|
+
* source, ref, target before-hash, model id) was already processed.
|
|
290
|
+
* The row survives the proposal's lifecycle, so identical inputs stay
|
|
291
|
+
* deduplicated until the target, model, or scheme changes. Pass
|
|
292
|
+
* `input.force = true` to bypass.
|
|
293
|
+
* 2. `rejection_backoff` — a proposal for this `ref+source` was rejected
|
|
294
|
+
* within the source-specific backoff window (reflect: 14 d, distill:
|
|
295
|
+
* 30 d, others: 7 d). Bypass with `force: true`.
|
|
182
296
|
*
|
|
183
297
|
* When a guard fires the function returns a `CreateProposalSkipped` record
|
|
184
298
|
* instead of writing. Use {@link isProposalSkipped} to detect it.
|
|
185
299
|
*/
|
|
186
300
|
export function createProposal(stashDir, input, ctx) {
|
|
187
|
-
// F-4 / #385: Validate source against the allow-list. Unknown values are
|
|
188
|
-
// warned (not rejected) for backward compatibility — extension callers
|
|
189
|
-
// that pass custom source strings must not break.
|
|
190
301
|
if (!isValidProposalSource(input.source)) {
|
|
191
302
|
warn(`[proposal] Unknown source "${input.source}". ` +
|
|
192
303
|
`Expected one of: ${PROPOSAL_SOURCES.join(", ")}. ` +
|
|
193
304
|
"Typos in source values produce unaggregatable accept-rate-per-source metrics.");
|
|
194
305
|
}
|
|
195
306
|
else if (isAutomatedProposalSource(input.source) && !input.sourceRun) {
|
|
196
|
-
// Advisory warning: automated sources should include sourceRun for PROV-DM
|
|
197
|
-
// traceability. This is not a hard error to avoid breaking existing callers.
|
|
198
307
|
warn(`[proposal] Automated source "${input.source}" created a proposal without sourceRun. ` +
|
|
199
308
|
"Add sourceRun to enable accept-rate-per-run aggregation (W3C PROV-DM).");
|
|
200
309
|
}
|
|
@@ -212,17 +321,20 @@ export function createProposal(stashDir, input, ctx) {
|
|
|
212
321
|
};
|
|
213
322
|
let parsedRef;
|
|
214
323
|
try {
|
|
215
|
-
parsedRef =
|
|
324
|
+
parsedRef = parseRefInput(input.ref);
|
|
216
325
|
}
|
|
217
326
|
catch (err) {
|
|
218
327
|
return rejectProposal("invalid_ref", `Invalid proposal ref "${input.ref}": ${err instanceof Error ? err.message : String(err)}`);
|
|
219
328
|
}
|
|
220
|
-
if (!
|
|
221
|
-
return rejectProposal("unknown_type", `Unknown asset type "${parsedRef.type}" in proposal ref "${input.ref}". Known types: ${
|
|
329
|
+
if (!stashDirFor(parsedRef.type)) {
|
|
330
|
+
return rejectProposal("unknown_type", `Unknown asset type "${parsedRef.type}" in proposal ref "${input.ref}". Known types: ${[...placementTypes()].sort().join(", ")}.`);
|
|
222
331
|
}
|
|
223
332
|
if (!input.payload.content.trim()) {
|
|
224
333
|
return rejectProposal("empty_content", `Proposal for "${input.ref}" has empty content.`);
|
|
225
334
|
}
|
|
335
|
+
if (input.target && parsedRef.origin && input.target.source !== parsedRef.origin) {
|
|
336
|
+
return rejectProposal("invalid_ref", `Qualified proposal ref bundle "${parsedRef.origin}" conflicts with queue target "${input.target.source}".`);
|
|
337
|
+
}
|
|
226
338
|
// Description check is only enforced for `consolidate` source — that's the
|
|
227
339
|
// automated pipeline that historically produced proposals with missing or
|
|
228
340
|
// malformed frontmatter, polluting the queue with hundreds of unusable
|
|
@@ -234,18 +346,73 @@ export function createProposal(stashDir, input, ctx) {
|
|
|
234
346
|
return rejectProposal("missing_description", `Proposal for "${input.ref}" (source=consolidate) has empty or missing frontmatter description.`);
|
|
235
347
|
}
|
|
236
348
|
}
|
|
237
|
-
const
|
|
349
|
+
const proposalTarget = resolveCreateProposalTarget(stashDir, input.target, parsedRef.origin);
|
|
350
|
+
const normalizedRef = proposalDurableRef(parsedRef, proposalTarget);
|
|
351
|
+
const targetRoot = path.resolve(proposalTarget.root);
|
|
352
|
+
// WI-6.2: derive the FileChange[] envelope + mint-time beforeHash. The
|
|
353
|
+
// target is resolved against the proposal's OWN stash (a local snapshot —
|
|
354
|
+
// accept re-resolves the write target from config at apply time), and only
|
|
355
|
+
// the before-state's HASH is kept: the change's `before` body is a
|
|
356
|
+
// transaction-time capture that does not exist at mint time.
|
|
357
|
+
let targetRelPath;
|
|
358
|
+
let mintBeforeContent;
|
|
359
|
+
try {
|
|
360
|
+
const typeRoot = path.join(targetRoot, stashDirFor(parsedRef.type));
|
|
361
|
+
const targetAbs = assetPathForName(parsedRef.type, typeRoot, parsedRef.name);
|
|
362
|
+
targetRelPath = path.relative(targetRoot, targetAbs);
|
|
363
|
+
if (fs.existsSync(targetAbs))
|
|
364
|
+
mintBeforeContent = fs.readFileSync(targetAbs, "utf8");
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
// Resolution failure degrades to a best-effort create — never blocks the mint.
|
|
368
|
+
targetRelPath = path.join(stashDirFor(parsedRef.type), parsedRef.name);
|
|
369
|
+
}
|
|
370
|
+
const proposalContent = targetRelPath.toLowerCase().endsWith(".md")
|
|
371
|
+
? ensureAkmMarkdownType(input.payload.content, parsedRef.type)
|
|
372
|
+
: input.payload.content;
|
|
373
|
+
const mintedChanges = [
|
|
374
|
+
{
|
|
375
|
+
path: targetRelPath,
|
|
376
|
+
after: proposalContent,
|
|
377
|
+
op: mintBeforeContent !== undefined ? "update" : "create",
|
|
378
|
+
},
|
|
379
|
+
];
|
|
380
|
+
const mintedBeforeHash = mintBeforeContent !== undefined ? contentHash(mintBeforeContent) : undefined;
|
|
381
|
+
if (hasCanonicalProposalValidator(parsedRef.type)) {
|
|
382
|
+
const report = validateProposal({
|
|
383
|
+
id: "pending",
|
|
384
|
+
ref: normalizedRef,
|
|
385
|
+
status: "pending",
|
|
386
|
+
source: input.source,
|
|
387
|
+
createdAt: "",
|
|
388
|
+
updatedAt: "",
|
|
389
|
+
payload: { ...input.payload, content: proposalContent },
|
|
390
|
+
changes: mintedChanges,
|
|
391
|
+
proposedTarget: { source: proposalTarget.source, root: targetRoot },
|
|
392
|
+
});
|
|
393
|
+
if (!report.ok) {
|
|
394
|
+
return rejectProposal("invalid_canonical_structure", `Proposal for "${input.ref}" has invalid ${parsedRef.type} structure:\n${report.findings
|
|
395
|
+
.map((finding) => `[${finding.kind}] ${finding.message}`)
|
|
396
|
+
.join("\n")}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const fingerprint = computeProposalFingerprint({
|
|
400
|
+
ref: normalizedRef,
|
|
401
|
+
source: input.source,
|
|
402
|
+
...(mintedBeforeHash !== undefined ? { beforeHash: mintedBeforeHash } : {}),
|
|
403
|
+
...(input.modelId !== undefined ? { modelId: input.modelId } : {}),
|
|
404
|
+
});
|
|
238
405
|
return withProposalsDb(stashDir, ctx, (db) => {
|
|
239
406
|
return withImmediateTransaction(db, () => {
|
|
240
407
|
if (!input.force) {
|
|
241
|
-
const skip =
|
|
408
|
+
const skip = checkFingerprintAndBackoff(db, stashDir, normalizedRef, input, fingerprint, ctx);
|
|
242
409
|
if (skip)
|
|
243
410
|
return skip;
|
|
244
411
|
}
|
|
245
412
|
const created = nowIso(ctx);
|
|
246
413
|
// Phase 6A: validate confidence is a finite number in [0, 1]. Anything else
|
|
247
414
|
// is dropped silently — we never store NaN, Infinity, or out-of-range values.
|
|
248
|
-
// Callers that mis-report confidence should not poison
|
|
415
|
+
// Callers that mis-report confidence should not poison downstream readers.
|
|
249
416
|
const sanitizedConfidence = typeof input.confidence === "number" &&
|
|
250
417
|
Number.isFinite(input.confidence) &&
|
|
251
418
|
input.confidence >= 0 &&
|
|
@@ -261,74 +428,98 @@ export function createProposal(stashDir, input, ctx) {
|
|
|
261
428
|
createdAt: created,
|
|
262
429
|
updatedAt: created,
|
|
263
430
|
payload: {
|
|
264
|
-
content:
|
|
431
|
+
content: proposalContent,
|
|
265
432
|
...(input.payload.frontmatter !== undefined ? { frontmatter: input.payload.frontmatter } : {}),
|
|
266
433
|
},
|
|
434
|
+
changes: mintedChanges,
|
|
435
|
+
proposedTarget: { source: proposalTarget.source, root: targetRoot },
|
|
436
|
+
...(mintedBeforeHash !== undefined ? { beforeHash: mintedBeforeHash } : {}),
|
|
267
437
|
...(sanitizedConfidence !== undefined ? { confidence: sanitizedConfidence } : {}),
|
|
268
438
|
// Attribution tagging: persist the eligibility lane so it survives to
|
|
269
439
|
// accept/reject/revert time. See EligibilitySource.
|
|
270
440
|
...(input.eligibilitySource !== undefined ? { eligibilitySource: input.eligibilitySource } : {}),
|
|
271
441
|
};
|
|
272
442
|
upsertProposal(db, proposal, stashDir);
|
|
443
|
+
// Record the processed fingerprint (also on force — a forced enqueue is
|
|
444
|
+
// still "these inputs were processed"; future unforced identical inputs
|
|
445
|
+
// dedup against it).
|
|
446
|
+
recordProposalFingerprint(db, stashDir, fingerprint, normalizedRef, input, proposal.id, created);
|
|
273
447
|
return proposal;
|
|
274
448
|
});
|
|
275
449
|
});
|
|
276
450
|
}
|
|
451
|
+
/** Version stamp of the input-fingerprint scheme; bump when terms change. */
|
|
452
|
+
const PROPOSAL_FINGERPRINT_VERSION = 1;
|
|
277
453
|
/**
|
|
278
|
-
*
|
|
279
|
-
*
|
|
454
|
+
* Compute the §23.6 input fingerprint for a proposal mint (+ the plan §4.5
|
|
455
|
+
* engine/model-id term). Terms, in order: scheme version, source (the recipe
|
|
456
|
+
* stand-in until Wave-2 recipes exist), target ref, target before-hash
|
|
457
|
+
* (empty for a create), evidence IDs/hashes (reserved — not yet modeled),
|
|
458
|
+
* guidance hashes (reserved), evaluator version (reserved), model id.
|
|
459
|
+
* Deliberately an INPUT fingerprint: the generated content is not a term —
|
|
460
|
+
* already-processed inputs skip re-processing regardless of what the model
|
|
461
|
+
* produced this time.
|
|
280
462
|
*/
|
|
281
|
-
function
|
|
282
|
-
|
|
463
|
+
function computeProposalFingerprint(args) {
|
|
464
|
+
return contentHash([
|
|
465
|
+
`v${PROPOSAL_FINGERPRINT_VERSION}`,
|
|
466
|
+
args.source,
|
|
467
|
+
args.ref,
|
|
468
|
+
args.beforeHash ?? "",
|
|
469
|
+
"", // evidence IDs/hashes — reserved (Wave-2 recipes)
|
|
470
|
+
"", // guidance hashes — reserved
|
|
471
|
+
"", // evaluator version — reserved
|
|
472
|
+
args.modelId ?? "",
|
|
473
|
+
].join("\0"));
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Durably record a processed fingerprint (INSERT OR REPLACE — idempotent).
|
|
477
|
+
* `ref` must be the NORMALIZED ref — the same value the fingerprint was
|
|
478
|
+
* computed over — so future ref-keyed readers of the table never mismatch.
|
|
479
|
+
*/
|
|
480
|
+
function recordProposalFingerprint(db, stashDir, fingerprint, ref, input, proposalId, createdAt) {
|
|
481
|
+
db.prepare(`INSERT OR REPLACE INTO proposal_fingerprints
|
|
482
|
+
(stash_dir, fingerprint, ref, source, model_id, proposal_id, created_at)
|
|
483
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(stashDir, fingerprint, ref, input.source, input.modelId ?? "", proposalId, createdAt);
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Evaluate the fingerprint + rejection-backoff guards. Returns the skip
|
|
487
|
+
* record when a guard fires, or undefined when the create may proceed.
|
|
488
|
+
*/
|
|
489
|
+
function checkFingerprintAndBackoff(db, stashDir, normalizedRef, input, fingerprint, ctx) {
|
|
283
490
|
const nowMs = (ctx?.now ?? Date.now)();
|
|
284
|
-
const
|
|
285
|
-
//
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
reason: "content_hash_match",
|
|
294
|
-
message: `Identical proposal for ${normalizedRef} already pending (id: ${hashMatch.id}).`,
|
|
295
|
-
existingProposalId: hashMatch.id,
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
// Duplicate pending for same ref+source (different content).
|
|
299
|
-
const firstPending = pending[0];
|
|
491
|
+
const backoffMs = cooldownMsForSource(input.source);
|
|
492
|
+
// §23.6: an already-processed fingerprint skips another model call's output
|
|
493
|
+
// unless explicitly forced. The row survives the proposal's lifecycle —
|
|
494
|
+
// identical inputs stay deduplicated until the target (before-hash), the
|
|
495
|
+
// model, or the scheme changes.
|
|
496
|
+
const existing = db
|
|
497
|
+
.prepare("SELECT proposal_id FROM proposal_fingerprints WHERE stash_dir = ? AND fingerprint = ?")
|
|
498
|
+
.get(stashDir, fingerprint);
|
|
499
|
+
if (existing) {
|
|
300
500
|
return {
|
|
301
501
|
skipped: true,
|
|
302
|
-
reason: "
|
|
303
|
-
message: `
|
|
304
|
-
existingProposalId:
|
|
502
|
+
reason: "fingerprint_match",
|
|
503
|
+
message: `These inputs were already processed into a proposal for ${normalizedRef} (fingerprint match). Pass force:true to enqueue anyway.`,
|
|
504
|
+
...(existing.proposal_id ? { existingProposalId: existing.proposal_id } : {}),
|
|
305
505
|
};
|
|
306
506
|
}
|
|
307
|
-
//
|
|
507
|
+
// Rejection backoff (RETAINED cooldown semantics): a recent rejection for
|
|
508
|
+
// this ref+source suppresses new proposals until the window expires.
|
|
308
509
|
const rejected = listStateProposals(db, { stashDir, ref: normalizedRef, status: "rejected" })
|
|
309
510
|
.filter((p) => p.source === input.source)
|
|
310
511
|
.sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime());
|
|
311
512
|
const mostRecent = rejected[0];
|
|
312
513
|
if (mostRecent !== undefined) {
|
|
313
|
-
// Check content hash against recently rejected.
|
|
314
|
-
if (contentHash(mostRecent.payload.content) === newHash) {
|
|
315
|
-
return {
|
|
316
|
-
skipped: true,
|
|
317
|
-
reason: "content_hash_match",
|
|
318
|
-
message: `Identical proposal for ${normalizedRef} was already rejected (id: ${mostRecent.id}).`,
|
|
319
|
-
existingProposalId: mostRecent.id,
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
// Check cooldown window.
|
|
323
514
|
const rejectedAt = new Date(mostRecent.updatedAt ?? 0).getTime();
|
|
324
|
-
if (nowMs - rejectedAt <
|
|
325
|
-
const
|
|
326
|
-
const remainingDays = Math.ceil((
|
|
515
|
+
if (nowMs - rejectedAt < backoffMs) {
|
|
516
|
+
const backoffDays = backoffMs / MS_PER_DAY;
|
|
517
|
+
const remainingDays = Math.ceil((backoffMs - (nowMs - rejectedAt)) / MS_PER_DAY);
|
|
327
518
|
return {
|
|
328
519
|
skipped: true,
|
|
329
|
-
reason: "
|
|
330
|
-
message: `Proposal for ${normalizedRef} from source "${input.source}" is in
|
|
331
|
-
`(${
|
|
520
|
+
reason: "rejection_backoff",
|
|
521
|
+
message: `Proposal for ${normalizedRef} from source "${input.source}" is in rejection backoff ` +
|
|
522
|
+
`(${backoffDays}d window, ~${remainingDays}d remaining). Pass force:true to bypass.`,
|
|
332
523
|
existingProposalId: mostRecent.id,
|
|
333
524
|
};
|
|
334
525
|
}
|
|
@@ -349,15 +540,21 @@ export function listProposals(stashDir, options = {}, ctx) {
|
|
|
349
540
|
return [];
|
|
350
541
|
}
|
|
351
542
|
const status = options.includeArchive ? options.status : "pending";
|
|
543
|
+
// Short filters match by conceptId; qualified filters additionally retain
|
|
544
|
+
// bundle identity. Applied in JS because a short query does not equal the
|
|
545
|
+
// fully-qualified stored ref.
|
|
546
|
+
const wantRef = options.ref !== undefined ? filterRefIdentity(options.ref) : undefined;
|
|
352
547
|
return listStateProposals(db, {
|
|
353
548
|
stashDir,
|
|
354
549
|
...(status !== undefined ? { status } : {}),
|
|
355
|
-
...(options.ref !== undefined ? { ref: options.ref } : {}),
|
|
356
550
|
}).filter((p) => {
|
|
551
|
+
if (wantRef !== undefined && !proposalMatchesRef(p.ref, wantRef)) {
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
357
554
|
if (!options.type)
|
|
358
555
|
return true;
|
|
359
556
|
try {
|
|
360
|
-
return
|
|
557
|
+
return parseRefInput(p.ref).type === options.type;
|
|
361
558
|
}
|
|
362
559
|
catch {
|
|
363
560
|
return false;
|
|
@@ -379,12 +576,31 @@ function requireProposal(db, stashDir, id) {
|
|
|
379
576
|
}
|
|
380
577
|
return proposal;
|
|
381
578
|
}
|
|
579
|
+
/**
|
|
580
|
+
* Lenient counterpart to {@link requireProposal} / {@link getProposal}, used
|
|
581
|
+
* ONLY on the terminal-status (reject/archive) read paths — `archiveProposal`
|
|
582
|
+
* and the reject-transaction machinery (`rejectProposalDurably`,
|
|
583
|
+
* `finalizeRejectTransaction`). Those paths must succeed even when the row's
|
|
584
|
+
* metadata fails strict decoding (see {@link proposalRowToProposalLenient});
|
|
585
|
+
* every other reader (accept, revert, show, list's pending branch) keeps
|
|
586
|
+
* strict decoding via `getProposal`/`requireProposal` unchanged.
|
|
587
|
+
*/
|
|
588
|
+
function requireProposalLenient(db, stashDir, id) {
|
|
589
|
+
const proposal = getStateProposalLenient(db, id, stashDir);
|
|
590
|
+
if (!proposal) {
|
|
591
|
+
throw new NotFoundError(`Proposal "${id}" not found.`, "FILE_NOT_FOUND");
|
|
592
|
+
}
|
|
593
|
+
return proposal;
|
|
594
|
+
}
|
|
595
|
+
function getProposalLenient(stashDir, id, ctx) {
|
|
596
|
+
return withProposalsDb(stashDir, ctx, (db) => requireProposalLenient(db, stashDir, id));
|
|
597
|
+
}
|
|
382
598
|
/**
|
|
383
599
|
* Resolve a proposal by full UUID, UUID prefix, or asset ref.
|
|
384
600
|
*
|
|
385
601
|
* Resolution order:
|
|
386
602
|
* 1. Exact UUID match (existing behaviour).
|
|
387
|
-
* 2. Asset ref (contains
|
|
603
|
+
* 2. Asset ref (contains `/`) — finds the most-recent pending proposal for
|
|
388
604
|
* that ref; falls back to archived if nothing is pending.
|
|
389
605
|
* 3. UUID prefix — matches any PENDING proposal whose id starts with the
|
|
390
606
|
* given string; throws if ambiguous.
|
|
@@ -395,14 +611,16 @@ export function resolveProposalId(stashDir, idOrRef, ctx) {
|
|
|
395
611
|
const exact = getStateProposal(db, idOrRef, stashDir);
|
|
396
612
|
if (exact)
|
|
397
613
|
return exact;
|
|
398
|
-
// 2. Asset ref
|
|
399
|
-
//
|
|
400
|
-
|
|
614
|
+
// 2. Asset ref — most recent pending, else most recent archived. Qualified
|
|
615
|
+
// refs retain bundle identity; short refs match by conceptId in this queue.
|
|
616
|
+
const wantRef = idOrRef.includes(":") || idOrRef.includes("/") ? filterRefIdentity(idOrRef) : undefined;
|
|
617
|
+
if (wantRef !== undefined) {
|
|
401
618
|
const byRecency = (proposals) => proposals.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
|
|
402
|
-
const
|
|
619
|
+
const forConcept = (status) => listStateProposals(db, { stashDir, ...(status !== undefined ? { status } : {}) }).filter((p) => proposalMatchesRef(p.ref, wantRef));
|
|
620
|
+
const pending = byRecency(forConcept("pending"));
|
|
403
621
|
if (pending)
|
|
404
622
|
return pending;
|
|
405
|
-
const archived = byRecency(
|
|
623
|
+
const archived = byRecency(forConcept());
|
|
406
624
|
if (archived)
|
|
407
625
|
return archived;
|
|
408
626
|
throw new NotFoundError(`No proposal found for ref "${idOrRef}".`, "FILE_NOT_FOUND");
|
|
@@ -417,15 +635,34 @@ export function resolveProposalId(stashDir, idOrRef, ctx) {
|
|
|
417
635
|
throw new NotFoundError(`Proposal "${idOrRef}" not found.`, "FILE_NOT_FOUND");
|
|
418
636
|
});
|
|
419
637
|
}
|
|
638
|
+
/**
|
|
639
|
+
* Resolve a reject target's id, tolerating a legacy row whose metadata fails
|
|
640
|
+
* strict decoding. `akm proposal reject` only needs the id — but
|
|
641
|
+
* {@link resolveProposalId}'s exact-UUID fast path decodes the FULL proposal
|
|
642
|
+
* just to hand it back, which throws on those rows before the
|
|
643
|
+
* terminal-status machinery (which reads leniently — see
|
|
644
|
+
* `requireProposalLenient`) ever runs. Falls back to `resolveProposalId`
|
|
645
|
+
* unchanged for ref / uuid-prefix inputs, where existing resolution
|
|
646
|
+
* (including its error semantics) is preserved.
|
|
647
|
+
*/
|
|
648
|
+
export function resolveProposalIdForReject(stashDir, idOrRef, ctx) {
|
|
649
|
+
const exact = withProposalsDb(stashDir, ctx, (db) => getStateProposalLenient(db, idOrRef, stashDir));
|
|
650
|
+
if (exact)
|
|
651
|
+
return exact.id;
|
|
652
|
+
return resolveProposalId(stashDir, idOrRef, ctx).id;
|
|
653
|
+
}
|
|
420
654
|
/**
|
|
421
655
|
* Archive a proposal: flip its status to `accepted` / `rejected`, bump
|
|
422
656
|
* `updatedAt`, and record the review block. Used by both accept and reject
|
|
423
657
|
* paths so the live queue only contains pending entries.
|
|
424
658
|
*/
|
|
425
|
-
export function archiveProposal(stashDir, id, status, reason, ctx) {
|
|
659
|
+
export function archiveProposal(stashDir, id, status, reason, ctx, gateDecision) {
|
|
426
660
|
return withProposalsDb(stashDir, ctx, (db) => {
|
|
427
661
|
return withImmediateTransaction(db, () => {
|
|
428
|
-
|
|
662
|
+
// Lenient: archiving (accept OR reject) a legacy row with malformed
|
|
663
|
+
// changes/proposedTarget metadata must still be able to flip status —
|
|
664
|
+
// see requireProposalLenient.
|
|
665
|
+
const existing = requireProposalLenient(db, stashDir, id);
|
|
429
666
|
if (existing.status !== "pending") {
|
|
430
667
|
throw new UsageError(`Proposal ${id} is not pending (current status: ${existing.status}). Only pending proposals can be ${status}.`, "INVALID_FLAG_VALUE");
|
|
431
668
|
}
|
|
@@ -439,16 +676,18 @@ export function archiveProposal(stashDir, id, status, reason, ctx) {
|
|
|
439
676
|
...(reason !== undefined ? { reason } : {}),
|
|
440
677
|
decidedAt,
|
|
441
678
|
},
|
|
679
|
+
...(gateDecision ? { gateDecision: { ...gateDecision, decidedAt: gateDecision.decidedAt ?? decidedAt } } : {}),
|
|
442
680
|
};
|
|
443
|
-
|
|
681
|
+
persistProposalUpdate(db, updated, stashDir);
|
|
444
682
|
return updated;
|
|
445
683
|
});
|
|
446
684
|
});
|
|
447
685
|
}
|
|
448
686
|
/**
|
|
449
|
-
* Record
|
|
687
|
+
* Record the drain/triage engine's decision onto a proposal (#577).
|
|
688
|
+
* Drain-owned audit machinery — the deterministic drain engine is the writer.
|
|
450
689
|
*
|
|
451
|
-
* Stamps `gateDecision` (decision / reason /
|
|
690
|
+
* Stamps `gateDecision` (decision / reason / measurement / thresholds) onto the
|
|
452
691
|
* row so `akm proposal show` and `list` can explain why a proposal landed where
|
|
453
692
|
* it did. The decision is metadata about the adjudication, so this does NOT
|
|
454
693
|
* change `status` or bump `updatedAt` — a `deferred` proposal stays `pending`,
|
|
@@ -469,7 +708,7 @@ export function recordGateDecision(stashDir, id, decision, ctx) {
|
|
|
469
708
|
...existing,
|
|
470
709
|
gateDecision: { ...decision, decidedAt: decision.decidedAt ?? nowIso(ctx) },
|
|
471
710
|
};
|
|
472
|
-
|
|
711
|
+
persistProposalUpdate(db, updated, stashDir);
|
|
473
712
|
return updated;
|
|
474
713
|
});
|
|
475
714
|
});
|
|
@@ -493,7 +732,7 @@ export function purgeOrphanProposals(stashDir, sourceDirs, ctx) {
|
|
|
493
732
|
for (const p of reflectPending) {
|
|
494
733
|
let parsed;
|
|
495
734
|
try {
|
|
496
|
-
parsed =
|
|
735
|
+
parsed = parseRefInput(p.ref);
|
|
497
736
|
}
|
|
498
737
|
catch {
|
|
499
738
|
continue;
|
|
@@ -501,12 +740,12 @@ export function purgeOrphanProposals(stashDir, sourceDirs, ctx) {
|
|
|
501
740
|
// Lessons are new-asset proposals by definition — they cannot be orphaned.
|
|
502
741
|
if (parsed.type === "lesson")
|
|
503
742
|
continue;
|
|
504
|
-
const spec =
|
|
743
|
+
const spec = stashDirFor(parsed.type);
|
|
505
744
|
if (!spec)
|
|
506
745
|
continue;
|
|
507
746
|
const exists = sourceDirs.some((root) => {
|
|
508
747
|
const typeRoot = path.join(root, spec);
|
|
509
|
-
const candidate =
|
|
748
|
+
const candidate = assetPathForName(parsed.type, typeRoot, parsed.name);
|
|
510
749
|
return fs.existsSync(candidate);
|
|
511
750
|
});
|
|
512
751
|
if (!exists) {
|
|
@@ -596,6 +835,15 @@ export function expireStaleProposals(stashDir, config, ctx) {
|
|
|
596
835
|
warn(`[proposals] expireStaleProposals: failed to expire ${p.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
597
836
|
}
|
|
598
837
|
}
|
|
838
|
+
// Prune fingerprint rows past the same retention window (best-effort):
|
|
839
|
+
// ISO created_at strings compare lexicographically.
|
|
840
|
+
try {
|
|
841
|
+
const cutoffIso = new Date(nowMs - retentionMs).toISOString();
|
|
842
|
+
withProposalsDb(stashDir, ctx, (db) => db.prepare("DELETE FROM proposal_fingerprints WHERE stash_dir = ? AND created_at < ?").run(stashDir, cutoffIso));
|
|
843
|
+
}
|
|
844
|
+
catch (err) {
|
|
845
|
+
warn(`[proposals] expireStaleProposals: fingerprint prune failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
846
|
+
}
|
|
599
847
|
return {
|
|
600
848
|
checked: pending.length,
|
|
601
849
|
expired: expiredProposals.length,
|
|
@@ -604,6 +852,676 @@ export function expireStaleProposals(stashDir, config, ctx) {
|
|
|
604
852
|
expiredProposals,
|
|
605
853
|
};
|
|
606
854
|
}
|
|
855
|
+
const PROPOSAL_TXN_KIND = "proposal";
|
|
856
|
+
const PROPOSAL_TXN_PHASES = [
|
|
857
|
+
"prepared",
|
|
858
|
+
"asset-published",
|
|
859
|
+
"proposal-persisted",
|
|
860
|
+
"index-finalized",
|
|
861
|
+
"event-finalized",
|
|
862
|
+
"committed",
|
|
863
|
+
];
|
|
864
|
+
/** TEST-ONLY crash-window hook used by subprocess recovery tests. */
|
|
865
|
+
export function _setProposalMutationHookForTests(hook) {
|
|
866
|
+
_setTxnMutationHookForTests(hook);
|
|
867
|
+
}
|
|
868
|
+
function proposalHash(content) {
|
|
869
|
+
return createHash("sha256").update(content).digest("hex");
|
|
870
|
+
}
|
|
871
|
+
function proposalFileHash(filePath) {
|
|
872
|
+
return proposalHash(fs.readFileSync(filePath));
|
|
873
|
+
}
|
|
874
|
+
function sameProposalFile(left, right) {
|
|
875
|
+
try {
|
|
876
|
+
const leftStat = fs.statSync(left);
|
|
877
|
+
const rightStat = fs.statSync(right);
|
|
878
|
+
return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
|
|
879
|
+
}
|
|
880
|
+
catch {
|
|
881
|
+
return false;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
function cleanupProposalPublication(p) {
|
|
885
|
+
for (const filePath of [p.publishPath, p.displacedPath]) {
|
|
886
|
+
try {
|
|
887
|
+
fs.rmSync(filePath, { force: true });
|
|
888
|
+
}
|
|
889
|
+
catch (error) {
|
|
890
|
+
warn(`[proposals] transaction publication cleanup failed at ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
fsyncTxnDir(path.dirname(p.assetPath));
|
|
894
|
+
}
|
|
895
|
+
function rollbackPreparedProposalTransaction(txn) {
|
|
896
|
+
const p = txn.journal.payload;
|
|
897
|
+
const currentHash = fs.existsSync(p.assetPath) ? proposalFileHash(p.assetPath) : null;
|
|
898
|
+
if (!fs.existsSync(p.displacedPath)) {
|
|
899
|
+
if (p.originalHash === null) {
|
|
900
|
+
if (currentHash === p.publishedHash && sameProposalFile(p.assetPath, p.publishPath)) {
|
|
901
|
+
fs.unlinkSync(p.assetPath);
|
|
902
|
+
}
|
|
903
|
+
else if (currentHash !== null) {
|
|
904
|
+
throw new Error(`Cannot roll back proposal transaction: target was created externally.`);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
else if (currentHash !== p.originalHash) {
|
|
908
|
+
throw new Error(`Cannot roll back proposal transaction: ${p.assetPath} diverged.`);
|
|
909
|
+
}
|
|
910
|
+
cleanupProposalPublication(p);
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
if (currentHash === p.publishedHash)
|
|
914
|
+
fs.unlinkSync(p.assetPath);
|
|
915
|
+
else if (currentHash !== null && currentHash !== p.originalHash) {
|
|
916
|
+
throw new Error(`Cannot roll back proposal transaction: ${p.assetPath} diverged.`);
|
|
917
|
+
}
|
|
918
|
+
if (fs.existsSync(p.displacedPath)) {
|
|
919
|
+
if (fs.existsSync(p.assetPath)) {
|
|
920
|
+
throw new Error(`Cannot restore proposal backup: ${p.assetPath} is occupied.`);
|
|
921
|
+
}
|
|
922
|
+
fs.linkSync(p.displacedPath, p.assetPath);
|
|
923
|
+
}
|
|
924
|
+
cleanupProposalPublication(p);
|
|
925
|
+
}
|
|
926
|
+
function validatePublishedProposal(p) {
|
|
927
|
+
if (!fs.existsSync(p.assetPath) || proposalFileHash(p.assetPath) !== p.publishedHash) {
|
|
928
|
+
throw new Error(`Cannot recover proposal ${p.proposalId}: published asset diverged.`);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
function persistProposalTransactionState(txn, proposal, ctx) {
|
|
932
|
+
const p = txn.journal.payload;
|
|
933
|
+
const decidedAt = txn.journal.decidedAt;
|
|
934
|
+
const backupContent = p.backupPath ? fs.readFileSync(p.backupPath, "utf8") : undefined;
|
|
935
|
+
const publishedContent = fs.readFileSync(p.contentPath, "utf8");
|
|
936
|
+
return withProposalsDb(p.stashDir, ctx, (db) => withImmediateTransaction(db, () => {
|
|
937
|
+
const current = requireProposal(db, p.stashDir, p.proposalId);
|
|
938
|
+
if (p.operation === "accept") {
|
|
939
|
+
if (current.status === "accepted") {
|
|
940
|
+
if (current.acceptedTarget?.contentHash !== p.publishedHash) {
|
|
941
|
+
throw new Error(`Accepted proposal ${p.proposalId} does not match its recovery journal.`);
|
|
942
|
+
}
|
|
943
|
+
return current;
|
|
944
|
+
}
|
|
945
|
+
if (current.status !== "pending") {
|
|
946
|
+
throw new Error(`Proposal ${p.proposalId} changed status during acceptance (${current.status}).`);
|
|
947
|
+
}
|
|
948
|
+
const persistedProposal = proposal.changes.length > 0 && proposal.changes.every((change) => change.path.length > 0)
|
|
949
|
+
? withProposalContent(proposal, publishedContent)
|
|
950
|
+
: {
|
|
951
|
+
...proposal,
|
|
952
|
+
payload: { ...proposal.payload, content: publishedContent },
|
|
953
|
+
changes: [
|
|
954
|
+
{
|
|
955
|
+
path: path.relative(txn.journal.root, p.assetPath),
|
|
956
|
+
op: p.originalHash === null ? "create" : "update",
|
|
957
|
+
after: publishedContent,
|
|
958
|
+
},
|
|
959
|
+
],
|
|
960
|
+
proposedTarget: { source: p.targetSource, root: txn.journal.root },
|
|
961
|
+
};
|
|
962
|
+
const accepted = {
|
|
963
|
+
...persistedProposal,
|
|
964
|
+
status: "accepted",
|
|
965
|
+
updatedAt: decidedAt,
|
|
966
|
+
review: { outcome: "accepted", decidedAt },
|
|
967
|
+
acceptedTarget: {
|
|
968
|
+
source: p.targetSource,
|
|
969
|
+
root: txn.journal.root,
|
|
970
|
+
path: p.assetPath,
|
|
971
|
+
contentHash: p.publishedHash,
|
|
972
|
+
},
|
|
973
|
+
...(p.gateDecision
|
|
974
|
+
? { gateDecision: { ...p.gateDecision, decidedAt: p.gateDecision.decidedAt ?? decidedAt } }
|
|
975
|
+
: {}),
|
|
976
|
+
...(backupContent !== undefined ? { backupContent } : {}),
|
|
977
|
+
};
|
|
978
|
+
upsertProposal(db, accepted, p.stashDir);
|
|
979
|
+
return accepted;
|
|
980
|
+
}
|
|
981
|
+
if (current.status === "reverted")
|
|
982
|
+
return current;
|
|
983
|
+
if (current.status !== "accepted") {
|
|
984
|
+
throw new Error(`Proposal ${p.proposalId} changed status during reversion (${current.status}).`);
|
|
985
|
+
}
|
|
986
|
+
const reverted = {
|
|
987
|
+
...current,
|
|
988
|
+
status: "reverted",
|
|
989
|
+
updatedAt: decidedAt,
|
|
990
|
+
review: {
|
|
991
|
+
outcome: "rejected",
|
|
992
|
+
reason: "reverted: prior content restored from backup",
|
|
993
|
+
decidedAt,
|
|
994
|
+
},
|
|
995
|
+
};
|
|
996
|
+
upsertProposal(db, reverted, p.stashDir);
|
|
997
|
+
return reverted;
|
|
998
|
+
}));
|
|
999
|
+
}
|
|
1000
|
+
function persistProposalEvent(txn, proposal, ctx) {
|
|
1001
|
+
const p = txn.journal.payload;
|
|
1002
|
+
withProposalsDb(p.stashDir, ctx, (db) => withImmediateTransaction(db, () => {
|
|
1003
|
+
const metadata = {
|
|
1004
|
+
proposalId: proposal.id,
|
|
1005
|
+
source: proposal.source,
|
|
1006
|
+
...(proposal.sourceRun !== undefined ? { sourceRun: proposal.sourceRun } : {}),
|
|
1007
|
+
assetPath: p.assetPath,
|
|
1008
|
+
...(proposal.eligibilitySource !== undefined ? { eligibilitySource: proposal.eligibilitySource } : {}),
|
|
1009
|
+
...(p.eventMetadata ?? {}),
|
|
1010
|
+
proposalTransactionId: txn.journal.transactionId,
|
|
1011
|
+
};
|
|
1012
|
+
insertEventOnce(db, {
|
|
1013
|
+
eventType: p.operation === "accept" ? "promoted" : "proposal_reverted",
|
|
1014
|
+
ts: txn.journal.decidedAt,
|
|
1015
|
+
ref: p.ref,
|
|
1016
|
+
metadata,
|
|
1017
|
+
idempotencyKey: txn.journal.transactionId,
|
|
1018
|
+
});
|
|
1019
|
+
}));
|
|
1020
|
+
}
|
|
1021
|
+
async function finalizeProposalTransaction(txn, target, proposal, ctx) {
|
|
1022
|
+
const p = txn.journal.payload;
|
|
1023
|
+
validatePublishedProposal(p);
|
|
1024
|
+
cleanupProposalPublication(p);
|
|
1025
|
+
if (txn.journal.phase === "asset-published") {
|
|
1026
|
+
const commitRoot = target.source.repoPath ?? target.source.path;
|
|
1027
|
+
const commitPath = path.relative(commitRoot, p.assetPath).replaceAll(path.sep, "/");
|
|
1028
|
+
publishWriteTargetTransaction(target, p.gitPublication, {
|
|
1029
|
+
transactionId: txn.journal.transactionId,
|
|
1030
|
+
message: `${p.operation === "accept" ? "Update" : "Revert"} ${p.ref}`,
|
|
1031
|
+
paths: [commitPath],
|
|
1032
|
+
snapshots: p.gitSnapshots ?? {},
|
|
1033
|
+
onCommitRecorded: (commit) => {
|
|
1034
|
+
// biome-ignore lint/style/noNonNullAssertion: publishWriteTargetTransaction throws when absent
|
|
1035
|
+
const publication = p.gitPublication;
|
|
1036
|
+
if (publication.commit !== commit) {
|
|
1037
|
+
publication.commit = commit;
|
|
1038
|
+
advanceTxn(txn, "asset-published");
|
|
1039
|
+
}
|
|
1040
|
+
},
|
|
1041
|
+
});
|
|
1042
|
+
persistProposalTransactionState(txn, proposal, ctx);
|
|
1043
|
+
advanceTxn(txn, "proposal-persisted");
|
|
1044
|
+
}
|
|
1045
|
+
let accepted = getProposal(p.stashDir, p.proposalId, ctx);
|
|
1046
|
+
if (txn.journal.phase === "proposal-persisted") {
|
|
1047
|
+
if (!(await indexWrittenAssets(txn.journal.root, [p.assetPath], { bundleId: target.source.name }))) {
|
|
1048
|
+
throw new Error(`Proposal ${p.proposalId} index finalization failed.`);
|
|
1049
|
+
}
|
|
1050
|
+
advanceTxn(txn, "index-finalized");
|
|
1051
|
+
}
|
|
1052
|
+
if (txn.journal.phase === "index-finalized") {
|
|
1053
|
+
accepted = getProposal(p.stashDir, p.proposalId, ctx);
|
|
1054
|
+
persistProposalEvent(txn, accepted, ctx);
|
|
1055
|
+
txnMutationHook("event-persisted");
|
|
1056
|
+
advanceTxn(txn, "event-finalized");
|
|
1057
|
+
}
|
|
1058
|
+
if (txn.journal.phase === "event-finalized")
|
|
1059
|
+
advanceTxn(txn, "committed");
|
|
1060
|
+
return accepted;
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* Kind-level safety fence for a `proposal` journal, run before any recovery
|
|
1064
|
+
* action. The engine fences root binding and the uniform changes[] separately.
|
|
1065
|
+
*/
|
|
1066
|
+
function fenceProposalTxnJournal(journal, txnDir, root) {
|
|
1067
|
+
const p = journal.payload;
|
|
1068
|
+
const refIdentity = proposalRefIdentity(p.ref);
|
|
1069
|
+
if (!["accept", "revert"].includes(p.operation) ||
|
|
1070
|
+
!p.targetSource ||
|
|
1071
|
+
!p.targetKind ||
|
|
1072
|
+
refIdentity?.bundle === undefined ||
|
|
1073
|
+
!isWithin(p.assetPath, root) ||
|
|
1074
|
+
![p.contentPath, p.backupPath]
|
|
1075
|
+
.filter((candidate) => candidate !== null)
|
|
1076
|
+
.every((candidate) => isWithin(candidate, txnDir)) ||
|
|
1077
|
+
![p.publishPath, p.displacedPath].every((candidate) => isWithin(candidate, root) && path.dirname(candidate) === path.dirname(p.assetPath))) {
|
|
1078
|
+
throw new Error(`Refusing unsafe proposal transaction journal at ${path.join(txnDir, "journal.json")}.`);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
function resolveProposalRecoveryTarget(config, journal) {
|
|
1082
|
+
let target;
|
|
1083
|
+
try {
|
|
1084
|
+
target = resolveBundleWriteTarget(config, journal.payload.targetSource);
|
|
1085
|
+
}
|
|
1086
|
+
catch {
|
|
1087
|
+
throw new UsageError(`Proposal transaction ${journal.transactionId} target is no longer configured.`, "INVALID_FLAG_VALUE");
|
|
1088
|
+
}
|
|
1089
|
+
const bundleId = canonicalBundleIdForTarget(config, target);
|
|
1090
|
+
return { ...target, source: { ...target.source, name: bundleId } };
|
|
1091
|
+
}
|
|
1092
|
+
async function recoverProposalTransactions(target, stashDir, ctx) {
|
|
1093
|
+
const completed = new Map();
|
|
1094
|
+
const nsDir = txnNamespaceDir(target.source.path);
|
|
1095
|
+
if (!fs.existsSync(nsDir))
|
|
1096
|
+
return completed;
|
|
1097
|
+
for (const entry of fs.readdirSync(nsDir, { withFileTypes: true })) {
|
|
1098
|
+
if (!entry.isDirectory())
|
|
1099
|
+
continue;
|
|
1100
|
+
const transactionDir = path.join(nsDir, entry.name);
|
|
1101
|
+
const journalPath = path.join(transactionDir, "journal.json");
|
|
1102
|
+
if (!fs.existsSync(journalPath)) {
|
|
1103
|
+
// Journal-less dirs may be a SIBLING kind's beginTxn window (shared
|
|
1104
|
+
// per-root namespace) — sweep only when demonstrably stale.
|
|
1105
|
+
sweepJournallessTxnDir(transactionDir);
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf8"));
|
|
1109
|
+
if (journal.kind !== PROPOSAL_TXN_KIND)
|
|
1110
|
+
continue;
|
|
1111
|
+
if (path.resolve(journal.payload.stashDir) !== path.resolve(stashDir))
|
|
1112
|
+
continue;
|
|
1113
|
+
if (journal.version !== 1 ||
|
|
1114
|
+
canonicalTxnRoot(journal.root) !== canonicalTxnRoot(target.source.path) ||
|
|
1115
|
+
journal.payload.targetSource !== target.source.name ||
|
|
1116
|
+
journal.payload.targetKind !== target.source.kind) {
|
|
1117
|
+
throw new Error(`Refusing unsafe proposal transaction journal at ${journalPath}.`);
|
|
1118
|
+
}
|
|
1119
|
+
fenceProposalTxnJournal(journal, transactionDir, target.source.path);
|
|
1120
|
+
const txn = { journal, journalPath, dir: transactionDir };
|
|
1121
|
+
if (journal.phase === "prepared") {
|
|
1122
|
+
rollbackPreparedProposalTransaction(txn);
|
|
1123
|
+
}
|
|
1124
|
+
else if (journal.phase !== "committed") {
|
|
1125
|
+
const proposal = getProposal(stashDir, journal.payload.proposalId, ctx);
|
|
1126
|
+
completed.set(journal.payload.proposalId, await finalizeProposalTransaction(txn, target, proposal, ctx));
|
|
1127
|
+
}
|
|
1128
|
+
else {
|
|
1129
|
+
completed.set(journal.payload.proposalId, getProposal(stashDir, journal.payload.proposalId, ctx));
|
|
1130
|
+
}
|
|
1131
|
+
cleanupProposalPublication(journal.payload);
|
|
1132
|
+
cleanupTxn(transactionDir);
|
|
1133
|
+
}
|
|
1134
|
+
return completed;
|
|
1135
|
+
}
|
|
1136
|
+
export async function recoverProposalTransactionsForStash(stashDir, config, ctx, proposalId) {
|
|
1137
|
+
const completed = new Map();
|
|
1138
|
+
const matches = listTxnJournals((j) => j.kind === PROPOSAL_TXN_KIND &&
|
|
1139
|
+
path.resolve(j.payload.stashDir) === path.resolve(stashDir) &&
|
|
1140
|
+
(proposalId === undefined || j.payload.proposalId === proposalId));
|
|
1141
|
+
const irreversible = matches.filter((journal) => journal.phase !== "prepared" && journal.phase !== "committed");
|
|
1142
|
+
if (proposalId !== undefined && irreversible.length > 1) {
|
|
1143
|
+
throw new Error(`Conflicting durable proposal transactions exist for ${proposalId}; refusing recovery.`);
|
|
1144
|
+
}
|
|
1145
|
+
const recoveredRoots = new Set();
|
|
1146
|
+
for (const journal of matches) {
|
|
1147
|
+
let target = resolveProposalRecoveryTarget(config, journal);
|
|
1148
|
+
const requiresGitPublication = matches.some((candidate) => candidate.phase === "asset-published" && canonicalTxnRoot(candidate.root) === canonicalTxnRoot(journal.root));
|
|
1149
|
+
if (requiresGitPublication)
|
|
1150
|
+
target = prepareWriteTargetForMutation(target, { allowAhead: true });
|
|
1151
|
+
if (canonicalTxnRoot(target.source.path) !== canonicalTxnRoot(journal.root) ||
|
|
1152
|
+
journal.payload.targetKind !== target.source.kind) {
|
|
1153
|
+
throw new Error(`Proposal transaction ${journal.transactionId} is bound to a different target root.`);
|
|
1154
|
+
}
|
|
1155
|
+
const key = path.resolve(target.source.path);
|
|
1156
|
+
if (recoveredRoots.has(key))
|
|
1157
|
+
continue;
|
|
1158
|
+
const recovered = await recoverProposalTransactions(target, stashDir, ctx);
|
|
1159
|
+
for (const [id, proposal] of recovered)
|
|
1160
|
+
completed.set(id, proposal);
|
|
1161
|
+
recoveredRoots.add(key);
|
|
1162
|
+
}
|
|
1163
|
+
return completed;
|
|
1164
|
+
}
|
|
1165
|
+
const REJECT_TXN_KIND = "proposal-reject";
|
|
1166
|
+
const REJECT_TXN_PHASES = ["prepared", "state-persisted", "event-finalized", "committed"];
|
|
1167
|
+
function finalizeRejectTransaction(txn, ctx) {
|
|
1168
|
+
const p = txn.journal.payload;
|
|
1169
|
+
const decidedAt = txn.journal.decidedAt;
|
|
1170
|
+
// Lenient: a reject transaction must run to completion even on a legacy
|
|
1171
|
+
// row whose metadata fails strict decoding — see requireProposalLenient.
|
|
1172
|
+
let proposal = getProposalLenient(p.stashDir, p.proposalId, ctx);
|
|
1173
|
+
if (txn.journal.phase === "prepared") {
|
|
1174
|
+
if (proposal.status === "pending") {
|
|
1175
|
+
proposal = archiveProposal(p.stashDir, p.proposalId, "rejected", p.reason, { ...ctx, now: () => Date.parse(decidedAt) }, p.gateDecision);
|
|
1176
|
+
}
|
|
1177
|
+
else if (proposal.status !== "rejected") {
|
|
1178
|
+
throw new Error(`Proposal ${p.proposalId} changed status during rejection (${proposal.status}).`);
|
|
1179
|
+
}
|
|
1180
|
+
advanceTxn(txn, "state-persisted");
|
|
1181
|
+
txnMutationHook("reject-state-persisted");
|
|
1182
|
+
}
|
|
1183
|
+
if (txn.journal.phase === "state-persisted") {
|
|
1184
|
+
proposal = getProposalLenient(p.stashDir, p.proposalId, ctx);
|
|
1185
|
+
const eventRef = proposal.ref;
|
|
1186
|
+
const eventMeta = {
|
|
1187
|
+
proposalId: proposal.id,
|
|
1188
|
+
source: proposal.source,
|
|
1189
|
+
...(proposal.sourceRun !== undefined ? { sourceRun: proposal.sourceRun } : {}),
|
|
1190
|
+
...(p.reason !== undefined ? { reason: p.reason } : {}),
|
|
1191
|
+
proposalTransactionId: txn.journal.transactionId,
|
|
1192
|
+
};
|
|
1193
|
+
withProposalsDb(p.stashDir, ctx, (db) => withImmediateTransaction(db, () => {
|
|
1194
|
+
insertEventOnce(db, {
|
|
1195
|
+
eventType: "rejected",
|
|
1196
|
+
ts: decidedAt,
|
|
1197
|
+
ref: eventRef,
|
|
1198
|
+
metadata: eventMeta,
|
|
1199
|
+
idempotencyKey: txn.journal.transactionId,
|
|
1200
|
+
});
|
|
1201
|
+
}));
|
|
1202
|
+
txnMutationHook("reject-event-persisted");
|
|
1203
|
+
advanceTxn(txn, "event-finalized");
|
|
1204
|
+
}
|
|
1205
|
+
if (txn.journal.phase === "event-finalized")
|
|
1206
|
+
advanceTxn(txn, "committed");
|
|
1207
|
+
return proposal;
|
|
1208
|
+
}
|
|
1209
|
+
function recoverRejectTransaction(stashDir, proposalId, ctx) {
|
|
1210
|
+
const nsDir = txnNamespaceDir(stashDir);
|
|
1211
|
+
if (!fs.existsSync(nsDir))
|
|
1212
|
+
return undefined;
|
|
1213
|
+
for (const entry of fs.readdirSync(nsDir, { withFileTypes: true })) {
|
|
1214
|
+
if (!entry.isDirectory())
|
|
1215
|
+
continue;
|
|
1216
|
+
const transactionDir = path.join(nsDir, entry.name);
|
|
1217
|
+
const journalPath = path.join(transactionDir, "journal.json");
|
|
1218
|
+
if (!fs.existsSync(journalPath))
|
|
1219
|
+
continue;
|
|
1220
|
+
const journal = JSON.parse(fs.readFileSync(journalPath, "utf8"));
|
|
1221
|
+
if (journal.kind !== REJECT_TXN_KIND)
|
|
1222
|
+
continue;
|
|
1223
|
+
if (journal.payload.proposalId !== proposalId)
|
|
1224
|
+
continue;
|
|
1225
|
+
if (journal.version !== 1 || path.resolve(journal.payload.stashDir) !== path.resolve(stashDir)) {
|
|
1226
|
+
throw new Error(`Refusing unsafe proposal rejection journal at ${journalPath}.`);
|
|
1227
|
+
}
|
|
1228
|
+
const proposal = finalizeRejectTransaction({ journal, journalPath, dir: transactionDir }, ctx);
|
|
1229
|
+
cleanupTxn(transactionDir);
|
|
1230
|
+
return proposal;
|
|
1231
|
+
}
|
|
1232
|
+
return undefined;
|
|
1233
|
+
}
|
|
1234
|
+
export function rejectProposalDurably(stashDir, proposalId, reason, ctx, gateDecision) {
|
|
1235
|
+
const recovered = recoverRejectTransaction(stashDir, proposalId, ctx);
|
|
1236
|
+
if (recovered)
|
|
1237
|
+
return recovered;
|
|
1238
|
+
// Lenient: see requireProposalLenient — the reject decision itself must not
|
|
1239
|
+
// be blocked by a legacy row's malformed changes/proposedTarget metadata.
|
|
1240
|
+
const proposal = getProposalLenient(stashDir, proposalId, ctx);
|
|
1241
|
+
if (proposal.status !== "pending") {
|
|
1242
|
+
throw new UsageError(`Proposal ${proposalId} is not pending (current status: ${proposal.status}). Only pending proposals can be rejected.`, "INVALID_FLAG_VALUE");
|
|
1243
|
+
}
|
|
1244
|
+
const txn = beginTxn({
|
|
1245
|
+
kind: REJECT_TXN_KIND,
|
|
1246
|
+
root: stashDir,
|
|
1247
|
+
changes: [],
|
|
1248
|
+
payload: {
|
|
1249
|
+
proposalId,
|
|
1250
|
+
stashDir,
|
|
1251
|
+
...(reason !== undefined ? { reason } : {}),
|
|
1252
|
+
...(gateDecision ? { gateDecision } : {}),
|
|
1253
|
+
},
|
|
1254
|
+
decidedAt: nowIso(ctx),
|
|
1255
|
+
});
|
|
1256
|
+
const rejected = finalizeRejectTransaction(txn, ctx);
|
|
1257
|
+
cleanupTxn(txn.dir);
|
|
1258
|
+
return rejected;
|
|
1259
|
+
}
|
|
1260
|
+
function prepareProposalTransaction(stashDir, target, proposal, ref, content, options, ctx) {
|
|
1261
|
+
if (options.operation === "accept")
|
|
1262
|
+
assertAkmAssetWrite(target.source);
|
|
1263
|
+
const assetPath = resolveAssetFilePathSafe(target.source, ref);
|
|
1264
|
+
if (!assetPath)
|
|
1265
|
+
throw new Error(`Cannot resolve proposal target ${proposal.ref}.`);
|
|
1266
|
+
fs.mkdirSync(path.dirname(assetPath), { recursive: true });
|
|
1267
|
+
const normalized = content.endsWith("\n") ? content : `${content}\n`;
|
|
1268
|
+
const publishedHash = proposalHash(normalized);
|
|
1269
|
+
// Mint the id first: the payload embeds paths under the transaction dir,
|
|
1270
|
+
// and the initial `prepared` journal must be written exactly ONCE with its
|
|
1271
|
+
// final contents (crash runners intercept the first rename per phase).
|
|
1272
|
+
const transactionId = mintTxnId();
|
|
1273
|
+
const gitPublication = captureGitPublication(target);
|
|
1274
|
+
const transactionDir = txnDirFor(target.source.path, transactionId);
|
|
1275
|
+
fs.mkdirSync(transactionDir, { recursive: true, mode: 0o700 });
|
|
1276
|
+
const contentPath = path.join(transactionDir, "published-content");
|
|
1277
|
+
const publishPath = path.join(path.dirname(assetPath), `.akm-proposal-${transactionId}.publish`);
|
|
1278
|
+
const displacedPath = path.join(path.dirname(assetPath), `.akm-proposal-${transactionId}.displaced`);
|
|
1279
|
+
fs.writeFileSync(contentPath, normalized, { encoding: "utf8", mode: 0o600 });
|
|
1280
|
+
fsyncTxnFile(contentPath);
|
|
1281
|
+
let persistedBackupPath = null;
|
|
1282
|
+
if (options.backup) {
|
|
1283
|
+
const backupPath = path.join(transactionDir, "backup-content");
|
|
1284
|
+
fs.writeFileSync(backupPath, options.backup, { mode: 0o600 });
|
|
1285
|
+
fsyncTxnFile(backupPath);
|
|
1286
|
+
persistedBackupPath = backupPath;
|
|
1287
|
+
}
|
|
1288
|
+
const txn = beginTxn({
|
|
1289
|
+
kind: PROPOSAL_TXN_KIND,
|
|
1290
|
+
root: target.source.path,
|
|
1291
|
+
transactionId,
|
|
1292
|
+
changes: [
|
|
1293
|
+
{
|
|
1294
|
+
path: assetPath,
|
|
1295
|
+
op: options.originalHash === null ? "create" : "update",
|
|
1296
|
+
beforeHash: options.originalHash,
|
|
1297
|
+
afterHash: publishedHash,
|
|
1298
|
+
},
|
|
1299
|
+
],
|
|
1300
|
+
payload: {
|
|
1301
|
+
operation: options.operation,
|
|
1302
|
+
proposalId: proposal.id,
|
|
1303
|
+
stashDir,
|
|
1304
|
+
targetSource: target.source.name,
|
|
1305
|
+
targetKind: target.source.kind,
|
|
1306
|
+
assetPath,
|
|
1307
|
+
ref: proposal.ref,
|
|
1308
|
+
contentPath,
|
|
1309
|
+
publishPath,
|
|
1310
|
+
displacedPath,
|
|
1311
|
+
backupPath: persistedBackupPath,
|
|
1312
|
+
originalHash: options.originalHash,
|
|
1313
|
+
publishedHash,
|
|
1314
|
+
...(gitPublication ? { gitPublication } : {}),
|
|
1315
|
+
...(options.eventMetadata ? { eventMetadata: options.eventMetadata } : {}),
|
|
1316
|
+
...(options.gateDecision ? { gateDecision: options.gateDecision } : {}),
|
|
1317
|
+
},
|
|
1318
|
+
decidedAt: nowIso(ctx),
|
|
1319
|
+
});
|
|
1320
|
+
try {
|
|
1321
|
+
const mode = fs.existsSync(assetPath) ? fs.statSync(assetPath).mode & 0o777 : 0o644;
|
|
1322
|
+
fs.writeFileSync(publishPath, normalized, { encoding: "utf8", flag: "wx", mode });
|
|
1323
|
+
fsyncTxnFile(publishPath);
|
|
1324
|
+
fsyncTxnDir(path.dirname(assetPath));
|
|
1325
|
+
}
|
|
1326
|
+
catch (error) {
|
|
1327
|
+
rollbackPreparedProposalTransaction(txn);
|
|
1328
|
+
cleanupTxn(txn.dir);
|
|
1329
|
+
throw error;
|
|
1330
|
+
}
|
|
1331
|
+
return txn;
|
|
1332
|
+
}
|
|
1333
|
+
function publishProposalAsset(txn, target) {
|
|
1334
|
+
const p = txn.journal.payload;
|
|
1335
|
+
try {
|
|
1336
|
+
if (p.originalHash !== null) {
|
|
1337
|
+
fs.renameSync(p.assetPath, p.displacedPath);
|
|
1338
|
+
if (proposalFileHash(p.displacedPath) !== p.originalHash) {
|
|
1339
|
+
fs.renameSync(p.displacedPath, p.assetPath);
|
|
1340
|
+
throw new Error(`Proposal target changed while its backup was being acquired.`);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
fs.linkSync(p.publishPath, p.assetPath);
|
|
1344
|
+
fsyncTxnDir(path.dirname(p.assetPath));
|
|
1345
|
+
const snapshot = captureWriteTargetPathSnapshot(target, p.assetPath);
|
|
1346
|
+
if (snapshot)
|
|
1347
|
+
p.gitSnapshots = { [snapshot.path]: snapshot.state };
|
|
1348
|
+
advanceTxn(txn, "asset-published");
|
|
1349
|
+
}
|
|
1350
|
+
catch (error) {
|
|
1351
|
+
rollbackPreparedProposalTransaction(txn);
|
|
1352
|
+
cleanupTxn(txn.dir);
|
|
1353
|
+
throw error;
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
function resolveRecordedProposalTarget(config, proposalId, binding, explicitTarget) {
|
|
1357
|
+
let target;
|
|
1358
|
+
try {
|
|
1359
|
+
target = explicitTarget
|
|
1360
|
+
? resolveWriteTarget(config, explicitTarget)
|
|
1361
|
+
: resolveBundleWriteTarget(config, binding.source);
|
|
1362
|
+
}
|
|
1363
|
+
catch {
|
|
1364
|
+
throw new UsageError(`Proposal ${proposalId} is bound to target "${binding.source}" at ${binding.root}, but that writable target is no longer configured.`, "INVALID_FLAG_VALUE");
|
|
1365
|
+
}
|
|
1366
|
+
const targetBundleId = canonicalBundleIdForTarget(config, target);
|
|
1367
|
+
if (targetBundleId !== binding.source || path.resolve(target.source.path) !== path.resolve(binding.root)) {
|
|
1368
|
+
throw new UsageError(`Proposal ${proposalId} is bound to target "${binding.source}" at ${binding.root}; ` +
|
|
1369
|
+
`--target "${explicitTarget}" resolves to "${targetBundleId}" at ${target.source.path}.`, "INVALID_FLAG_VALUE");
|
|
1370
|
+
}
|
|
1371
|
+
return { ...target, source: { ...target.source, name: targetBundleId } };
|
|
1372
|
+
}
|
|
1373
|
+
function resolveProposalWriteTarget(config, proposal, explicitTarget, queueTarget) {
|
|
1374
|
+
if (!proposal.proposedTarget) {
|
|
1375
|
+
const identity = proposalRefIdentity(proposal.ref);
|
|
1376
|
+
if (!identity)
|
|
1377
|
+
throw new UsageError(`Proposal ${proposal.id} has an invalid ref.`, "INVALID_PROPOSAL");
|
|
1378
|
+
if (identity.bundle !== undefined) {
|
|
1379
|
+
const target = resolveBundleWriteTarget(config, identity.bundle);
|
|
1380
|
+
const targetBundleId = canonicalBundleIdForTarget(config, target);
|
|
1381
|
+
if (explicitTarget !== undefined) {
|
|
1382
|
+
const explicit = resolveWriteTarget(config, explicitTarget);
|
|
1383
|
+
if (canonicalBundleIdForTarget(config, explicit) !== identity.bundle) {
|
|
1384
|
+
throw new UsageError(`Proposal ${proposal.id} ref is bound to bundle "${identity.bundle}", which conflicts with --target "${explicitTarget}".`, "INVALID_FLAG_VALUE");
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
if (queueTarget && canonicalBundleIdForTarget(config, queueTarget) !== identity.bundle) {
|
|
1388
|
+
throw new UsageError(`Proposal ${proposal.id} is bound to a different queue target.`, "INVALID_FLAG_VALUE");
|
|
1389
|
+
}
|
|
1390
|
+
return { ...target, source: { ...target.source, name: targetBundleId } };
|
|
1391
|
+
}
|
|
1392
|
+
const target = explicitTarget ? resolveWriteTarget(config, explicitTarget) : queueTarget;
|
|
1393
|
+
if (!target) {
|
|
1394
|
+
throw new UsageError(`Unbound short proposal ${proposal.id} requires an explicit --target or authenticated --queue context.`, "INVALID_PROPOSAL");
|
|
1395
|
+
}
|
|
1396
|
+
const targetBundleId = canonicalBundleIdForTarget(config, target);
|
|
1397
|
+
return { ...target, source: { ...target.source, name: targetBundleId } };
|
|
1398
|
+
}
|
|
1399
|
+
if (queueTarget && explicitTarget === undefined) {
|
|
1400
|
+
const queueBundleId = canonicalBundleIdForTarget(config, queueTarget);
|
|
1401
|
+
if (queueBundleId !== proposal.proposedTarget.source ||
|
|
1402
|
+
path.resolve(queueTarget.source.path) !== path.resolve(proposal.proposedTarget.root)) {
|
|
1403
|
+
throw new UsageError(`Proposal ${proposal.id} is bound to a different queue target.`, "INVALID_FLAG_VALUE");
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
return resolveRecordedProposalTarget(config, proposal.id, proposal.proposedTarget, explicitTarget);
|
|
1407
|
+
}
|
|
1408
|
+
// ── D2 (#730) — OKF v0.2 provenance stamping on promotion ───────────────────
|
|
1409
|
+
//
|
|
1410
|
+
// The proposals system already tracks exactly what OKF v0.2 wants on disk —
|
|
1411
|
+
// `source`/`sourceRun` (PROV-DM modeled, `proposal-types.ts` §80-120),
|
|
1412
|
+
// `gateDecision` (`:200-237`), `review` (`:170-174`) — but none of it leaves
|
|
1413
|
+
// state.db. This section projects it onto the written asset's frontmatter at
|
|
1414
|
+
// promotion time, AKM-native assets only (an OKF-adapter target never reaches
|
|
1415
|
+
// this function — `assertAkmAssetWrite` rejects it earlier in
|
|
1416
|
+
// `promoteProposalWithLease`, before any of this runs).
|
|
1417
|
+
//
|
|
1418
|
+
// Two DISTINCT actors, deliberately not conflated (documented judgment call —
|
|
1419
|
+
// see the PR body for the alternative considered and rejected):
|
|
1420
|
+
// - `generated.by` answers "what produced the CONTENT" — keyed on
|
|
1421
|
+
// `isAutomatedProposalSource(proposal.source)`: an automated pipeline
|
|
1422
|
+
// (reflect/distill/consolidate/extract/improve/schema-repair) stamps
|
|
1423
|
+
// `akm/<pkgVersion>`; a human-initiated source (propose/remember/import)
|
|
1424
|
+
// or the semi-automated `feedback` source stamps `human:<actorId>`.
|
|
1425
|
+
// - `verified[0].by` answers "what accepted/reviewed THIS promotion" —
|
|
1426
|
+
// keyed on whether a `gateDecision` was supplied to THIS call: present
|
|
1427
|
+
// (the automated drain/triage path decided) stamps `akm/<pkgVersion>`;
|
|
1428
|
+
// absent (a human explicitly ran `akm proposal accept`) stamps
|
|
1429
|
+
// `human:<actorId>`. Every promotion reaches this function via exactly
|
|
1430
|
+
// one of those two paths, so `verified` is always stamped — there is no
|
|
1431
|
+
// third, unreviewed path to disk.
|
|
1432
|
+
/** Resolve the `human:<id>` actor id — the OS account, the least-surprising stand-in for "the human at this keyboard" in a system with no multi-user identity. */
|
|
1433
|
+
function resolveActorId(ctx) {
|
|
1434
|
+
if (ctx?.actorId)
|
|
1435
|
+
return ctx.actorId();
|
|
1436
|
+
try {
|
|
1437
|
+
const username = os.userInfo().username?.trim();
|
|
1438
|
+
return username ? username : "local";
|
|
1439
|
+
}
|
|
1440
|
+
catch {
|
|
1441
|
+
return "local";
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
/** `generated.by` — keyed on the SOURCE that produced the content (see file-header note above). */
|
|
1445
|
+
function generatedByActor(proposal, ctx) {
|
|
1446
|
+
return isAutomatedProposalSource(proposal.source) ? `akm/${pkgVersion}` : `human:${resolveActorId(ctx)}`;
|
|
1447
|
+
}
|
|
1448
|
+
/** `verified[0].by` — keyed on whether THIS promotion was gated (automated) or a direct human accept (see file-header note above). */
|
|
1449
|
+
function verifiedByActor(gateDecision, ctx) {
|
|
1450
|
+
return gateDecision !== undefined ? `akm/${pkgVersion}` : `human:${resolveActorId(ctx)}`;
|
|
1451
|
+
}
|
|
1452
|
+
/** True for a plain (non-null, non-array) object. */
|
|
1453
|
+
function isPlainRecord(value) {
|
|
1454
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1455
|
+
}
|
|
1456
|
+
/**
|
|
1457
|
+
* Stamp OKF v0.2 provenance onto ONE promoted asset's frontmatter (D2.1/D2.2/
|
|
1458
|
+
* D2.3), in the **hybrid** on-disk shape settled by the #730 review:
|
|
1459
|
+
*
|
|
1460
|
+
* - `generated: {by, at}` and `verified: [{by, at}]` are written **bare at the
|
|
1461
|
+
* top level**, exactly as OKF v0.2 spells them (SPEC §5.2/§5.3). Neither key
|
|
1462
|
+
* has any pre-existing AKM consumer, so spelling them the spec's way costs
|
|
1463
|
+
* nothing and makes `okf-support.md`'s "AKM Markdown is an OKF-compatible
|
|
1464
|
+
* superset" positioning actually true for trust metadata: a third-party OKF
|
|
1465
|
+
* v0.2 reader pointed at an AKM stash sees conformant provenance.
|
|
1466
|
+
* - `sources` stays namespaced under `provenance:`, because a bare top-level
|
|
1467
|
+
* `sources:` genuinely collides with the pre-existing AKM-native wiki
|
|
1468
|
+
* citation-**string** convention
|
|
1469
|
+
* (`indexer/passes/metadata.ts#applyWikiFrontmatter`, which silently drops
|
|
1470
|
+
* non-strings) on a promoted wiki page.
|
|
1471
|
+
*
|
|
1472
|
+
* The read side back into `IndexDocument.provenance` is
|
|
1473
|
+
* `metadata.ts#applyProvenanceFrontmatter` (which accepts both this shape and
|
|
1474
|
+
* the older fully-nested one), carried through `akm-adapter.ts`'s
|
|
1475
|
+
* `DOCUMENT_JSON_CARRIED_FIELDS` (D2.4).
|
|
1476
|
+
*
|
|
1477
|
+
* A no-op frontmatter mutation preserves the existing frontmatter block's raw
|
|
1478
|
+
* body bytes (mirrors `frontmatter.ts#mutateFrontmatter`'s documented
|
|
1479
|
+
* contract) rather than reshaping via `assembleAsset`, which would strip
|
|
1480
|
+
* leading body blank lines / force a trailing newline. A file with no
|
|
1481
|
+
* frontmatter block at all (non-conformant input) gains one via
|
|
1482
|
+
* `assembleAsset`, exactly as any other first-frontmatter write would.
|
|
1483
|
+
*/
|
|
1484
|
+
function stampProposalProvenance(content, proposal, gateDecision, ctx, nowIsoStr) {
|
|
1485
|
+
const parsed = parseFrontmatter(content);
|
|
1486
|
+
const fm = { ...parsed.data };
|
|
1487
|
+
const existingProvenance = isPlainRecord(fm.provenance) ? fm.provenance : {};
|
|
1488
|
+
// Bare `generated:` — OKF v0.2's replacement for `timestamp` (SPEC §13).
|
|
1489
|
+
fm.generated = { by: generatedByActor(proposal, ctx), at: nowIsoStr };
|
|
1490
|
+
// Bare `verified:` — append, so independent confirmations accumulate rather
|
|
1491
|
+
// than the newest overwriting the record. Both the bare list and the older
|
|
1492
|
+
// nested spelling are absorbed, so a re-promotion never loses history.
|
|
1493
|
+
const priorVerified = Array.isArray(fm.verified)
|
|
1494
|
+
? fm.verified
|
|
1495
|
+
: Array.isArray(existingProvenance.verified)
|
|
1496
|
+
? existingProvenance.verified
|
|
1497
|
+
: [];
|
|
1498
|
+
fm.verified = [
|
|
1499
|
+
...priorVerified,
|
|
1500
|
+
{ by: verifiedByActor(gateDecision, ctx), at: gateDecision?.decidedAt ?? nowIsoStr },
|
|
1501
|
+
];
|
|
1502
|
+
// `sources` alone stays namespaced — bare `sources:` is the wiki
|
|
1503
|
+
// citation-string convention. Drop the nested provenance block entirely when
|
|
1504
|
+
// it would otherwise be empty, so unrelated assets gain no dead key.
|
|
1505
|
+
const provenance = { ...existingProvenance };
|
|
1506
|
+
delete provenance.generatedBy;
|
|
1507
|
+
delete provenance.generatedAt;
|
|
1508
|
+
delete provenance.verified;
|
|
1509
|
+
const evidenceSources = fm.evidenceSources;
|
|
1510
|
+
if (Array.isArray(evidenceSources)) {
|
|
1511
|
+
const sources = evidenceSources
|
|
1512
|
+
.filter((s) => typeof s === "string" && s.trim().length > 0)
|
|
1513
|
+
.map((resource) => ({ resource: resource.trim() }));
|
|
1514
|
+
if (sources.length > 0)
|
|
1515
|
+
provenance.sources = sources;
|
|
1516
|
+
}
|
|
1517
|
+
if (Object.keys(provenance).length > 0)
|
|
1518
|
+
fm.provenance = provenance;
|
|
1519
|
+
else
|
|
1520
|
+
delete fm.provenance;
|
|
1521
|
+
return parsed.frontmatter !== null
|
|
1522
|
+
? `---\n${serializeFrontmatter(fm)}\n---\n${parsed.content}`
|
|
1523
|
+
: assembleAsset(fm, parsed.content);
|
|
1524
|
+
}
|
|
607
1525
|
/**
|
|
608
1526
|
* Validate a proposal, then promote it through the canonical
|
|
609
1527
|
* {@link writeAssetToSource} dispatch (the single place that branches on
|
|
@@ -617,20 +1535,53 @@ export function expireStaleProposals(stashDir, config, ctx) {
|
|
|
617
1535
|
* Genuinely-new assets carry no backup.
|
|
618
1536
|
*/
|
|
619
1537
|
export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
1538
|
+
return withAssetMutationLease("proposal-accept", () => promoteProposalWithLease(stashDir, config, id, options, ctx));
|
|
1539
|
+
}
|
|
1540
|
+
function promotionLintBlockers(raw, assetPath, targetRoot, refType, config) {
|
|
1541
|
+
let data;
|
|
1542
|
+
let body;
|
|
1543
|
+
let frontmatter;
|
|
1544
|
+
if (refType === "task") {
|
|
1545
|
+
try {
|
|
1546
|
+
const parsed = parseYaml(raw);
|
|
1547
|
+
data = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
1548
|
+
}
|
|
1549
|
+
catch {
|
|
1550
|
+
data = {};
|
|
1551
|
+
}
|
|
1552
|
+
body = raw;
|
|
1553
|
+
frontmatter = null;
|
|
1554
|
+
}
|
|
1555
|
+
else {
|
|
1556
|
+
({ data, content: body, frontmatter } = parseFrontmatter(raw));
|
|
623
1557
|
}
|
|
1558
|
+
const resolvedRoot = path.resolve(targetRoot);
|
|
1559
|
+
const extraStashRoots = resolveSourceEntries(targetRoot, config)
|
|
1560
|
+
.map((source) => source.path)
|
|
1561
|
+
.filter((sourcePath) => path.resolve(sourcePath) !== resolvedRoot);
|
|
1562
|
+
return runBaseChecks({
|
|
1563
|
+
filePath: assetPath,
|
|
1564
|
+
relPath: path.relative(targetRoot, assetPath),
|
|
1565
|
+
raw,
|
|
1566
|
+
data,
|
|
1567
|
+
body,
|
|
1568
|
+
frontmatter,
|
|
1569
|
+
fix: false,
|
|
1570
|
+
stashRoot: targetRoot,
|
|
1571
|
+
extraStashRoots,
|
|
1572
|
+
}).filter((finding) => PROMOTION_LINT_BLOCKERS.has(finding.issue));
|
|
1573
|
+
}
|
|
1574
|
+
async function promoteProposalWithLease(stashDir, config, id, options, ctx) {
|
|
1575
|
+
recoverRejectTransaction(stashDir, id, ctx);
|
|
1576
|
+
let proposal = getProposal(stashDir, id, ctx);
|
|
624
1577
|
// Attempt bounded auto-repair of mechanically-fixable structural defects
|
|
625
1578
|
// (pseudo-frontmatter-in-body, stray `---` fences, truncated description)
|
|
626
1579
|
// BEFORE running validation. If the repair produces valid content, we
|
|
627
1580
|
// promote the repaired version; if validation still fails, the original
|
|
628
1581
|
// error path throws as before. The repair is content-preserving and
|
|
629
1582
|
// deterministic — it never invents text.
|
|
630
|
-
const repairedContent = repairProposalContent(proposal
|
|
631
|
-
const proposalToValidate = repairedContent !== proposal
|
|
632
|
-
? { ...proposal, payload: { ...proposal.payload, content: repairedContent } }
|
|
633
|
-
: proposal;
|
|
1583
|
+
const repairedContent = repairProposalContent(proposalContent(proposal));
|
|
1584
|
+
const proposalToValidate = repairedContent !== proposalContent(proposal) ? withProposalContent(proposal, repairedContent) : proposal;
|
|
634
1585
|
const report = validateProposal(proposalToValidate);
|
|
635
1586
|
if (!report.ok) {
|
|
636
1587
|
const message = report.findings.map((f) => `[${f.kind}] ${f.message}`).join("\n");
|
|
@@ -639,47 +1590,101 @@ export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
|
|
|
639
1590
|
// Use the (possibly repaired) payload for the promotion write. Persist the
|
|
640
1591
|
// repaired content back onto the DB row so the audit trail reflects the
|
|
641
1592
|
// final promoted payload (not the defective original).
|
|
642
|
-
if (repairedContent !== proposal
|
|
1593
|
+
if (repairedContent !== proposalContent(proposal)) {
|
|
643
1594
|
withProposalsDb(stashDir, ctx, (db) => {
|
|
644
|
-
|
|
645
|
-
upsertProposal(db, updated, stashDir);
|
|
1595
|
+
persistProposalUpdate(db, withProposalContent(proposal, repairedContent), stashDir);
|
|
646
1596
|
});
|
|
647
1597
|
}
|
|
648
|
-
const ref =
|
|
649
|
-
if (!
|
|
1598
|
+
const ref = parseRefInput(proposalToValidate.ref);
|
|
1599
|
+
if (!stashDirFor(ref.type)) {
|
|
650
1600
|
throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
|
|
651
1601
|
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
const
|
|
660
|
-
if (
|
|
661
|
-
|
|
1602
|
+
await recoverProposalTransactionsForStash(stashDir, config, ctx, id);
|
|
1603
|
+
proposal = getProposal(stashDir, id, ctx);
|
|
1604
|
+
const target = resolveProposalWriteTarget(config, proposal, options.target, options.queueTarget);
|
|
1605
|
+
if (proposal.status === "accepted") {
|
|
1606
|
+
if (!proposal.acceptedTarget) {
|
|
1607
|
+
throw new UsageError(`Accepted proposal ${id} has no recorded target.`, "INVALID_PROPOSAL");
|
|
1608
|
+
}
|
|
1609
|
+
const assetPath = resolveAssetFilePathSafe(target.source, ref);
|
|
1610
|
+
if (proposal.acceptedTarget.source !== target.source.name ||
|
|
1611
|
+
path.resolve(proposal.acceptedTarget.root) !== path.resolve(target.source.path) ||
|
|
1612
|
+
!assetPath ||
|
|
1613
|
+
path.resolve(proposal.acceptedTarget.path) !== path.resolve(assetPath)) {
|
|
1614
|
+
throw new UsageError(`proposal ${id} is bound to a different accepted target`, "INVALID_FLAG_VALUE");
|
|
1615
|
+
}
|
|
1616
|
+
if (!assetPath ||
|
|
1617
|
+
!fs.existsSync(assetPath) ||
|
|
1618
|
+
proposalFileHash(assetPath) !== proposal.acceptedTarget.contentHash) {
|
|
1619
|
+
throw new UsageError(`Accepted proposal ${id} does not match the current asset content.`, "INVALID_FLAG_VALUE");
|
|
662
1620
|
}
|
|
1621
|
+
return { proposal, assetPath, ref: proposal.ref };
|
|
663
1622
|
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
1623
|
+
if (proposal.status !== "pending") {
|
|
1624
|
+
throw new UsageError(`Proposal ${id} is not pending (current status: ${proposal.status}). Only pending proposals can be accepted.`, "INVALID_FLAG_VALUE");
|
|
1625
|
+
}
|
|
1626
|
+
const mutationTarget = prepareWriteTargetForMutation(target);
|
|
1627
|
+
const assetPath = resolveAssetFilePathSafe(mutationTarget.source, ref);
|
|
1628
|
+
if (!assetPath)
|
|
1629
|
+
throw new UsageError(`Cannot resolve proposal target ${proposal.ref}.`, "INVALID_PROPOSAL");
|
|
1630
|
+
assertWriteTargetPathsClean(mutationTarget.source, [assetPath]);
|
|
1631
|
+
let backup;
|
|
1632
|
+
if (fs.existsSync(assetPath)) {
|
|
1633
|
+
try {
|
|
1634
|
+
backup = fs.readFileSync(assetPath);
|
|
1635
|
+
}
|
|
1636
|
+
catch (error) {
|
|
1637
|
+
throw new Error(`Proposal backup read failed for ${assetPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
if (proposal.beforeHash !== undefined && (!backup || proposalHash(backup) !== proposal.beforeHash)) {
|
|
1641
|
+
throw new UsageError(`Proposal target changed after proposal ${id} was created; refusing to overwrite newer content.`, "INVALID_FLAG_VALUE");
|
|
1642
|
+
}
|
|
1643
|
+
if (proposal.beforeHash === undefined &&
|
|
1644
|
+
backup !== undefined &&
|
|
1645
|
+
proposal.changes.some((change) => change.op === "create")) {
|
|
1646
|
+
throw new UsageError(`Proposal target was created after proposal ${id}; refusing to overwrite newer content.`, "INVALID_FLAG_VALUE");
|
|
1647
|
+
}
|
|
1648
|
+
assertAkmAssetWrite(mutationTarget.source);
|
|
1649
|
+
// D2 (#730): stamp OKF v0.2 provenance onto the promoted content BEFORE
|
|
1650
|
+
// lint/write — reaching this point already proves the target is AKM-native
|
|
1651
|
+
// (assertAkmAssetWrite above rejects an OKF-adapter target first), so the
|
|
1652
|
+
// OKF write-rejection contract (runbook §10) is untouched: this code never
|
|
1653
|
+
// runs for it. Markdown-only: a task/env/script/other non-markdown target
|
|
1654
|
+
// has no frontmatter block to stamp into.
|
|
1655
|
+
//
|
|
1656
|
+
// workflow-format-unification removed the re-validation fallback that used
|
|
1657
|
+
// to live here: every AKM-native markdown type (workflow included) now
|
|
1658
|
+
// validates its frontmatter against a schema whose closed key set is
|
|
1659
|
+
// `envelope ∪ type-keys` (`schemas/akm-workflow.json` $ref's
|
|
1660
|
+
// `schemas/akm-asset-envelope.json`, which already admits `generated`/
|
|
1661
|
+
// `verified`/`provenance`/`status`/`stale_after`). A validator rejecting the
|
|
1662
|
+
// machine-stamped keys it is contractually required to admit is structurally
|
|
1663
|
+
// impossible now, so falling back to unstamped content on rejection would
|
|
1664
|
+
// only silently hide a real regression instead of promoting stamped content.
|
|
1665
|
+
const stampedContent = assetPath.toLowerCase().endsWith(".md")
|
|
1666
|
+
? stampProposalProvenance(repairedContent, proposalToValidate, options.gateDecision, ctx, nowIso(ctx))
|
|
1667
|
+
: repairedContent;
|
|
1668
|
+
const lintBlockers = promotionLintBlockers(stampedContent, assetPath, mutationTarget.source.path, ref.type, config);
|
|
1669
|
+
if (lintBlockers.length > 0) {
|
|
1670
|
+
const message = lintBlockers.map((finding) => `[${finding.issue}] ${finding.detail}`).join("\n");
|
|
1671
|
+
throw new UsageError(`Proposal ${id} failed lint:\n${message}`, "INVALID_PROPOSAL", "Fix or explicitly suppress the reported lint findings, then retry.");
|
|
1672
|
+
}
|
|
1673
|
+
const refIdentity = proposalRefIdentity(proposalToValidate.ref);
|
|
1674
|
+
const proposalForMutation = refIdentity?.bundle === undefined
|
|
1675
|
+
? { ...proposalToValidate, ref: `${target.source.name}//${refIdentity?.conceptId ?? ""}` }
|
|
1676
|
+
: proposalToValidate;
|
|
1677
|
+
const transaction = prepareProposalTransaction(stashDir, mutationTarget, proposalForMutation, ref, stampedContent, {
|
|
1678
|
+
operation: "accept",
|
|
1679
|
+
originalHash: backup ? proposalHash(backup) : null,
|
|
1680
|
+
backup,
|
|
1681
|
+
eventMetadata: options.eventMetadata,
|
|
1682
|
+
gateDecision: options.gateDecision,
|
|
1683
|
+
}, ctx);
|
|
1684
|
+
publishProposalAsset(transaction, mutationTarget);
|
|
1685
|
+
const accepted = await finalizeProposalTransaction(transaction, mutationTarget, proposalForMutation, ctx);
|
|
1686
|
+
cleanupTxn(transaction.dir);
|
|
1687
|
+
return { proposal: accepted, assetPath: transaction.journal.payload.assetPath, ref: accepted.ref };
|
|
683
1688
|
}
|
|
684
1689
|
/**
|
|
685
1690
|
* Restore the prior content of an accepted proposal from the backup captured
|
|
@@ -702,37 +1707,64 @@ export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
|
|
|
702
1707
|
* wrapper.
|
|
703
1708
|
*/
|
|
704
1709
|
export async function revertProposal(stashDir, config, id, options = {}, ctx) {
|
|
705
|
-
|
|
1710
|
+
return withAssetMutationLease("proposal-revert", () => revertProposalWithLease(stashDir, config, id, options, ctx));
|
|
1711
|
+
}
|
|
1712
|
+
async function revertProposalWithLease(stashDir, config, id, options, ctx) {
|
|
1713
|
+
let proposal = getProposal(stashDir, id, ctx);
|
|
1714
|
+
const ref = parseRefInput(proposal.ref);
|
|
1715
|
+
if (!stashDirFor(ref.type)) {
|
|
1716
|
+
throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
|
|
1717
|
+
}
|
|
1718
|
+
await recoverProposalTransactionsForStash(stashDir, config, ctx, id);
|
|
1719
|
+
proposal = getProposal(stashDir, id, ctx);
|
|
1720
|
+
if (proposal.status === "reverted") {
|
|
1721
|
+
if (!proposal.acceptedTarget) {
|
|
1722
|
+
throw new UsageError(`Reverted proposal ${id} has no recorded target.`, "INVALID_PROPOSAL");
|
|
1723
|
+
}
|
|
1724
|
+
const target = resolveRecordedProposalTarget(config, id, proposal.acceptedTarget, options.target);
|
|
1725
|
+
const requestedAssetPath = resolveAssetFilePathSafe(target.source, ref);
|
|
1726
|
+
if (!requestedAssetPath ||
|
|
1727
|
+
proposal.acceptedTarget.source !== target.source.name ||
|
|
1728
|
+
path.resolve(proposal.acceptedTarget.root) !== path.resolve(target.source.path) ||
|
|
1729
|
+
path.resolve(proposal.acceptedTarget.path) !== path.resolve(requestedAssetPath)) {
|
|
1730
|
+
throw new UsageError(`proposal ${id} is bound to a different accepted target`, "INVALID_FLAG_VALUE");
|
|
1731
|
+
}
|
|
1732
|
+
return {
|
|
1733
|
+
proposal,
|
|
1734
|
+
assetPath: requestedAssetPath,
|
|
1735
|
+
ref: proposal.ref,
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
706
1738
|
if (proposal.status !== "accepted") {
|
|
707
1739
|
throw new UsageError(`only accepted proposals can be reverted (proposal ${id} status: ${proposal.status})`, "INVALID_FLAG_VALUE");
|
|
708
1740
|
}
|
|
709
|
-
|
|
1741
|
+
const backupContent = proposal.backupContent;
|
|
1742
|
+
if (backupContent === undefined) {
|
|
710
1743
|
throw new UsageError(`no backup available for this proposal (id: ${id})`, "MISSING_REQUIRED_ARGUMENT", "Backups are only captured when a proposal overwrites an existing asset — new-asset proposals cannot be reverted via this path; delete the asset directly instead.");
|
|
711
1744
|
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
|
|
1745
|
+
if (!proposal.acceptedTarget) {
|
|
1746
|
+
throw new UsageError(`Accepted proposal ${id} has no recorded target.`, "INVALID_PROPOSAL");
|
|
715
1747
|
}
|
|
716
|
-
|
|
717
|
-
const
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
const
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
return { proposal: reverted, assetPath
|
|
1748
|
+
let target = resolveRecordedProposalTarget(config, id, proposal.acceptedTarget, options.target);
|
|
1749
|
+
const requestedAssetPath = resolveAssetFilePathSafe(target.source, ref);
|
|
1750
|
+
if (proposal.acceptedTarget.source !== target.source.name ||
|
|
1751
|
+
path.resolve(proposal.acceptedTarget.root) !== path.resolve(target.source.path) ||
|
|
1752
|
+
!requestedAssetPath ||
|
|
1753
|
+
path.resolve(proposal.acceptedTarget.path) !== path.resolve(requestedAssetPath)) {
|
|
1754
|
+
throw new UsageError(`proposal ${id} is bound to a different accepted target`, "INVALID_FLAG_VALUE");
|
|
1755
|
+
}
|
|
1756
|
+
const assetPath = requestedAssetPath;
|
|
1757
|
+
const acceptedHash = proposal.acceptedTarget.contentHash;
|
|
1758
|
+
target = prepareWriteTargetForMutation(target);
|
|
1759
|
+
if (!fs.existsSync(assetPath) || proposalFileHash(assetPath) !== acceptedHash) {
|
|
1760
|
+
throw new UsageError(`asset content changed after proposal ${id} was accepted; refusing to clobber the newer content`, "INVALID_FLAG_VALUE");
|
|
1761
|
+
}
|
|
1762
|
+
assertWriteTargetPathsClean(target.source, [assetPath]);
|
|
1763
|
+
const transaction = prepareProposalTransaction(stashDir, target, proposal, ref, backupContent, { operation: "revert", originalHash: acceptedHash }, ctx);
|
|
1764
|
+
publishProposalAsset(transaction, target);
|
|
1765
|
+
const reverted = await finalizeProposalTransaction(transaction, target, proposal, ctx);
|
|
1766
|
+
cleanupTxn(transaction.dir);
|
|
1767
|
+
return { proposal: reverted, assetPath, ref: proposal.ref };
|
|
736
1768
|
}
|
|
737
1769
|
/**
|
|
738
1770
|
* Compute a diff between a proposal payload and the existing on-disk asset.
|
|
@@ -742,21 +1774,17 @@ export async function revertProposal(stashDir, config, id, options = {}, ctx) {
|
|
|
742
1774
|
*/
|
|
743
1775
|
export function diffProposal(stashDir, config, id, options = {}, ctx) {
|
|
744
1776
|
const proposal = getProposal(stashDir, id, ctx);
|
|
745
|
-
const ref =
|
|
1777
|
+
const ref = parseRefInput(proposal.ref);
|
|
746
1778
|
let targetPath;
|
|
747
1779
|
let existing = null;
|
|
748
|
-
|
|
749
|
-
const target = resolveWriteTarget(config, options.target);
|
|
1780
|
+
const readTarget = (target) => {
|
|
750
1781
|
targetPath = resolveAssetFilePathSafe(target.source, ref);
|
|
751
1782
|
if (targetPath && fs.existsSync(targetPath)) {
|
|
752
1783
|
existing = fs.readFileSync(targetPath, "utf8");
|
|
753
1784
|
}
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
// callers can see the proposed payload without erroring out.
|
|
758
|
-
}
|
|
759
|
-
const proposed = proposal.payload.content;
|
|
1785
|
+
};
|
|
1786
|
+
readTarget(resolveProposalWriteTarget(config, proposal, options.target, options.queueTarget));
|
|
1787
|
+
const proposed = proposalContent(proposal);
|
|
760
1788
|
if (existing === null) {
|
|
761
1789
|
return {
|
|
762
1790
|
existing: null,
|
|
@@ -775,55 +1803,52 @@ export function diffProposal(stashDir, config, id, options = {}, ctx) {
|
|
|
775
1803
|
};
|
|
776
1804
|
}
|
|
777
1805
|
function resolveAssetFilePathSafe(source, ref) {
|
|
778
|
-
const typeDir =
|
|
1806
|
+
const typeDir = stashDirFor(ref.type);
|
|
779
1807
|
if (!typeDir)
|
|
780
1808
|
return undefined;
|
|
781
1809
|
const typeRoot = path.join(source.path, typeDir);
|
|
782
1810
|
try {
|
|
783
|
-
return
|
|
1811
|
+
return assetPathForName(ref.type, typeRoot, ref.name);
|
|
784
1812
|
}
|
|
785
1813
|
catch {
|
|
786
1814
|
return undefined;
|
|
787
1815
|
}
|
|
788
1816
|
}
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
lines.push(`@@ 1,${leftLines.length} 1,${rightLines.length} @@`);
|
|
808
|
-
for (let i = 0; i < max; i += 1) {
|
|
809
|
-
const l = leftLines[i];
|
|
810
|
-
const r = rightLines[i];
|
|
811
|
-
if (l === r && l !== undefined) {
|
|
812
|
-
lines.push(` ${l}`);
|
|
813
|
-
continue;
|
|
1817
|
+
// Register the proposal transaction kinds with the unified engine so ANY
|
|
1818
|
+
// recovery entry point (mv pre-flight, indexer, write-path indexer) can
|
|
1819
|
+
// finish or roll back an interrupted proposal mutation for a root it
|
|
1820
|
+
// touches. The proposal-owned entry points below keep their richer,
|
|
1821
|
+
// ctx-threaded recovery paths over the same journals.
|
|
1822
|
+
registerTxnKind(PROPOSAL_TXN_KIND, {
|
|
1823
|
+
phases: PROPOSAL_TXN_PHASES,
|
|
1824
|
+
commitPhase: "asset-published",
|
|
1825
|
+
validate: (journal, txnDir, root) => fenceProposalTxnJournal(journal, txnDir, root),
|
|
1826
|
+
rollback: (txn) => {
|
|
1827
|
+
rollbackPreparedProposalTransaction(txn);
|
|
1828
|
+
},
|
|
1829
|
+
finalize: async (txn) => {
|
|
1830
|
+
const p = txn.journal.payload;
|
|
1831
|
+
const config = loadConfig();
|
|
1832
|
+
let target = resolveProposalRecoveryTarget(config, txn.journal);
|
|
1833
|
+
if (txn.journal.phase === "asset-published") {
|
|
1834
|
+
target = prepareWriteTargetForMutation(target, { allowAhead: true });
|
|
814
1835
|
}
|
|
815
|
-
if (
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
}
|
|
1836
|
+
if (canonicalTxnRoot(target.source.path) !== canonicalTxnRoot(txn.journal.root) ||
|
|
1837
|
+
p.targetKind !== target.source.kind) {
|
|
1838
|
+
throw new Error(`Proposal transaction ${txn.journal.transactionId} is bound to a different target root.`);
|
|
1839
|
+
}
|
|
1840
|
+
const proposal = getProposal(p.stashDir, p.proposalId);
|
|
1841
|
+
await finalizeProposalTransaction(txn, target, proposal);
|
|
1842
|
+
cleanupProposalPublication(p);
|
|
1843
|
+
},
|
|
1844
|
+
});
|
|
1845
|
+
registerTxnKind(REJECT_TXN_KIND, {
|
|
1846
|
+
phases: REJECT_TXN_PHASES,
|
|
1847
|
+
// A reject is roll-forward from its very first phase (DB-only; the archive
|
|
1848
|
+
// decision is durable the moment the journal exists).
|
|
1849
|
+
commitPhase: "prepared",
|
|
1850
|
+
rollback: () => { },
|
|
1851
|
+
finalize: (txn) => {
|
|
1852
|
+
finalizeRejectTransaction(txn);
|
|
1853
|
+
},
|
|
1854
|
+
});
|