akm-cli 0.9.16 → 0.9.17-alpha.10
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 +2101 -0
- package/STABILITY.md +11 -10
- package/dist/akm +124 -193
- package/dist/akm-migrate +38 -19
- package/dist/assets/hints/cli-hints-full.md +6 -7
- package/dist/assets/improve-strategies/catchup.json +0 -3
- package/dist/assets/improve-strategies/consolidate.json +0 -1
- package/dist/assets/improve-strategies/default.json +1 -2
- package/dist/assets/improve-strategies/proactive-maintenance.json +1 -2
- package/dist/assets/improve-strategies/quick.json +1 -2
- package/dist/assets/improve-strategies/reflect-distill.json +1 -2
- package/dist/assets/improve-strategies/thorough.json +0 -3
- package/dist/assets/prompts/consolidate-pair.md +20 -0
- package/dist/assets/prompts/consolidate-system.md +4 -11
- package/dist/assets/prompts/retrieval-relevance-judge.md +6 -0
- package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +20 -20
- package/dist/assets/stash-skeleton/facts/conventions/domains.md +2 -2
- package/dist/assets/templates/html/health.html +3 -5
- package/dist/cli/retired-commands.js +1 -1
- package/dist/cli/shared.js +6 -2
- package/dist/cli/unknown-flags.js +24 -1
- package/dist/cli.js +68 -10
- package/dist/commands/agent/agent-dispatch.js +1 -1
- package/dist/commands/command/command-execution.js +24 -62
- package/dist/commands/feedback-cli.js +0 -1
- package/dist/commands/health/accept-rate.js +6 -0
- package/dist/commands/health/archive-usage.js +92 -0
- package/dist/commands/health/checks.js +83 -74
- package/dist/commands/health/config-skew.js +38 -0
- package/dist/commands/health/data-dir-usage.js +25 -13
- package/dist/commands/health/egress.js +54 -0
- package/dist/commands/health/html-report.js +1 -42
- package/dist/commands/health/improve-metrics.js +136 -591
- package/dist/commands/health/md-report.js +1 -6
- package/dist/commands/health/plugin-staleness.js +53 -3
- package/dist/commands/health/renderers.js +12 -4
- package/dist/commands/health/report-view-model.js +14 -120
- package/dist/commands/health/types-improve.js +4 -19
- package/dist/commands/health/windows.js +64 -74
- package/dist/commands/health.js +145 -143
- package/dist/commands/improve/consolidate/chunking.js +26 -117
- package/dist/commands/improve/consolidate/continuity-check.js +137 -0
- package/dist/commands/improve/consolidate/pair-pass.js +791 -0
- package/dist/commands/improve/consolidate/sanitize.js +54 -149
- package/dist/commands/improve/consolidate.js +589 -1127
- package/dist/commands/improve/content-hash.js +16 -24
- package/dist/commands/improve/distill/content-repair.js +18 -100
- package/dist/commands/improve/distill-guards.js +20 -81
- package/dist/commands/improve/distill-promotion-policy.js +23 -243
- package/dist/commands/improve/distill.js +608 -1041
- package/dist/commands/improve/eligibility.js +126 -390
- package/dist/commands/improve/execution.js +8 -10
- package/dist/commands/improve/extract-prompt.js +1 -2
- package/dist/commands/improve/extract.js +487 -1046
- package/dist/commands/improve/feedback-valence.js +0 -25
- package/dist/commands/improve/improve-cli.js +75 -169
- package/dist/commands/improve/improve-result-file.js +10 -66
- package/dist/commands/improve/improve-strategies.js +52 -4
- package/dist/commands/improve/improve-usage-report.js +18 -64
- package/dist/commands/improve/improve.js +480 -1074
- package/dist/commands/improve/ledger.js +119 -0
- package/dist/commands/improve/locks.js +2 -8
- package/dist/commands/improve/loop-stages.js +415 -1073
- package/dist/commands/improve/memory/derived-ref.js +12 -77
- package/dist/commands/improve/memory/memory-belief.js +16 -118
- package/dist/commands/improve/memory/memory-improve.js +266 -14
- package/dist/commands/improve/outcome-loop.js +28 -156
- package/dist/commands/improve/planner.js +5 -15
- package/dist/commands/improve/preparation.js +779 -2319
- package/dist/commands/improve/proactive-maintenance.js +34 -101
- package/dist/commands/improve/reflect-noise.js +104 -280
- package/dist/commands/improve/reflect.js +642 -1353
- package/dist/commands/improve/retrieval-gate.js +127 -0
- package/dist/commands/improve/retrieval-scope.js +92 -0
- package/dist/commands/improve/salience.js +41 -240
- package/dist/commands/improve/session-asset.js +19 -100
- package/dist/commands/improve/stage.js +322 -0
- package/dist/commands/lint/base-linter.js +37 -15
- package/dist/commands/proposal/drain.js +261 -578
- package/dist/commands/proposal/proposal-cli.js +19 -20
- package/dist/commands/proposal/proposal-types.js +31 -24
- package/dist/commands/proposal/proposal.js +38 -8
- package/dist/commands/proposal/propose.js +134 -160
- package/dist/commands/proposal/repository.js +1097 -1394
- package/dist/commands/proposal/validators/proposal-quality-validators.js +71 -174
- package/dist/commands/proposal/validators/proposal-validators.js +1 -1
- package/dist/commands/proposal/validators/proposals.js +22 -89
- package/dist/commands/read/curate.js +105 -462
- package/dist/commands/read/knowledge.js +3 -2
- package/dist/commands/read/search-cli.js +16 -33
- package/dist/commands/read/search.js +17 -23
- package/dist/commands/read/show.js +57 -108
- package/dist/commands/sources/bundle-cli.js +25 -2
- package/dist/commands/sources/bundle-config-ops.js +4 -0
- package/dist/commands/sources/dangerous-env-audit.js +1 -2
- package/dist/commands/sources/info.js +127 -29
- package/dist/commands/sources/installed-stashes.js +197 -746
- package/dist/commands/sources/schema-repair.js +98 -129
- package/dist/commands/sources/source-add.js +62 -12
- package/dist/commands/sources/source-manage.js +9 -2
- package/dist/commands/sources/stash-cli.js +24 -4
- package/dist/commands/tasks/explain.js +10 -13
- package/dist/commands/tasks/tasks-cli.js +12 -13
- package/dist/commands/tasks/tasks.js +350 -936
- package/dist/commands/tasks/validate.js +26 -24
- package/dist/commands/workflow/plan.js +22 -29
- package/dist/commands/workflow-cli.js +4 -4
- package/dist/core/adapter/adapters/akm-adapter.js +2 -1
- package/dist/core/adapter/adapters/akm-lint.js +2 -3
- package/dist/core/adapter/adapters/akm-metadata.js +42 -12
- package/dist/core/adapter/adapters/akm-task-adapter.js +29 -8
- package/dist/core/adapter/adapters/akm-workflow-adapter.js +1 -1
- package/dist/core/adapter/execution-source.js +17 -29
- package/dist/core/asset/asset-placement.js +4 -13
- package/dist/core/asset/frontmatter.js +106 -1
- package/dist/core/asset/resolve-ref.js +1 -1
- package/dist/core/bundle-id.js +42 -5
- package/dist/core/bundle-rename.js +285 -0
- package/dist/core/config/config-io.js +1 -2
- package/dist/core/config/config-schema.js +9 -34
- package/dist/core/config/config-walker.js +1 -1
- package/dist/core/config/config.js +184 -111
- package/dist/core/config/engine-semantics.js +0 -2
- package/dist/core/config/legacy-source-shape-shim.js +38 -9
- package/dist/core/config/schema/embedding.js +20 -5
- package/dist/core/config/schema/engines.js +5 -0
- package/dist/core/config/schema/execution.js +1 -1
- package/dist/core/config/schema/experimental.js +1 -1
- package/dist/core/config/schema/improve-processes.js +54 -125
- package/dist/core/config/schema/improve.js +4 -42
- package/dist/core/config/schema/index-config.js +9 -48
- package/dist/core/config/schema/scheduler.js +12 -12
- package/dist/core/config/schema/search.js +6 -22
- package/dist/core/env-secret-ref.js +0 -1
- package/dist/core/errors.js +8 -9
- package/dist/core/file-change.js +13 -5
- package/dist/core/file-lock.js +76 -173
- package/dist/core/improve-result.js +35 -7
- package/dist/core/improve-types.js +0 -1
- package/dist/core/logs-db.js +2 -2
- package/dist/core/loopback.js +7 -12
- package/dist/core/non-task-input.js +20 -0
- package/dist/core/parse.js +13 -16
- package/dist/core/paths.js +0 -24
- package/dist/core/redaction.js +109 -2
- package/dist/core/run-lock.js +2 -5
- package/dist/core/spawn-env.js +1 -1
- package/dist/core/state/migrations.js +123 -61
- package/dist/core/state-db-scope.js +2 -4
- package/dist/core/state-db.js +126 -692
- package/dist/core/time.js +0 -20
- package/dist/core/type-presentation.js +1 -9
- package/dist/core/write-source.js +294 -1005
- package/dist/execution/input-contract.js +1 -1
- package/dist/execution/resolved-request.js +135 -689
- package/dist/execution/source.js +63 -257
- package/dist/execution/target-ref.js +1 -1
- package/dist/indexer/bundle-identity-guard.js +2 -2
- package/dist/indexer/db/llm-cache.js +2 -2
- package/dist/indexer/ensure-index.js +77 -73
- package/dist/indexer/index-rebuild-lock.js +3 -11
- package/dist/indexer/index-writer-lock.js +8 -17
- package/dist/indexer/index-written-assets.js +141 -154
- package/dist/indexer/indexer.js +400 -1124
- package/dist/indexer/links/declared-links.js +90 -0
- package/dist/indexer/materialize-embeddings.js +60 -397
- package/dist/indexer/passes/memory-inference.js +96 -90
- package/dist/indexer/passes/metadata.js +132 -219
- package/dist/indexer/read-preflight.js +0 -7
- package/dist/indexer/scan/doc-to-entry.js +2 -3
- package/dist/indexer/scan/drain-dir.js +1 -1
- package/dist/indexer/search/db-search.js +190 -590
- package/dist/indexer/search/fts-query.js +30 -41
- package/dist/indexer/search/ranking.js +28 -154
- package/dist/indexer/search/search-attribution.js +12 -32
- package/dist/indexer/search/search-fields.js +11 -15
- package/dist/indexer/search/search-hit-enrichers.js +54 -85
- package/dist/indexer/search/search-source.js +1 -4
- package/dist/indexer/usage/usage-events.js +36 -7
- package/dist/indexer/walk/walker.js +3 -4
- package/dist/integrations/agent/engine-fallback.js +23 -40
- package/dist/integrations/agent/engine-resolution.js +93 -183
- package/dist/integrations/agent/execution.js +507 -0
- package/dist/integrations/agent/model-map.js +28 -156
- package/dist/integrations/agent/request-lowering.js +66 -141
- package/dist/integrations/agent/runner-dispatch.js +143 -321
- package/dist/integrations/agent/runner.js +54 -14
- package/dist/integrations/lockfile.js +53 -101
- package/dist/llm/client.js +18 -6
- package/dist/llm/embedders/deterministic.js +2 -3
- package/dist/llm/embedders/profile.js +71 -0
- package/dist/llm/embedders/remote.js +11 -17
- package/dist/llm/feature-gate.js +0 -8
- package/dist/llm/index-passes.js +3 -5
- package/dist/llm/memory-infer.js +1 -2
- package/dist/llm/structured-call.js +5 -24
- package/dist/output/generic-render.js +23 -11
- package/dist/output/html-render.js +13 -10
- package/dist/output/render-registry.js +3 -32
- package/dist/output/shapes/helpers.js +25 -38
- package/dist/output/shapes/passthrough.js +1 -9
- package/dist/{indexer/graph/graph-types.js → output/text/bundle-rename.js} +4 -1
- package/dist/output/text/command-format.js +69 -31
- package/dist/output/text/helpers.js +1 -1
- package/dist/output/text/migrate.js +5 -14
- package/dist/output/text/proposal-format.js +48 -3
- package/dist/output/text/show-format.js +13 -17
- package/dist/output/text/workflow-format.js +0 -32
- package/dist/output/text.js +2 -0
- package/dist/registry/factory.js +4 -19
- package/dist/registry/network.js +66 -220
- package/dist/registry/providers/index.js +0 -2
- package/dist/registry/providers/skills-sh.js +3 -14
- package/dist/registry/providers/static-index.js +24 -26
- package/dist/registry/resolve.js +55 -131
- package/dist/scripts/akm-migrate-node.js +42948 -92369
- package/dist/scripts/akm-migrate.js +42935 -92354
- package/dist/setup/registry-stash-loader.js +4 -13
- package/dist/setup/semantic-assets.js +3 -44
- package/dist/setup/setup.js +1 -1
- package/dist/setup/steps/connection.js +5 -6
- package/dist/setup/steps/platforms.js +2 -2
- package/dist/setup/steps/tasks.js +25 -15
- package/dist/sources/provider-factory.js +17 -18
- package/dist/sources/providers/filesystem.js +2 -3
- package/dist/sources/providers/git-install.js +7 -1
- package/dist/sources/providers/git-provider.js +0 -3
- package/dist/sources/providers/git-stash.js +83 -21
- package/dist/sources/providers/npm.js +2 -4
- package/dist/sources/providers/provider-utils.js +5 -10
- package/dist/sources/providers/website.js +0 -2
- package/dist/sources/snapshot-fetchers/website-ingest.js +1 -1
- package/dist/sources/website-url.js +2 -2
- package/dist/storage/database.js +9 -35
- package/dist/storage/repositories/improve-ledger-repository.js +209 -0
- package/dist/storage/repositories/index-connection.js +39 -72
- package/dist/storage/repositories/index-entries-repository.js +131 -129
- package/dist/storage/repositories/index-entry-mapper.js +1 -2
- package/dist/storage/repositories/index-entry-schema.js +101 -268
- package/dist/storage/repositories/index-fts-repository.js +86 -256
- package/dist/storage/repositories/index-links-repository.js +143 -0
- package/dist/storage/repositories/index-llm-cache-repository.js +7 -9
- package/dist/storage/repositories/index-meta-repository.js +6 -4
- package/dist/storage/repositories/index-schema.js +257 -325
- package/dist/storage/repositories/index-utility-repository.js +8 -29
- package/dist/storage/repositories/index-vec-repository.js +133 -414
- package/dist/storage/repositories/outcome-repository.js +2 -1
- package/dist/storage/repositories/proposals-repository.js +104 -1
- package/dist/storage/repositories/registry-index-cache-repository.js +100 -0
- package/dist/storage/repositories/salience-repository.js +1 -19
- package/dist/storage/repositories/task-history-repository.js +26 -4
- package/dist/storage/repositories/workflow-runs-repository.js +53 -244
- package/dist/storage/sqlite-migrations.js +136 -0
- package/dist/storage/sqlite-pragmas.js +11 -9
- package/dist/storage/sqlite-transaction.js +170 -0
- package/dist/storage/state-db-integrity.js +130 -0
- package/dist/tasks/activation-config.js +134 -62
- package/dist/tasks/backends/cron.js +191 -302
- package/dist/tasks/backends/exec-utils.js +2 -5
- package/dist/tasks/backends/launchd.js +141 -748
- package/dist/tasks/backends/schtasks.js +119 -623
- package/dist/tasks/prepare/prepare-support.js +5 -15
- package/dist/tasks/prepare/prepare.js +0 -2
- package/dist/tasks/resolve-akm-bin.js +20 -79
- package/dist/tasks/run/attempt-lifecycle.js +0 -1
- package/dist/tasks/run/load-task.js +1 -1
- package/dist/tasks/scheduler-binding.js +20 -238
- package/dist/tasks/scheduler-invocation.js +136 -244
- package/dist/tasks/scheduler-lock.js +53 -0
- package/dist/tasks/scheduler-sync.js +368 -679
- package/dist/tasks/source/parse-task-source.js +55 -9
- package/dist/tasks/source/task-source-v3-frozen.js +3 -4
- package/dist/tasks/source/task-to-v4.js +464 -88
- package/dist/workflows/authoring/authoring.js +3 -12
- package/dist/workflows/compile.js +211 -0
- package/dist/workflows/concurrency-policy.js +13 -74
- package/dist/workflows/exec/child-invocation.js +3 -17
- package/dist/workflows/exec/child-workflow.js +32 -141
- package/dist/workflows/exec/dispatch-redaction.js +13 -53
- package/dist/workflows/exec/environment.js +98 -0
- package/dist/workflows/exec/exec-unit.js +33 -140
- package/dist/workflows/exec/frozen-judge.js +7 -59
- package/dist/workflows/exec/native-executor.js +82 -341
- package/dist/workflows/exec/param-secrets.js +29 -47
- package/dist/workflows/exec/run-workflow.js +154 -387
- package/dist/workflows/exec/scheduler.js +9 -36
- package/dist/workflows/exec/step-work.js +127 -430
- package/dist/workflows/exec/unit-dispatch.js +11 -63
- package/dist/workflows/exec/unit-writer.js +8 -52
- package/dist/workflows/exec/worktree.js +39 -273
- package/dist/workflows/freeze/child-output-references.js +4 -15
- package/dist/workflows/freeze/environment.js +99 -92
- package/dist/workflows/freeze/freeze.js +172 -0
- package/dist/workflows/freeze/step-values.js +19 -21
- package/dist/workflows/freeze/targets/child-workflow.js +23 -92
- package/dist/workflows/freeze/targets/command.js +10 -33
- package/dist/workflows/freeze/targets/script.js +5 -12
- package/dist/workflows/freeze/targets/shell.js +3 -6
- package/dist/workflows/freeze/targets/task.js +25 -80
- package/dist/workflows/freeze/task-bindings.js +20 -67
- package/dist/workflows/{source-ir/github-yaml.js → github-yaml.js} +88 -206
- package/dist/workflows/ir/params.js +6 -51
- package/dist/workflows/ir/plan-hash.js +2 -34
- package/dist/workflows/parser.js +140 -43
- package/dist/{commands/improve/consolidate/types.js → workflows/plan.js} +2 -1
- package/dist/workflows/renderer.js +36 -69
- package/dist/workflows/resource-limits.js +12 -120
- package/dist/workflows/runtime/agent-identity.js +8 -40
- package/dist/workflows/runtime/run-outputs.js +3 -6
- package/dist/workflows/runtime/run-plan.js +316 -0
- package/dist/workflows/runtime/runs.js +48 -200
- package/dist/workflows/runtime/workflow-asset-loader.js +24 -57
- package/dist/workflows/{source-ir/semantics.js → source-semantics.js} +16 -20
- package/dist/workflows/validate-summary.js +2 -7
- package/docs/integration/bundling-akm.md +49 -42
- package/docs/migration/README.md +1 -0
- package/docs/migration/release-notes/0.9.17.md +43 -0
- package/docs/migration/v0.9.1-to-v0.9.2.md +23 -7
- package/docs/reference/cli.md +232 -135
- package/docs/reference/configuration.md +71 -57
- package/docs/reference/data-and-telemetry.md +20 -21
- package/docs/reference/tasks.md +105 -39
- package/docs/reference/workflow-schema.md +14 -18
- package/docs/reference/workflows.md +6 -9
- package/package.json +1 -1
- package/schemas/akm-config.json +115 -738
- package/schemas/akm-workflow.json +1 -0
- package/dist/assets/improve-strategies/graph-refresh.json +0 -15
- package/dist/assets/prompts/contradiction-judge.md +0 -33
- package/dist/assets/prompts/graph-extract-system.md +0 -1
- package/dist/assets/prompts/graph-extract-user-prompt.md +0 -35
- package/dist/assets/prompts/metadata-enhance-system.md +0 -1
- package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +0 -4
- package/dist/commands/health/advisories.js +0 -150
- package/dist/commands/health/metrics.js +0 -329
- package/dist/commands/health/surfaces.js +0 -102
- package/dist/commands/improve/anti-collapse.js +0 -83
- package/dist/commands/improve/collapse-detector.js +0 -432
- package/dist/commands/improve/consolidate/eligibility.js +0 -48
- package/dist/commands/improve/consolidate/merge.js +0 -149
- package/dist/commands/improve/distill/promote-memory.js +0 -291
- package/dist/commands/improve/distill/quality-gate.js +0 -337
- package/dist/commands/improve/eval-cases.js +0 -52
- package/dist/commands/improve/memory/memory-contradiction-detect.js +0 -291
- package/dist/commands/improve/proposal-envelope.js +0 -31
- package/dist/commands/improve/run-context.js +0 -123
- package/dist/commands/improve/shared.js +0 -31
- package/dist/commands/improve/source-identity.js +0 -28
- package/dist/commands/improve/triage.js +0 -96
- package/dist/commands/proposal/drain-policies.js +0 -151
- package/dist/commands/sources/update-transaction.js +0 -220
- package/dist/core/action-contributors.js +0 -28
- package/dist/core/config/config-version-shim.js +0 -101
- package/dist/core/fs-txn.js +0 -405
- package/dist/core/lexical-score.js +0 -25
- package/dist/core/maintenance-barrier.js +0 -167
- package/dist/execution/executable-identity.js +0 -105
- package/dist/execution/guarded-source.js +0 -427
- package/dist/indexer/db/graph-db.js +0 -444
- package/dist/indexer/graph/graph-boost.js +0 -427
- package/dist/indexer/graph/graph-dedup.js +0 -95
- package/dist/indexer/graph/graph-extraction.js +0 -1108
- package/dist/indexer/search/name-match.js +0 -35
- package/dist/indexer/search/ranking-contributors.js +0 -515
- package/dist/indexer/search/ranking-types.js +0 -4
- package/dist/indexer/walk/project-context.js +0 -192
- package/dist/integrations/agent/execution-cascade.js +0 -566
- package/dist/integrations/agent/execution-definitions.js +0 -202
- package/dist/integrations/agent/execution-lowering.js +0 -841
- package/dist/integrations/agent/execution-preparation.js +0 -98
- package/dist/integrations/agent/inline-execution.js +0 -74
- package/dist/llm/graph-extract.js +0 -728
- package/dist/llm/metadata-enhance.js +0 -96
- package/dist/registry/create-provider-registry.js +0 -29
- package/dist/registry/pinned-request-helper.js +0 -247
- package/dist/registry/pinned-transport.js +0 -717
- package/dist/sources/providers/index.js +0 -14
- package/dist/storage/engines/sqlite-migrations.js +0 -271
- package/dist/storage/repositories/canaries-repository.js +0 -107
- package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
- package/dist/storage/repositories/registry-cache.js +0 -113
- package/dist/tasks/scheduler-sync-preview.js +0 -52
- package/dist/tasks/source/task-to-v3.js +0 -507
- package/dist/workflows/freeze/resolve-steps.js +0 -86
- package/dist/workflows/freeze/source-freeze.js +0 -64
- package/dist/workflows/ir/compile.js +0 -321
- package/dist/workflows/ir/environment-v4.js +0 -330
- package/dist/workflows/ir/freeze-v4.js +0 -153
- package/dist/workflows/ir/schema-v4.js +0 -745
- package/dist/workflows/ir/schema.js +0 -354
- package/dist/workflows/program/schema.js +0 -77
- package/dist/workflows/runtime/checkin.js +0 -57
- package/dist/workflows/runtime/plan-classifier.js +0 -196
- package/dist/workflows/runtime/unit-checkin.js +0 -45
- package/dist/workflows/runtime/unit-phases.js +0 -20
- package/dist/workflows/schema.js +0 -4
- package/dist/workflows/source-ir/compile.js +0 -200
- package/dist/workflows/source-ir/program.js +0 -50
- package/dist/workflows/source-ir/result.js +0 -26
- package/dist/workflows/source-ir/schema.js +0 -786
- package/dist/workflows/source-ir/triggers.js +0 -79
- package/dist/workflows/source-ir/uses.js +0 -40
- package/dist/workflows/validator.js +0 -60
|
@@ -2,190 +2,91 @@
|
|
|
2
2
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* (
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* `akm proposal accept` validates and promotes them via
|
|
12
|
-
* {@link writeAssetToSource}.
|
|
13
|
-
*
|
|
14
|
-
* # Storage
|
|
15
|
-
*
|
|
16
|
-
* The canonical store is the `proposals` table in `state.db` (SQLite, WAL
|
|
17
|
-
* mode — see `src/core/state-db.ts`). Rows are partitioned by `stash_dir` so
|
|
18
|
-
* multi-stash installs keep independent queues, and the `status` column
|
|
19
|
-
* distinguishes the live queue (`pending`) from the archive (`accepted` /
|
|
20
|
-
* `rejected` / `reverted`). There is no separate archive location — archival
|
|
21
|
-
* is a status flip, and the full audit trail (review outcome, reason, backup
|
|
22
|
-
* content for revert) lives on the row.
|
|
23
|
-
*
|
|
24
|
-
* # Why the queue bypasses `writeAssetToSource`
|
|
25
|
-
*
|
|
26
|
-
* The architectural rule "all writes go through `writeAssetToSource`" applies
|
|
27
|
-
* to *assets*. Proposals are **not** assets — they live outside the asset
|
|
28
|
-
* tree (in state.db, parallel to how events do). Routing them through
|
|
29
|
-
* `writeAssetToSource` would force them into a placement stash-subdir slot, would commit
|
|
30
|
-
* them to git, and would leak unaccepted drafts through the normal indexer.
|
|
31
|
-
* The {@link promoteProposal} step is the bridge: it routes the accepted
|
|
32
|
-
* payload through `writeAssetToSource` so the actual asset write still
|
|
33
|
-
* funnels through the single dispatch point in `src/core/write-source.ts`.
|
|
5
|
+
* The proposal queue (#225; storage in state.db since #578). Proposals are
|
|
6
|
+
* queue state, not assets: rows in the `proposals` table partitioned by
|
|
7
|
+
* `stash_dir`, where archival is a status flip that keeps the full audit trail
|
|
8
|
+
* (review, reason, backup for revert). They never go through the asset writer
|
|
9
|
+
* until accepted — {@link promoteProposal} is the bridge that writes the
|
|
10
|
+
* accepted payload into the bundle.
|
|
34
11
|
*/
|
|
35
|
-
import {
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
36
13
|
import fs from "node:fs";
|
|
37
14
|
import os from "node:os";
|
|
38
15
|
import path from "node:path";
|
|
39
16
|
import { parse as parseYaml } from "yaml";
|
|
40
|
-
import { adapterForId } from "../../core/adapter/registry.js";
|
|
41
|
-
import { createValidateContext } from "../../core/adapter/validate-context.js";
|
|
42
17
|
import { ensureAkmMarkdownType } from "../../core/asset/akm-markdown.js";
|
|
43
18
|
import { assetPathForName, placementTypes, stashDirFor } from "../../core/asset/asset-placement.js";
|
|
44
19
|
import { isBundleSlug, parseBundleRef } from "../../core/asset/asset-ref.js";
|
|
45
20
|
import { assembleAsset, serializeFrontmatter } from "../../core/asset/asset-serialize.js";
|
|
46
|
-
import { parseFrontmatter } from "../../core/asset/frontmatter.js";
|
|
21
|
+
import { carryForwardBookkeepingFrontmatter, parseFrontmatter } from "../../core/asset/frontmatter.js";
|
|
47
22
|
import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-ref.js";
|
|
48
|
-
import { isWithin } from "../../core/common.js";
|
|
49
23
|
import { loadConfig } from "../../core/config/config.js";
|
|
50
24
|
import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
|
|
51
25
|
import { appendEvent } from "../../core/events.js";
|
|
52
26
|
import { proposalContent } from "../../core/file-change.js";
|
|
53
|
-
import { _setTxnMutationHookForTests, advanceTxn, beginTxn, canonicalTxnRoot, cleanupTxn, fsyncTxnDir, fsyncTxnFile, listTxnJournals, mintTxnId, registerTxnKind, sweepJournallessTxnDir, txnDirFor, txnMutationHook, txnNamespaceDir, } from "../../core/fs-txn.js";
|
|
54
27
|
import { canonicalBundleIdForTarget, resolveBundleWriteTarget } from "../../core/mutation-target.js";
|
|
55
28
|
import { getStateDbPath, withImmediateTransaction, withStateDb } from "../../core/state-db.js";
|
|
56
29
|
import { warn } from "../../core/warn.js";
|
|
57
30
|
import { recordWrittenPath } from "../../core/write-provenance.js";
|
|
58
|
-
import { assertAkmAssetWrite,
|
|
31
|
+
import { assertAkmAssetWrite, commitWriteTargetBoundary, prepareWriteTargetForMutation, resolveWriteTarget, } from "../../core/write-source.js";
|
|
59
32
|
import { withAssetMutationLease } from "../../indexer/index-writer-lock.js";
|
|
60
33
|
import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
|
|
61
34
|
import { deriveInstallations } from "../../indexer/installations.js";
|
|
62
35
|
import { resolveSourceEntries } from "../../indexer/search/search-source.js";
|
|
63
36
|
import { insertEventOnce } from "../../storage/repositories/events-repository.js";
|
|
37
|
+
import { recordImproveLedger, recordImproveLedgerDecision, } from "../../storage/repositories/improve-ledger-repository.js";
|
|
64
38
|
import { getStateProposal, listStateProposalIdsByPrefix, listStateProposals, upsertProposal, } from "../../storage/repositories/proposals-repository.js";
|
|
65
39
|
import { openSqliteReadSnapshot } from "../../storage/sqlite-read-snapshot.js";
|
|
66
40
|
import { pkgVersion } from "../../version.js";
|
|
41
|
+
import { contentHash } from "../improve/content-hash.js";
|
|
42
|
+
import { writeSupersededEdge } from "../improve/memory/memory-belief.js";
|
|
43
|
+
import { archiveCleanupCandidate, derivedTwinPath } from "../improve/memory/memory-improve.js";
|
|
67
44
|
import { runBaseChecks } from "../lint/base-linter.js";
|
|
68
45
|
import { formatNewAssetDiff, formatUnifiedDiff } from "./diff-format.js";
|
|
69
|
-
import { isAutomatedProposalSource, isValidProposalSource, PROPOSAL_SOURCES, } from "./proposal-types.js";
|
|
46
|
+
import { ASSET_MISSING_GATE_REASON, EXPIRED_GATE_REASON, isAutomatedProposalSource, isRetireProposal, isValidProposalSource, PROPOSAL_SOURCES, STALE_TARGET_GATE_REASON, } from "./proposal-types.js";
|
|
70
47
|
import { canonicalOnlyProposalValidators, hasCanonicalProposalValidator, runProposalValidators, } from "./validators/proposal-validators.js";
|
|
71
48
|
import { repairProposalContent, validateProposal } from "./validators/proposals.js";
|
|
72
|
-
const PROMOTION_LINT_ISSUE_TYPES = new Set(["unquoted-colon", "missing-ref", "stale-path"]);
|
|
73
|
-
// ── Proposal domain types (moved to ./proposal-types.ts, WI-9.8 KILL 1) ─────
|
|
74
|
-
//
|
|
75
|
-
// Proposal / ProposalStatus / ProposalPayload / ProposalReview /
|
|
76
|
-
// ProposalGateDecision(Outcome) / ProposalSource / PROPOSAL_SOURCES /
|
|
77
|
-
// AUTOMATED_PROPOSAL_SOURCES / isValidProposalSource / isAutomatedProposalSource
|
|
78
|
-
// moved to the dependency-free leaf so validators/proposals.ts,
|
|
79
|
-
// validators/proposal-validators.ts, storage/repositories/proposals-repository.ts,
|
|
80
|
-
// and storage repositories can import the `Proposal` type without importing
|
|
81
|
-
// this heavier transaction-engine module back. That back-edge was the
|
|
82
|
-
// repository↔validators import cycle (plan §10.7 D.3). Every symbol this
|
|
83
|
-
// module used to export directly is re-exported here verbatim so existing
|
|
84
|
-
// import sites (`from "../proposal/repository.js"`) are unchanged.
|
|
85
49
|
export { AUTOMATED_PROPOSAL_SOURCES, isAutomatedProposalSource, isValidProposalSource, PROPOSAL_SOURCES, } from "./proposal-types.js";
|
|
86
|
-
// The envelope's primary-content accessor lives in the dependency-free
|
|
87
|
-
// core/file-change module; re-exported here so proposal consumers get it
|
|
88
|
-
// alongside the repository API.
|
|
89
50
|
export { proposalContent };
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
* WI-6.2 invariant (`changes[0].after === payload.content`) intact.
|
|
94
|
-
*/
|
|
51
|
+
const PROMOTION_LINT_ISSUE_TYPES = new Set(["unquoted-colon", "missing-ref", "stale-path"]);
|
|
52
|
+
const MS_PER_DAY = 86_400_000;
|
|
53
|
+
/** `p` with `content` as both the payload and the primary change's `after` (they must agree). */
|
|
95
54
|
function withProposalContent(p, content) {
|
|
96
55
|
return {
|
|
97
56
|
...p,
|
|
98
57
|
payload: { ...p.payload, content },
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
i === 0 && c.op !== "delete" ? { ...c, after: content } : c),
|
|
58
|
+
// A delete-op primary change carries no `after`.
|
|
59
|
+
changes: p.changes.map((c, i) => (i === 0 && c.op !== "delete" ? { ...c, after: content } : c)),
|
|
102
60
|
};
|
|
103
61
|
}
|
|
104
|
-
/** Type guard: true when createProposal returned a skipped record. */
|
|
105
|
-
export function isProposalSkipped(result) {
|
|
106
|
-
return result.skipped === true;
|
|
107
|
-
}
|
|
108
|
-
// ── Fingerprint / rejection-backoff constants ────────────────────────────────
|
|
109
|
-
const MS_PER_DAY = 86_400_000;
|
|
110
|
-
/**
|
|
111
|
-
* Post-rejection backoff windows by source (the RETAINED cooldown semantics,
|
|
112
|
-
* plan §4.5). After a proposal is rejected, `createProposal` silently skips
|
|
113
|
-
* new proposals for the same `ref+source` until the window expires (unless
|
|
114
|
-
* `force: true` is passed).
|
|
115
|
-
*
|
|
116
|
-
* Rationale (Settles 2009 active-learning survey; Argilla/Label Studio HITL):
|
|
117
|
-
* Reviewer fatigue is a blocker for the human-in-the-loop guarantee. Backoff
|
|
118
|
-
* prevents nightly improve runs from re-flooding the queue with near-identical
|
|
119
|
-
* proposals the reviewer just declined.
|
|
120
|
-
*
|
|
121
|
-
* - reflect: 14 days (agent-based; slower feedback loops)
|
|
122
|
-
* - distill: 30 days (LLM-based; even more prone to regeneration loops)
|
|
123
|
-
* - default: 7 days (conservative fallback for other sources)
|
|
124
|
-
*/
|
|
125
|
-
const COOLDOWN_MS = {
|
|
126
|
-
reflect: 14 * MS_PER_DAY,
|
|
127
|
-
distill: 30 * MS_PER_DAY,
|
|
128
|
-
};
|
|
129
|
-
const DEFAULT_COOLDOWN_MS = 7 * MS_PER_DAY;
|
|
130
|
-
function cooldownMsForSource(source) {
|
|
131
|
-
return COOLDOWN_MS[source] ?? DEFAULT_COOLDOWN_MS;
|
|
132
|
-
}
|
|
133
|
-
/** Compute a stable SHA-256 hex digest of a proposal's content string. */
|
|
134
|
-
function contentHash(content) {
|
|
135
|
-
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
136
|
-
}
|
|
137
|
-
// ── Store access ─────────────────────────────────────────────────────────────
|
|
138
62
|
function nowIso(ctx) {
|
|
139
|
-
|
|
140
|
-
return new Date(fn()).toISOString();
|
|
141
|
-
}
|
|
142
|
-
function newId(ctx) {
|
|
143
|
-
const fn = ctx?.randomUUID ?? randomUUID;
|
|
144
|
-
return fn();
|
|
63
|
+
return new Date((ctx?.now ?? Date.now)()).toISOString();
|
|
145
64
|
}
|
|
146
|
-
|
|
147
|
-
* Open the state database (honouring the `ctx.dbPath` test seam), hand the
|
|
148
|
-
* connection to `fn`, and close it in a `finally`. Every public function in
|
|
149
|
-
* this module funnels its store access through here.
|
|
150
|
-
*
|
|
151
|
-
* `stashDir` is threaded through the public API for the store's per-stash
|
|
152
|
-
* partition.
|
|
153
|
-
*/
|
|
154
|
-
function withProposalsDb(_stashDir, ctx, fn) {
|
|
65
|
+
function withProposalsDb(ctx, fn) {
|
|
155
66
|
return withStateDb(fn, { path: ctx?.dbPath });
|
|
156
67
|
}
|
|
68
|
+
function isRetiredProposalConceptId(conceptId) {
|
|
69
|
+
const colon = conceptId.indexOf(":");
|
|
70
|
+
return colon > 0 && stashDirFor(conceptId.slice(0, colon)) !== undefined;
|
|
71
|
+
}
|
|
157
72
|
function proposalRefIdentity(ref) {
|
|
158
73
|
try {
|
|
159
74
|
const parsed = parseBundleRef(ref);
|
|
160
75
|
if (parsed.fragment !== undefined || isRetiredProposalConceptId(parsed.conceptId))
|
|
161
76
|
return undefined;
|
|
162
|
-
return {
|
|
163
|
-
conceptId: parsed.conceptId,
|
|
164
|
-
...(parsed.bundle !== undefined ? { bundle: parsed.bundle } : {}),
|
|
165
|
-
};
|
|
77
|
+
return { conceptId: parsed.conceptId, ...(parsed.bundle !== undefined ? { bundle: parsed.bundle } : {}) };
|
|
166
78
|
}
|
|
167
79
|
catch {
|
|
168
80
|
return undefined;
|
|
169
81
|
}
|
|
170
82
|
}
|
|
83
|
+
/** A `--ref` filter: a short ref matches its concept in the queue, a qualified one also its bundle. */
|
|
171
84
|
function filterRefIdentity(ref) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (p.fragment !== undefined || isRetiredProposalConceptId(p.conceptId)) {
|
|
175
|
-
throw new Error("not a current proposal identity");
|
|
176
|
-
}
|
|
177
|
-
return {
|
|
178
|
-
conceptId: p.conceptId,
|
|
179
|
-
...(p.bundle !== undefined ? { bundle: p.bundle } : {}),
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
catch {
|
|
85
|
+
const identity = proposalRefIdentity(ref);
|
|
86
|
+
if (!identity) {
|
|
183
87
|
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");
|
|
184
88
|
}
|
|
185
|
-
|
|
186
|
-
function isRetiredProposalConceptId(conceptId) {
|
|
187
|
-
const colon = conceptId.indexOf(":");
|
|
188
|
-
return colon > 0 && stashDirFor(conceptId.slice(0, colon)) !== undefined;
|
|
89
|
+
return identity;
|
|
189
90
|
}
|
|
190
91
|
function proposalMatchesRef(proposalRef, filter) {
|
|
191
92
|
const proposal = proposalRefIdentity(proposalRef);
|
|
@@ -193,17 +94,19 @@ function proposalMatchesRef(proposalRef, filter) {
|
|
|
193
94
|
proposal.conceptId === filter.conceptId &&
|
|
194
95
|
(filter.bundle === undefined || proposal.bundle === filter.bundle));
|
|
195
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* The durable `proposals.ref`: `bundle//conceptId`, the conceptId built from
|
|
99
|
+
* the type table (so a not-yet-existing asset keys onto its final spelling) and
|
|
100
|
+
* the bundle the queue writes to — byte-identical to the item_ref the indexer
|
|
101
|
+
* mints for the accepted asset.
|
|
102
|
+
*/
|
|
196
103
|
function proposalDurableRef(parsedRef, target) {
|
|
197
104
|
const conceptId = conceptIdFromTypeName(parsedRef.type, parsedRef.name);
|
|
198
|
-
const { origin } = parsedRef;
|
|
199
105
|
if (!isBundleSlug(target.source)) {
|
|
200
106
|
throw new UsageError(`Proposal target source "${target.source}" is not a valid bundle name.`, "INVALID_FLAG_VALUE");
|
|
201
107
|
}
|
|
202
|
-
if (origin !== undefined) {
|
|
203
|
-
|
|
204
|
-
throw new UsageError(`Proposal ref bundle "${origin}" conflicts with target source "${target.source}".`, "INVALID_FLAG_VALUE");
|
|
205
|
-
}
|
|
206
|
-
return `${origin}//${conceptId}`;
|
|
108
|
+
if (parsedRef.origin !== undefined && target.source !== parsedRef.origin) {
|
|
109
|
+
throw new UsageError(`Proposal ref bundle "${parsedRef.origin}" conflicts with target source "${target.source}".`, "INVALID_FLAG_VALUE");
|
|
207
110
|
}
|
|
208
111
|
return `${target.source}//${conceptId}`;
|
|
209
112
|
}
|
|
@@ -218,11 +121,8 @@ function resolveCreateProposalTarget(stashDir, explicit, bundle) {
|
|
|
218
121
|
const local = resolveProposalQueueTarget(stashDir, config);
|
|
219
122
|
if (!bundle || bundle === local.source)
|
|
220
123
|
return local;
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
return { source: target.source.name, root: target.source.path };
|
|
224
|
-
}
|
|
225
|
-
return local;
|
|
124
|
+
const target = resolveBundleWriteTarget(config, bundle);
|
|
125
|
+
return { source: target.source.name, root: target.source.path };
|
|
226
126
|
}
|
|
227
127
|
export function resolveProposalQueueTarget(stashDir, config = loadConfig()) {
|
|
228
128
|
const root = path.resolve(stashDir);
|
|
@@ -241,25 +141,11 @@ export function resolveProposalQueueTarget(stashDir, config = loadConfig()) {
|
|
|
241
141
|
}
|
|
242
142
|
return { source: bundleId, root };
|
|
243
143
|
}
|
|
244
|
-
// ── Public API ──────────────────────────────────────────────────────────────
|
|
245
144
|
/**
|
|
246
|
-
* Create a
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
* Before writing, this function checks:
|
|
252
|
-
* 1. `fingerprint_match` — the §23.6 input fingerprint (scheme version,
|
|
253
|
-
* source, ref, target before-hash, model id) was already processed.
|
|
254
|
-
* The row survives the proposal's lifecycle, so identical inputs stay
|
|
255
|
-
* deduplicated until the target, model, or scheme changes. Pass
|
|
256
|
-
* `input.force = true` to bypass.
|
|
257
|
-
* 2. `rejection_backoff` — a proposal for this `ref+source` was rejected
|
|
258
|
-
* within the source-specific backoff window (reflect: 14 d, distill:
|
|
259
|
-
* 30 d, others: 7 d). Bypass with `force: true`.
|
|
260
|
-
*
|
|
261
|
-
* When a guard fires the function returns a `CreateProposalSkipped` record
|
|
262
|
-
* instead of writing. Use {@link isProposalSkipped} to detect it.
|
|
145
|
+
* Create a pending proposal (a random UUID id). Obviously invalid input is
|
|
146
|
+
* refused with a typed `proposal_creation_rejected` event. The mint and its
|
|
147
|
+
* `proposed` ledger rows commit in one transaction; whether a ref may be
|
|
148
|
+
* proposed again is decided earlier, by the stage's candidate selection.
|
|
263
149
|
*/
|
|
264
150
|
export function createProposal(stashDir, input, ctx) {
|
|
265
151
|
if (!isValidProposalSource(input.source)) {
|
|
@@ -271,10 +157,6 @@ export function createProposal(stashDir, input, ctx) {
|
|
|
271
157
|
warn(`[proposal] Automated source "${input.source}" created a proposal without sourceRun. ` +
|
|
272
158
|
"Add sourceRun to enable accept-rate-per-run aggregation (W3C PROV-DM).");
|
|
273
159
|
}
|
|
274
|
-
// Deterministic input validation. Reject obviously-invalid proposals at
|
|
275
|
-
// the source rather than letting them enter the queue and waste reviewer
|
|
276
|
-
// time. Each rejection emits `proposal_creation_rejected` with a typed
|
|
277
|
-
// reason so we can see *which* check is firing in the event stream.
|
|
278
160
|
const rejectProposal = (reason, message) => {
|
|
279
161
|
appendEvent({
|
|
280
162
|
eventType: "proposal_creation_rejected",
|
|
@@ -290,7 +172,8 @@ export function createProposal(stashDir, input, ctx) {
|
|
|
290
172
|
catch (err) {
|
|
291
173
|
return rejectProposal("invalid_ref", `Invalid proposal ref "${input.ref}": ${err instanceof Error ? err.message : String(err)}`);
|
|
292
174
|
}
|
|
293
|
-
|
|
175
|
+
const typeDir = stashDirFor(parsedRef.type);
|
|
176
|
+
if (!typeDir) {
|
|
294
177
|
return rejectProposal("unknown_type", `Unknown asset type "${parsedRef.type}" in proposal ref "${input.ref}". Known types: ${[...placementTypes()].sort().join(", ")}.`);
|
|
295
178
|
}
|
|
296
179
|
if (!input.payload.content.trim()) {
|
|
@@ -299,55 +182,39 @@ export function createProposal(stashDir, input, ctx) {
|
|
|
299
182
|
if (input.target && parsedRef.origin && input.target.source !== parsedRef.origin) {
|
|
300
183
|
return rejectProposal("invalid_ref", `Qualified proposal ref bundle "${parsedRef.origin}" conflicts with queue target "${input.target.source}".`);
|
|
301
184
|
}
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
// malformed frontmatter, polluting the queue with hundreds of unusable
|
|
305
|
-
// entries. Reflect / distill / propose proposals have varied legitimate
|
|
306
|
-
// shapes and should not be rejected here for missing description.
|
|
185
|
+
// Only consolidate — the pipeline that historically flooded the queue with
|
|
186
|
+
// frontmatter-less proposals — must carry a description.
|
|
307
187
|
if (input.source === "consolidate") {
|
|
308
188
|
const desc = input.payload.frontmatter?.description;
|
|
309
189
|
if (typeof desc !== "string" || desc.trim() === "") {
|
|
310
190
|
return rejectProposal("missing_description", `Proposal for "${input.ref}" (source=consolidate) has empty or missing frontmatter description.`);
|
|
311
191
|
}
|
|
312
192
|
}
|
|
193
|
+
// The FileChange envelope, and the target's before-hashes as of mint (the
|
|
194
|
+
// freshness check at accept compares against them).
|
|
313
195
|
const proposalTarget = resolveCreateProposalTarget(stashDir, input.target, parsedRef.origin);
|
|
314
196
|
const normalizedRef = proposalDurableRef(parsedRef, proposalTarget);
|
|
315
197
|
const targetRoot = path.resolve(proposalTarget.root);
|
|
316
|
-
// WI-6.2: derive the FileChange[] envelope + mint-time beforeHash. The
|
|
317
|
-
// target is resolved against the proposal's OWN stash (a local snapshot —
|
|
318
|
-
// accept re-resolves the write target from config at apply time), and only
|
|
319
|
-
// the before-state's HASH is kept: the change's `before` body is a
|
|
320
|
-
// transaction-time capture that does not exist at mint time.
|
|
321
198
|
let targetRelPath;
|
|
322
|
-
let
|
|
199
|
+
let beforeContent;
|
|
323
200
|
try {
|
|
324
|
-
const
|
|
325
|
-
const targetAbs = assetPathForName(parsedRef.type, typeRoot, parsedRef.name);
|
|
201
|
+
const targetAbs = assetPathForName(parsedRef.type, path.join(targetRoot, typeDir), parsedRef.name);
|
|
326
202
|
targetRelPath = path.relative(targetRoot, targetAbs);
|
|
327
203
|
if (fs.existsSync(targetAbs))
|
|
328
|
-
|
|
204
|
+
beforeContent = fs.readFileSync(targetAbs, "utf8");
|
|
329
205
|
}
|
|
330
206
|
catch {
|
|
331
|
-
|
|
332
|
-
targetRelPath = path.join(stashDirFor(parsedRef.type), parsedRef.name);
|
|
207
|
+
targetRelPath = path.join(typeDir, parsedRef.name);
|
|
333
208
|
}
|
|
334
|
-
const
|
|
209
|
+
const content = targetRelPath.toLowerCase().endsWith(".md")
|
|
335
210
|
? ensureAkmMarkdownType(input.payload.content, parsedRef.type)
|
|
336
211
|
: input.payload.content;
|
|
337
|
-
const
|
|
338
|
-
{
|
|
339
|
-
path: targetRelPath,
|
|
340
|
-
after: proposalContent,
|
|
341
|
-
op: mintBeforeContent !== undefined ? "update" : "create",
|
|
342
|
-
},
|
|
212
|
+
const changes = [
|
|
213
|
+
{ path: targetRelPath, after: content, op: beforeContent !== undefined ? "update" : "create" },
|
|
343
214
|
];
|
|
344
|
-
const
|
|
215
|
+
const proposedTarget = { source: proposalTarget.source, root: targetRoot };
|
|
345
216
|
if (hasCanonicalProposalValidator(parsedRef.type)) {
|
|
346
|
-
// Mint
|
|
347
|
-
// NOT the full quality-validator list — see canonicalOnlyProposalValidators'
|
|
348
|
-
// doc comment (#952 review round 2). Quality validators (including the
|
|
349
|
-
// blocking reflect-truncation-marker guard) run at `proposal accept` /
|
|
350
|
-
// drain-promotion time via validateProposal instead.
|
|
217
|
+
// Mint checks structure only; the quality validators run at accept.
|
|
351
218
|
const report = runProposalValidators({
|
|
352
219
|
id: "pending",
|
|
353
220
|
ref: normalizedRef,
|
|
@@ -355,9 +222,9 @@ export function createProposal(stashDir, input, ctx) {
|
|
|
355
222
|
source: input.source,
|
|
356
223
|
createdAt: "",
|
|
357
224
|
updatedAt: "",
|
|
358
|
-
payload: { ...input.payload, content
|
|
359
|
-
changes
|
|
360
|
-
proposedTarget
|
|
225
|
+
payload: { ...input.payload, content },
|
|
226
|
+
changes,
|
|
227
|
+
proposedTarget,
|
|
361
228
|
}, canonicalOnlyProposalValidators);
|
|
362
229
|
if (!report.ok) {
|
|
363
230
|
return rejectProposal("invalid_canonical_structure", `Proposal for "${input.ref}" has invalid ${parsedRef.type} structure:\n${report.findings
|
|
@@ -365,258 +232,198 @@ export function createProposal(stashDir, input, ctx) {
|
|
|
365
232
|
.join("\n")}`);
|
|
366
233
|
}
|
|
367
234
|
}
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
return withProposalsDb(
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
235
|
+
const confidence = typeof input.confidence === "number" &&
|
|
236
|
+
Number.isFinite(input.confidence) &&
|
|
237
|
+
input.confidence >= 0 &&
|
|
238
|
+
input.confidence <= 1
|
|
239
|
+
? input.confidence
|
|
240
|
+
: undefined;
|
|
241
|
+
return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
|
|
242
|
+
const created = nowIso(ctx);
|
|
243
|
+
const proposal = {
|
|
244
|
+
id: (ctx?.randomUUID ?? randomUUID)(),
|
|
245
|
+
ref: normalizedRef,
|
|
246
|
+
status: "pending",
|
|
247
|
+
source: input.source,
|
|
248
|
+
...(input.sourceRun !== undefined ? { sourceRun: input.sourceRun } : {}),
|
|
249
|
+
createdAt: created,
|
|
250
|
+
updatedAt: created,
|
|
251
|
+
payload: {
|
|
252
|
+
content,
|
|
253
|
+
...(input.payload.frontmatter !== undefined ? { frontmatter: input.payload.frontmatter } : {}),
|
|
254
|
+
},
|
|
255
|
+
changes,
|
|
256
|
+
proposedTarget,
|
|
257
|
+
...(beforeContent !== undefined
|
|
258
|
+
? { beforeHash: contentHash(beforeContent), beforeHashNormalized: contentHash(beforeContent, "normalized") }
|
|
259
|
+
: {}),
|
|
260
|
+
...(confidence !== undefined ? { confidence } : {}),
|
|
261
|
+
...(input.eligibilitySource !== undefined ? { eligibilitySource: input.eligibilitySource } : {}),
|
|
262
|
+
...(input.promotionSource !== undefined ? { promotionSource: input.promotionSource } : {}),
|
|
263
|
+
...(input.promotionSourceHash !== undefined ? { promotionSourceHash: input.promotionSourceHash } : {}),
|
|
264
|
+
};
|
|
265
|
+
upsertProposal(db, proposal, stashDir);
|
|
266
|
+
for (const ref of input.attemptedRefs ?? [normalizedRef]) {
|
|
267
|
+
recordImproveLedger(db, {
|
|
268
|
+
stashDir,
|
|
269
|
+
ref,
|
|
395
270
|
source: input.source,
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
changes: mintedChanges,
|
|
404
|
-
proposedTarget: { source: proposalTarget.source, root: targetRoot },
|
|
405
|
-
...(mintedBeforeHash !== undefined ? { beforeHash: mintedBeforeHash } : {}),
|
|
406
|
-
...(sanitizedConfidence !== undefined ? { confidence: sanitizedConfidence } : {}),
|
|
407
|
-
// Attribution tagging: persist the eligibility lane so it survives to
|
|
408
|
-
// accept/reject/revert time. See EligibilitySource.
|
|
409
|
-
...(input.eligibilitySource !== undefined ? { eligibilitySource: input.eligibilitySource } : {}),
|
|
410
|
-
};
|
|
411
|
-
upsertProposal(db, proposal, stashDir);
|
|
412
|
-
// Record the processed fingerprint (also on force — a forced enqueue is
|
|
413
|
-
// still "these inputs were processed"; future unforced identical inputs
|
|
414
|
-
// dedup against it).
|
|
415
|
-
recordProposalFingerprint(db, stashDir, fingerprint, normalizedRef, input, proposal.id, created);
|
|
416
|
-
return proposal;
|
|
417
|
-
});
|
|
418
|
-
});
|
|
419
|
-
}
|
|
420
|
-
/** Version stamp of the input-fingerprint scheme; bump when terms change. */
|
|
421
|
-
const PROPOSAL_FINGERPRINT_VERSION = 1;
|
|
422
|
-
/**
|
|
423
|
-
* Compute the §23.6 input fingerprint for a proposal mint (+ the plan §4.5
|
|
424
|
-
* engine/model-id term). Terms, in order: scheme version, source (the recipe
|
|
425
|
-
* stand-in until Wave-2 recipes exist), target ref, target before-hash
|
|
426
|
-
* (empty for a create), evidence IDs/hashes (reserved — not yet modeled),
|
|
427
|
-
* guidance hashes (reserved), evaluator version (reserved), model id.
|
|
428
|
-
* Deliberately an INPUT fingerprint: the generated content is not a term —
|
|
429
|
-
* already-processed inputs skip re-processing regardless of what the model
|
|
430
|
-
* produced this time.
|
|
431
|
-
*/
|
|
432
|
-
function computeProposalFingerprint(args) {
|
|
433
|
-
return contentHash([
|
|
434
|
-
`v${PROPOSAL_FINGERPRINT_VERSION}`,
|
|
435
|
-
args.source,
|
|
436
|
-
args.ref,
|
|
437
|
-
args.beforeHash ?? "",
|
|
438
|
-
"", // evidence IDs/hashes — reserved (Wave-2 recipes)
|
|
439
|
-
"", // guidance hashes — reserved
|
|
440
|
-
"", // evaluator version — reserved
|
|
441
|
-
args.modelId ?? "",
|
|
442
|
-
].join("\0"));
|
|
443
|
-
}
|
|
444
|
-
/**
|
|
445
|
-
* Durably record a processed fingerprint (INSERT OR REPLACE — idempotent).
|
|
446
|
-
* `ref` must be the NORMALIZED ref — the same value the fingerprint was
|
|
447
|
-
* computed over — so future ref-keyed readers of the table never mismatch.
|
|
448
|
-
*/
|
|
449
|
-
function recordProposalFingerprint(db, stashDir, fingerprint, ref, input, proposalId, createdAt) {
|
|
450
|
-
db.prepare(`INSERT OR REPLACE INTO proposal_fingerprints
|
|
451
|
-
(stash_dir, fingerprint, ref, source, model_id, proposal_id, created_at)
|
|
452
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(stashDir, fingerprint, ref, input.source, input.modelId ?? "", proposalId, createdAt);
|
|
271
|
+
outcome: "proposed",
|
|
272
|
+
at: created,
|
|
273
|
+
proposalId: proposal.id,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
return proposal;
|
|
277
|
+
}));
|
|
453
278
|
}
|
|
454
279
|
/**
|
|
455
|
-
*
|
|
456
|
-
*
|
|
280
|
+
* Mint a `retire` proposal (0.9.17-alpha.9, the consolidate pair pass): its
|
|
281
|
+
* primary `FileChange` deletes `ref`'s own file rather than writing new
|
|
282
|
+
* content, so this does not reuse {@link createProposal} (which always
|
|
283
|
+
* builds a create/update change and enforces content/description rules that
|
|
284
|
+
* do not apply to a delete). `ref` must already exist on disk — retiring a
|
|
285
|
+
* phantom is a caller bug, refused rather than silently accepted.
|
|
286
|
+
*
|
|
287
|
+
* Deliberately does NOT record an `improve_ledger` "proposed" row: the pair
|
|
288
|
+
* pass keys its own ledger cadence per INITIATOR (source `consolidate-pair`,
|
|
289
|
+
* `pair-pass.ts`'s own end-of-run write), which may differ from this
|
|
290
|
+
* proposal's `ref` — the initiator and the retired side are not always the
|
|
291
|
+
* same asset (see `runConsolidatePairPass`). Recording one here, keyed by
|
|
292
|
+
* the retired ref instead, would be a second, competing row for whichever
|
|
293
|
+
* asset happens to be both.
|
|
457
294
|
*/
|
|
458
|
-
function
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
// §23.6: an already-processed fingerprint skips another model call's output
|
|
462
|
-
// unless explicitly forced. The row survives the proposal's lifecycle —
|
|
463
|
-
// identical inputs stay deduplicated until the target (before-hash), the
|
|
464
|
-
// model, or the scheme changes.
|
|
465
|
-
const existing = db
|
|
466
|
-
.prepare("SELECT proposal_id FROM proposal_fingerprints WHERE stash_dir = ? AND fingerprint = ?")
|
|
467
|
-
.get(stashDir, fingerprint);
|
|
468
|
-
if (existing) {
|
|
469
|
-
return {
|
|
470
|
-
skipped: true,
|
|
471
|
-
reason: "fingerprint_match",
|
|
472
|
-
message: `These inputs were already processed into a proposal for ${normalizedRef} (fingerprint match). Pass force:true to enqueue anyway.`,
|
|
473
|
-
...(existing.proposal_id ? { existingProposalId: existing.proposal_id } : {}),
|
|
474
|
-
};
|
|
295
|
+
export function createRetireProposal(stashDir, input, ctx) {
|
|
296
|
+
if (!isValidProposalSource(input.source)) {
|
|
297
|
+
warn(`[proposal] Unknown source "${input.source}" for a retire proposal. Expected one of: ${PROPOSAL_SOURCES.join(", ")}.`);
|
|
475
298
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
const remainingDays = Math.ceil((backoffMs - (nowMs - rejectedAt)) / MS_PER_DAY);
|
|
487
|
-
return {
|
|
488
|
-
skipped: true,
|
|
489
|
-
reason: "rejection_backoff",
|
|
490
|
-
message: `Proposal for ${normalizedRef} from source "${input.source}" is in rejection backoff ` +
|
|
491
|
-
`(${backoffDays}d window, ~${remainingDays}d remaining). Pass force:true to bypass.`,
|
|
492
|
-
existingProposalId: mostRecent.id,
|
|
493
|
-
};
|
|
494
|
-
}
|
|
299
|
+
let parsedRef;
|
|
300
|
+
try {
|
|
301
|
+
parsedRef = parseRefInput(input.ref);
|
|
302
|
+
}
|
|
303
|
+
catch (err) {
|
|
304
|
+
throw new UsageError(`Invalid retire proposal ref "${input.ref}": ${err instanceof Error ? err.message : String(err)}`, "INVALID_PROPOSAL");
|
|
305
|
+
}
|
|
306
|
+
const typeDir = stashDirFor(parsedRef.type);
|
|
307
|
+
if (!typeDir) {
|
|
308
|
+
throw new UsageError(`Unknown asset type "${parsedRef.type}" in retire proposal ref "${input.ref}". Known types: ${[...placementTypes()].sort().join(", ")}.`, "INVALID_PROPOSAL");
|
|
495
309
|
}
|
|
496
|
-
|
|
310
|
+
const proposalTarget = resolveCreateProposalTarget(stashDir, input.target, parsedRef.origin);
|
|
311
|
+
const normalizedRef = proposalDurableRef(parsedRef, proposalTarget);
|
|
312
|
+
const targetRoot = path.resolve(proposalTarget.root);
|
|
313
|
+
const targetAbs = assetPathForName(parsedRef.type, path.join(targetRoot, typeDir), parsedRef.name);
|
|
314
|
+
if (!fs.existsSync(targetAbs)) {
|
|
315
|
+
throw new UsageError(`Retire proposal target "${input.ref}" does not exist at ${targetAbs}.`, "INVALID_PROPOSAL");
|
|
316
|
+
}
|
|
317
|
+
const targetRelPath = path.relative(targetRoot, targetAbs);
|
|
318
|
+
const beforeContent = fs.readFileSync(targetAbs, "utf8");
|
|
319
|
+
const changes = [{ path: targetRelPath, op: "delete" }];
|
|
320
|
+
const proposedTarget = { source: proposalTarget.source, root: targetRoot };
|
|
321
|
+
const confidence = typeof input.confidence === "number" &&
|
|
322
|
+
Number.isFinite(input.confidence) &&
|
|
323
|
+
input.confidence >= 0 &&
|
|
324
|
+
input.confidence <= 1
|
|
325
|
+
? input.confidence
|
|
326
|
+
: undefined;
|
|
327
|
+
return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
|
|
328
|
+
const created = nowIso(ctx);
|
|
329
|
+
const proposal = {
|
|
330
|
+
id: (ctx?.randomUUID ?? randomUUID)(),
|
|
331
|
+
ref: normalizedRef,
|
|
332
|
+
status: "pending",
|
|
333
|
+
source: input.source,
|
|
334
|
+
...(input.sourceRun !== undefined ? { sourceRun: input.sourceRun } : {}),
|
|
335
|
+
createdAt: created,
|
|
336
|
+
updatedAt: created,
|
|
337
|
+
payload: { content: "" },
|
|
338
|
+
changes,
|
|
339
|
+
proposedTarget,
|
|
340
|
+
beforeHash: contentHash(beforeContent),
|
|
341
|
+
beforeHashNormalized: contentHash(beforeContent, "normalized"),
|
|
342
|
+
...(confidence !== undefined ? { confidence } : {}),
|
|
343
|
+
retirement: input.retirement,
|
|
344
|
+
};
|
|
345
|
+
upsertProposal(db, proposal, stashDir);
|
|
346
|
+
return proposal;
|
|
347
|
+
}));
|
|
497
348
|
}
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
if (!options.
|
|
509
|
-
return
|
|
349
|
+
function queryProposals(db, stashDir, options) {
|
|
350
|
+
// The live queue holds only pending proposals, so without the archive a
|
|
351
|
+
// non-pending status matches nothing.
|
|
352
|
+
if (!options.includeArchive && options.status !== undefined && options.status !== "pending")
|
|
353
|
+
return [];
|
|
354
|
+
const status = options.includeArchive ? options.status : "pending";
|
|
355
|
+
const wantRef = options.ref !== undefined ? filterRefIdentity(options.ref) : undefined;
|
|
356
|
+
return listStateProposals(db, { stashDir, ...(status !== undefined ? { status } : {}) }).filter((p) => {
|
|
357
|
+
if (wantRef !== undefined && !proposalMatchesRef(p.ref, wantRef))
|
|
358
|
+
return false;
|
|
359
|
+
if (!options.type)
|
|
360
|
+
return true;
|
|
361
|
+
try {
|
|
362
|
+
return parseRefInput(p.ref).type === options.type;
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
return false;
|
|
510
366
|
}
|
|
511
|
-
const status = options.includeArchive ? options.status : "pending";
|
|
512
|
-
// Short filters match by conceptId; qualified filters additionally retain
|
|
513
|
-
// bundle identity. Applied in JS because a short query does not equal the
|
|
514
|
-
// fully-qualified stored ref.
|
|
515
|
-
const wantRef = options.ref !== undefined ? filterRefIdentity(options.ref) : undefined;
|
|
516
|
-
return listStateProposals(db, {
|
|
517
|
-
stashDir,
|
|
518
|
-
...(status !== undefined ? { status } : {}),
|
|
519
|
-
}).filter((p) => {
|
|
520
|
-
if (wantRef !== undefined && !proposalMatchesRef(p.ref, wantRef)) {
|
|
521
|
-
return false;
|
|
522
|
-
}
|
|
523
|
-
if (!options.type)
|
|
524
|
-
return true;
|
|
525
|
-
try {
|
|
526
|
-
return parseRefInput(p.ref).type === options.type;
|
|
527
|
-
}
|
|
528
|
-
catch {
|
|
529
|
-
return false;
|
|
530
|
-
}
|
|
531
|
-
});
|
|
532
367
|
});
|
|
533
368
|
}
|
|
369
|
+
/** One stash's proposals: the pending queue, or with `includeArchive` the decided ones too. */
|
|
370
|
+
export function listProposals(stashDir, options = {}, ctx) {
|
|
371
|
+
return withProposalsDb(ctx, (db) => queryProposals(db, stashDir, options));
|
|
372
|
+
}
|
|
534
373
|
/**
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
* missing proposal store is therefore an empty snapshot, not a reason to
|
|
539
|
-
* create durable state before a required symbolic credential is validated at
|
|
540
|
-
* the dispatch boundary.
|
|
374
|
+
* {@link listProposals} on a read snapshot that never creates or migrates
|
|
375
|
+
* state.db: prompt building runs before the first dispatch has validated its
|
|
376
|
+
* credentials, and a missing store is simply empty.
|
|
541
377
|
*/
|
|
542
378
|
export function listProposalsReadOnly(stashDir, options = {}, ctx) {
|
|
543
379
|
const dbPath = ctx?.dbPath ?? getStateDbPath();
|
|
544
380
|
if (!fs.existsSync(dbPath))
|
|
545
381
|
return [];
|
|
546
|
-
|
|
382
|
+
const db = openSqliteReadSnapshot(dbPath);
|
|
383
|
+
if (!db)
|
|
384
|
+
return [];
|
|
547
385
|
try {
|
|
548
|
-
db
|
|
549
|
-
if (!db)
|
|
550
|
-
return [];
|
|
551
|
-
if (!options.includeArchive && options.status !== undefined && options.status !== "pending")
|
|
552
|
-
return [];
|
|
553
|
-
const status = options.includeArchive ? options.status : "pending";
|
|
554
|
-
const wantRef = options.ref !== undefined ? filterRefIdentity(options.ref) : undefined;
|
|
555
|
-
return listStateProposals(db, {
|
|
556
|
-
stashDir,
|
|
557
|
-
...(status !== undefined ? { status } : {}),
|
|
558
|
-
}).filter((proposal) => {
|
|
559
|
-
if (wantRef !== undefined && !proposalMatchesRef(proposal.ref, wantRef))
|
|
560
|
-
return false;
|
|
561
|
-
if (!options.type)
|
|
562
|
-
return true;
|
|
563
|
-
try {
|
|
564
|
-
return parseRefInput(proposal.ref).type === options.type;
|
|
565
|
-
}
|
|
566
|
-
catch {
|
|
567
|
-
return false;
|
|
568
|
-
}
|
|
569
|
-
});
|
|
386
|
+
return queryProposals(db, stashDir, options);
|
|
570
387
|
}
|
|
571
388
|
finally {
|
|
572
|
-
db
|
|
389
|
+
db.close();
|
|
573
390
|
}
|
|
574
391
|
}
|
|
575
|
-
/**
|
|
576
|
-
* Look up a proposal by id (live or archived).
|
|
577
|
-
* Throws `NotFoundError` when no match exists in this stash.
|
|
578
|
-
*/
|
|
392
|
+
/** A proposal by id, live or archived. */
|
|
579
393
|
export function getProposal(stashDir, id, ctx) {
|
|
580
|
-
return withProposalsDb(
|
|
394
|
+
return withProposalsDb(ctx, (db) => requireProposal(db, stashDir, id));
|
|
581
395
|
}
|
|
582
396
|
function requireProposal(db, stashDir, id) {
|
|
583
397
|
const proposal = getStateProposal(db, id, stashDir);
|
|
584
|
-
if (!proposal)
|
|
398
|
+
if (!proposal)
|
|
585
399
|
throw new NotFoundError(`Proposal "${id}" not found.`, "PROPOSAL_NOT_FOUND");
|
|
586
|
-
}
|
|
587
400
|
return proposal;
|
|
588
401
|
}
|
|
589
402
|
/**
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
* Resolution order:
|
|
593
|
-
* 1. Exact UUID match (existing behaviour).
|
|
594
|
-
* 2. Asset ref (contains `/`) — finds the most-recent pending proposal for
|
|
595
|
-
* that ref; falls back to archived if nothing is pending.
|
|
596
|
-
* 3. UUID prefix — matches any PENDING proposal whose id starts with the
|
|
597
|
-
* given string; throws if ambiguous.
|
|
403
|
+
* A proposal by exact id; else, for an asset ref, its most recent pending
|
|
404
|
+
* proposal (then most recent archived); else by a unique pending id prefix.
|
|
598
405
|
*/
|
|
599
406
|
export function resolveProposalId(stashDir, idOrRef, ctx) {
|
|
600
|
-
return withProposalsDb(
|
|
601
|
-
// 1. Exact UUID.
|
|
407
|
+
return withProposalsDb(ctx, (db) => {
|
|
602
408
|
const exact = getStateProposal(db, idOrRef, stashDir);
|
|
603
409
|
if (exact)
|
|
604
410
|
return exact;
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
411
|
+
if (idOrRef.includes(":") || idOrRef.includes("/")) {
|
|
412
|
+
const wantRef = filterRefIdentity(idOrRef);
|
|
413
|
+
// Should-fix 7: by-ref resolution never picks a retire proposal — the
|
|
414
|
+
// newest pending proposal for a ref could be a `consolidate-pair`
|
|
415
|
+
// retirement rather than the reflect/distill edit a person typed the
|
|
416
|
+
// ref to accept, and accepting it archives the asset instead. A retire
|
|
417
|
+
// is reached by its own proposal id, or by the explicit generator
|
|
418
|
+
// `consolidate-pair` (bulk accept/reject).
|
|
419
|
+
const newest = (status) => listStateProposals(db, { stashDir, ...(status !== undefined ? { status } : {}) })
|
|
420
|
+
.filter((p) => proposalMatchesRef(p.ref, wantRef) && !isRetireProposal(p))
|
|
421
|
+
.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
|
|
422
|
+
const found = newest("pending") ?? newest();
|
|
423
|
+
if (found)
|
|
424
|
+
return found;
|
|
617
425
|
throw new NotFoundError(`No proposal found for ref "${idOrRef}".`, "PROPOSAL_NOT_FOUND");
|
|
618
426
|
}
|
|
619
|
-
// 3. UUID prefix (pending queue only).
|
|
620
427
|
const prefixMatches = listStateProposalIdsByPrefix(db, stashDir, idOrRef);
|
|
621
428
|
if (prefixMatches.length === 1)
|
|
622
429
|
return requireProposal(db, stashDir, prefixMatches[0]);
|
|
@@ -627,80 +434,88 @@ export function resolveProposalId(stashDir, idOrRef, ctx) {
|
|
|
627
434
|
});
|
|
628
435
|
}
|
|
629
436
|
/**
|
|
630
|
-
*
|
|
631
|
-
*
|
|
632
|
-
*
|
|
437
|
+
* The ledger outcome of a decision. A procedural refusal (retention expiry, a
|
|
438
|
+
* stale target, a missing asset) judged nothing, so it never carries the
|
|
439
|
+
* rejection window.
|
|
633
440
|
*/
|
|
441
|
+
function ledgerOutcomeForDecision(status, gateDecision) {
|
|
442
|
+
if (status === "accepted")
|
|
443
|
+
return "accepted";
|
|
444
|
+
if (gateDecision?.outcome === "auto-rejected") {
|
|
445
|
+
if (gateDecision.reason === EXPIRED_GATE_REASON)
|
|
446
|
+
return "expired";
|
|
447
|
+
if (gateDecision.reason === STALE_TARGET_GATE_REASON || gateDecision.reason === ASSET_MISSING_GATE_REASON) {
|
|
448
|
+
return "failed";
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return "rejected";
|
|
452
|
+
}
|
|
453
|
+
/** Archive a pending proposal as accepted/rejected, recording the decision in the ledger in the same transaction. */
|
|
634
454
|
export function archiveProposal(stashDir, id, status, reason, ctx, gateDecision) {
|
|
635
|
-
return withProposalsDb(
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
455
|
+
return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
|
|
456
|
+
const existing = requireProposal(db, stashDir, id);
|
|
457
|
+
if (existing.status !== "pending") {
|
|
458
|
+
throw new UsageError(`Proposal ${id} is not pending (current status: ${existing.status}). Only pending proposals can be ${status}.`, "INVALID_FLAG_VALUE");
|
|
459
|
+
}
|
|
460
|
+
const decidedAt = nowIso(ctx);
|
|
461
|
+
const updated = {
|
|
462
|
+
...existing,
|
|
463
|
+
status,
|
|
464
|
+
updatedAt: decidedAt,
|
|
465
|
+
review: { outcome: status, ...(reason !== undefined ? { reason } : {}), decidedAt },
|
|
466
|
+
...(gateDecision ? { gateDecision: { ...gateDecision, decidedAt: gateDecision.decidedAt ?? decidedAt } } : {}),
|
|
467
|
+
};
|
|
468
|
+
upsertProposal(db, updated, stashDir);
|
|
469
|
+
recordImproveLedgerDecision(db, {
|
|
470
|
+
proposalId: updated.id,
|
|
471
|
+
stashDir,
|
|
472
|
+
ref: updated.ref,
|
|
473
|
+
source: updated.source,
|
|
474
|
+
outcome: ledgerOutcomeForDecision(status, gateDecision),
|
|
475
|
+
at: decidedAt,
|
|
476
|
+
...(reason !== undefined ? { detail: reason } : {}),
|
|
655
477
|
});
|
|
656
|
-
|
|
478
|
+
return updated;
|
|
479
|
+
}));
|
|
657
480
|
}
|
|
658
481
|
/**
|
|
659
|
-
*
|
|
660
|
-
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
* row so `akm proposal show` and `list` can explain why a proposal landed where
|
|
664
|
-
* it did. The decision is metadata about the adjudication, so this does NOT
|
|
665
|
-
* change `status` or bump `updatedAt` — a `deferred` proposal stays `pending`,
|
|
666
|
-
* and the accept / reject status flips are owned by {@link promoteProposal} /
|
|
667
|
-
* {@link archiveProposal}. `decidedAt` defaults to now when the caller omits it.
|
|
668
|
-
*
|
|
669
|
-
* Best-effort: a proposal that no longer exists (e.g. concurrently archived) is
|
|
670
|
-
* skipped silently rather than throwing, so a gate run never aborts mid-batch.
|
|
671
|
-
* Returns the updated proposal, or undefined when no matching row exists.
|
|
482
|
+
* Stamp a gate's decision (#577) on a pending proposal without changing its
|
|
483
|
+
* status: the drain's verdict, or the generating stage's judge pass or review
|
|
484
|
+
* deferral (`deferred` records `review_needed` in the ledger). A proposal no
|
|
485
|
+
* longer pending is skipped (`undefined`), so a batch never aborts.
|
|
672
486
|
*/
|
|
673
487
|
export function recordGateDecision(stashDir, id, decision, ctx) {
|
|
674
|
-
return withProposalsDb(
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
488
|
+
return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
|
|
489
|
+
const existing = getStateProposal(db, id, stashDir);
|
|
490
|
+
if (!existing || existing.status !== "pending")
|
|
491
|
+
return undefined;
|
|
492
|
+
const decidedAt = decision.decidedAt ?? nowIso(ctx);
|
|
493
|
+
const updated = { ...existing, gateDecision: { ...decision, decidedAt } };
|
|
494
|
+
upsertProposal(db, updated, stashDir);
|
|
495
|
+
if (decision.outcome === "deferred") {
|
|
496
|
+
recordImproveLedgerDecision(db, {
|
|
497
|
+
proposalId: updated.id,
|
|
498
|
+
stashDir,
|
|
499
|
+
ref: updated.ref,
|
|
500
|
+
source: updated.source,
|
|
501
|
+
outcome: "review_needed",
|
|
502
|
+
at: decidedAt,
|
|
503
|
+
detail: decision.reason,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
return updated;
|
|
507
|
+
}));
|
|
687
508
|
}
|
|
688
509
|
/**
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
*
|
|
692
|
-
* from accumulating stale reviewer work after large refactors or deletes.
|
|
693
|
-
*
|
|
694
|
-
* Scope rule: only `source=reflect` proposals are subject to orphan rejection.
|
|
695
|
-
* Lessons, propose, distill, and consolidate proposals legitimately target
|
|
696
|
-
* assets that don't exist yet and must never be purged.
|
|
510
|
+
* Reject pending `reflect` proposals whose target no longer exists in any of
|
|
511
|
+
* `sourceDirs` (a maintenance pass). Other sources — lessons above all — may
|
|
512
|
+
* legitimately target assets that do not exist yet.
|
|
697
513
|
*/
|
|
698
514
|
export function purgeOrphanProposals(stashDir, sourceDirs, ctx) {
|
|
699
515
|
const t0 = Date.now();
|
|
700
516
|
const orphans = [];
|
|
701
517
|
const byType = {};
|
|
702
|
-
const
|
|
703
|
-
const reflectPending = pending.filter((p) => p.source === "reflect");
|
|
518
|
+
const reflectPending = listProposals(stashDir, { status: "pending" }, ctx).filter((p) => p.source === "reflect");
|
|
704
519
|
for (const p of reflectPending) {
|
|
705
520
|
let parsed;
|
|
706
521
|
try {
|
|
@@ -709,86 +524,60 @@ export function purgeOrphanProposals(stashDir, sourceDirs, ctx) {
|
|
|
709
524
|
catch {
|
|
710
525
|
continue;
|
|
711
526
|
}
|
|
712
|
-
// Lessons are new-asset proposals by definition — they cannot be orphaned.
|
|
713
|
-
if (parsed.type === "lesson")
|
|
714
|
-
continue;
|
|
715
527
|
const spec = stashDirFor(parsed.type);
|
|
716
|
-
if (!spec)
|
|
528
|
+
if (parsed.type === "lesson" || !spec)
|
|
717
529
|
continue;
|
|
718
|
-
const exists = sourceDirs.some((root) =>
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
}
|
|
530
|
+
const exists = sourceDirs.some((root) => fs.existsSync(assetPathForName(parsed.type, path.join(root, spec), parsed.name)));
|
|
531
|
+
if (exists)
|
|
532
|
+
continue;
|
|
533
|
+
try {
|
|
534
|
+
archiveProposal(stashDir, p.id, "rejected", "Asset no longer exists on disk", ctx, {
|
|
535
|
+
outcome: "auto-rejected",
|
|
536
|
+
reason: ASSET_MISSING_GATE_REASON,
|
|
537
|
+
gate: "orphan-purge",
|
|
538
|
+
});
|
|
539
|
+
orphans.push({ id: p.id, ref: p.ref, reason: "asset_missing" });
|
|
540
|
+
byType[parsed.type] = (byType[parsed.type] ?? 0) + 1;
|
|
541
|
+
}
|
|
542
|
+
catch (err) {
|
|
543
|
+
warn(`[proposals] purgeOrphanProposals: failed to reject ${p.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
733
544
|
}
|
|
734
545
|
}
|
|
735
|
-
return {
|
|
736
|
-
checked: reflectPending.length,
|
|
737
|
-
rejected: orphans.length,
|
|
738
|
-
durationMs: Date.now() - t0,
|
|
739
|
-
byType,
|
|
740
|
-
orphans,
|
|
741
|
-
};
|
|
546
|
+
return { checked: reflectPending.length, rejected: orphans.length, durationMs: Date.now() - t0, byType, orphans };
|
|
742
547
|
}
|
|
743
548
|
/**
|
|
744
|
-
* Archive pending proposals older than `
|
|
745
|
-
*
|
|
746
|
-
*
|
|
747
|
-
*
|
|
748
|
-
* human-in-the-loop pipeline (Settles 2009 active-learning survey). Pending
|
|
749
|
-
* proposals that have aged past the retention window are very rarely accepted
|
|
750
|
-
* — the reviewer either intentionally declined to act on them, or the asset
|
|
751
|
-
* they target has drifted enough that the proposal is no longer relevant.
|
|
752
|
-
* Auto-expiring them keeps the live queue focused on actionable work; the
|
|
753
|
-
* archive preserves the full audit trail.
|
|
754
|
-
*
|
|
755
|
-
* Each expired proposal is archived with status `rejected` and reason
|
|
756
|
-
* `"expired: no action within retention window"`. A `proposal_expired` event
|
|
757
|
-
* is appended for each expired proposal so downstream observability (events
|
|
758
|
-
* dashboards, source-acceptance-rate aggregations) can see expiry separately
|
|
759
|
-
* from explicit rejections.
|
|
760
|
-
*
|
|
761
|
-
* Idempotent: a second call within the same retention window finds nothing
|
|
762
|
-
* to expire (the archived entries are no longer in the pending queue).
|
|
549
|
+
* Archive pending proposals older than `archiveRetentionDays` (default 90;
|
|
550
|
+
* 0 disables) as rejected with an `expired` gate decision and a
|
|
551
|
+
* `proposal_expired` event. The ledger records `expired` — a short grace, not
|
|
552
|
+
* the rejection window, since nobody judged the content.
|
|
763
553
|
*/
|
|
764
554
|
export function expireStaleProposals(stashDir, config, ctx) {
|
|
765
555
|
const t0 = Date.now();
|
|
766
556
|
const retentionDays = config.archiveRetentionDays ?? 90;
|
|
767
557
|
const expiredProposals = [];
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
if (retentionDays <= 0) {
|
|
771
|
-
return {
|
|
772
|
-
checked: 0,
|
|
773
|
-
expired: 0,
|
|
774
|
-
durationMs: Date.now() - t0,
|
|
775
|
-
retentionDays,
|
|
776
|
-
expiredProposals,
|
|
777
|
-
};
|
|
778
|
-
}
|
|
779
|
-
const retentionMs = retentionDays * MS_PER_DAY;
|
|
558
|
+
if (retentionDays <= 0)
|
|
559
|
+
return { checked: 0, expired: 0, durationMs: Date.now() - t0, retentionDays, expiredProposals };
|
|
780
560
|
const nowMs = (ctx?.now ?? Date.now)();
|
|
781
561
|
const pending = listProposals(stashDir, { status: "pending" }, ctx);
|
|
782
562
|
for (const p of pending) {
|
|
783
|
-
|
|
784
|
-
|
|
563
|
+
// Should-fix 8: a retire proposal never expires by age. B2's accept-time
|
|
564
|
+
// hash check already refuses it once it goes stale, and the
|
|
565
|
+
// one-pending-retire-per-asset rule (pair-pass.ts's pendingRetireRefs)
|
|
566
|
+
// bounds how many can queue up — retention expiry would instead
|
|
567
|
+
// permanently drop a still-fresh pair nobody has reviewed yet, with no
|
|
568
|
+
// way back short of the pair pass finding it again from scratch.
|
|
569
|
+
if (isRetireProposal(p))
|
|
785
570
|
continue;
|
|
786
|
-
const
|
|
787
|
-
if (
|
|
571
|
+
const createdMs = new Date(p.createdAt).getTime();
|
|
572
|
+
if (!Number.isFinite(createdMs) || nowMs - createdMs < retentionDays * MS_PER_DAY)
|
|
788
573
|
continue;
|
|
789
574
|
try {
|
|
790
|
-
archiveProposal(stashDir, p.id, "rejected", "expired: no action within retention window", ctx
|
|
791
|
-
|
|
575
|
+
archiveProposal(stashDir, p.id, "rejected", "expired: no action within retention window", ctx, {
|
|
576
|
+
outcome: "auto-rejected",
|
|
577
|
+
reason: EXPIRED_GATE_REASON,
|
|
578
|
+
gate: "retention",
|
|
579
|
+
});
|
|
580
|
+
const ageDays = Math.floor((nowMs - createdMs) / MS_PER_DAY);
|
|
792
581
|
expiredProposals.push({ id: p.id, ref: p.ref, ageDays });
|
|
793
582
|
appendEvent({
|
|
794
583
|
eventType: "proposal_expired",
|
|
@@ -803,19 +592,9 @@ export function expireStaleProposals(stashDir, config, ctx) {
|
|
|
803
592
|
});
|
|
804
593
|
}
|
|
805
594
|
catch (err) {
|
|
806
|
-
// Best-effort — a single failure must not block the pass.
|
|
807
595
|
warn(`[proposals] expireStaleProposals: failed to expire ${p.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
808
596
|
}
|
|
809
597
|
}
|
|
810
|
-
// Prune fingerprint rows past the same retention window (best-effort):
|
|
811
|
-
// ISO created_at strings compare lexicographically.
|
|
812
|
-
try {
|
|
813
|
-
const cutoffIso = new Date(nowMs - retentionMs).toISOString();
|
|
814
|
-
withProposalsDb(stashDir, ctx, (db) => db.prepare("DELETE FROM proposal_fingerprints WHERE stash_dir = ? AND created_at < ?").run(stashDir, cutoffIso));
|
|
815
|
-
}
|
|
816
|
-
catch (err) {
|
|
817
|
-
warn(`[proposals] expireStaleProposals: fingerprint prune failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
818
|
-
}
|
|
819
598
|
return {
|
|
820
599
|
checked: pending.length,
|
|
821
600
|
expired: expiredProposals.length,
|
|
@@ -824,515 +603,155 @@ export function expireStaleProposals(stashDir, config, ctx) {
|
|
|
824
603
|
expiredProposals,
|
|
825
604
|
};
|
|
826
605
|
}
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
"asset-published",
|
|
831
|
-
"proposal-persisted",
|
|
832
|
-
"index-finalized",
|
|
833
|
-
"event-finalized",
|
|
834
|
-
"committed",
|
|
835
|
-
];
|
|
836
|
-
/** TEST-ONLY crash-window hook used by subprocess recovery tests. */
|
|
837
|
-
export function _setProposalMutationHookForTests(hook) {
|
|
838
|
-
_setTxnMutationHookForTests(hook);
|
|
839
|
-
}
|
|
840
|
-
function proposalHash(content) {
|
|
841
|
-
return createHash("sha256").update(content).digest("hex");
|
|
842
|
-
}
|
|
843
|
-
function proposalFileHash(filePath) {
|
|
844
|
-
return proposalHash(fs.readFileSync(filePath));
|
|
606
|
+
/** The hash a `staged` gate decision records, so a reader can tell the judged bytes from an edit. */
|
|
607
|
+
export function proposalContentHash(proposal) {
|
|
608
|
+
return contentHash(proposalContent(proposal));
|
|
845
609
|
}
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
for (const filePath of [p.publishPath, p.displacedPath]) {
|
|
858
|
-
try {
|
|
859
|
-
fs.rmSync(filePath, { force: true });
|
|
860
|
-
}
|
|
861
|
-
catch (error) {
|
|
862
|
-
warn(`[proposals] transaction publication cleanup failed at ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
fsyncTxnDir(path.dirname(p.assetPath));
|
|
866
|
-
}
|
|
867
|
-
function rollbackPreparedProposalTransaction(txn) {
|
|
868
|
-
const p = txn.journal.payload;
|
|
869
|
-
const currentHash = fs.existsSync(p.assetPath) ? proposalFileHash(p.assetPath) : null;
|
|
870
|
-
if (!fs.existsSync(p.displacedPath)) {
|
|
871
|
-
if (p.originalHash === null) {
|
|
872
|
-
if (currentHash === p.publishedHash && sameProposalFile(p.assetPath, p.publishPath)) {
|
|
873
|
-
fs.unlinkSync(p.assetPath);
|
|
874
|
-
// #652: un-publishing is a mutation of this run's own write — journal
|
|
875
|
-
// it so the sync stages the FINAL state of a written-then-reverted path.
|
|
876
|
-
recordWrittenPath(p.assetPath);
|
|
877
|
-
}
|
|
878
|
-
else if (currentHash !== null) {
|
|
879
|
-
throw new Error(`Cannot roll back proposal transaction: target was created externally.`);
|
|
880
|
-
}
|
|
881
|
-
}
|
|
882
|
-
else if (currentHash !== p.originalHash) {
|
|
883
|
-
throw new Error(`Cannot roll back proposal transaction: ${p.assetPath} diverged.`);
|
|
884
|
-
}
|
|
885
|
-
cleanupProposalPublication(p);
|
|
886
|
-
return;
|
|
887
|
-
}
|
|
888
|
-
if (currentHash === p.publishedHash) {
|
|
889
|
-
fs.unlinkSync(p.assetPath);
|
|
890
|
-
recordWrittenPath(p.assetPath);
|
|
891
|
-
}
|
|
892
|
-
else if (currentHash !== null && currentHash !== p.originalHash) {
|
|
893
|
-
throw new Error(`Cannot roll back proposal transaction: ${p.assetPath} diverged.`);
|
|
894
|
-
}
|
|
895
|
-
if (fs.existsSync(p.displacedPath)) {
|
|
896
|
-
if (fs.existsSync(p.assetPath)) {
|
|
897
|
-
throw new Error(`Cannot restore proposal backup: ${p.assetPath} is occupied.`);
|
|
898
|
-
}
|
|
899
|
-
fs.linkSync(p.displacedPath, p.assetPath);
|
|
900
|
-
// #652: restoring the displaced original still leaves the path in a state
|
|
901
|
-
// this run produced; journal it so the final on-disk bytes are staged.
|
|
902
|
-
recordWrittenPath(p.assetPath);
|
|
903
|
-
}
|
|
904
|
-
cleanupProposalPublication(p);
|
|
905
|
-
}
|
|
906
|
-
function validatePublishedProposal(p) {
|
|
907
|
-
if (!fs.existsSync(p.assetPath) || proposalFileHash(p.assetPath) !== p.publishedHash) {
|
|
908
|
-
throw new Error(`Cannot recover proposal ${p.proposalId}: published asset diverged.`);
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
function persistProposalTransactionState(txn, proposal, ctx) {
|
|
912
|
-
const p = txn.journal.payload;
|
|
913
|
-
const decidedAt = txn.journal.decidedAt;
|
|
914
|
-
const backupContent = p.backupPath ? fs.readFileSync(p.backupPath, "utf8") : undefined;
|
|
915
|
-
const publishedContent = fs.readFileSync(p.contentPath, "utf8");
|
|
916
|
-
return withProposalsDb(p.stashDir, ctx, (db) => withImmediateTransaction(db, () => {
|
|
917
|
-
const current = requireProposal(db, p.stashDir, p.proposalId);
|
|
918
|
-
if (p.operation === "accept") {
|
|
610
|
+
/**
|
|
611
|
+
* Record an accept or revert — the row, its ledger decision and its event in
|
|
612
|
+
* one transaction — after the asset file is on disk. A proposal already in the
|
|
613
|
+
* requested state is returned unchanged.
|
|
614
|
+
*/
|
|
615
|
+
function persistProposalDecision(stashDir, proposal, decision, ctx) {
|
|
616
|
+
return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
|
|
617
|
+
const current = requireProposal(db, stashDir, proposal.id);
|
|
618
|
+
let next;
|
|
619
|
+
if (decision.operation === "accept") {
|
|
620
|
+
const publishedHash = contentHash(decision.content);
|
|
919
621
|
if (current.status === "accepted") {
|
|
920
|
-
if (current.acceptedTarget?.contentHash !==
|
|
921
|
-
throw new Error(`Accepted proposal ${
|
|
622
|
+
if (current.acceptedTarget?.contentHash !== publishedHash) {
|
|
623
|
+
throw new Error(`Accepted proposal ${proposal.id} does not match the published content.`);
|
|
922
624
|
}
|
|
923
625
|
return current;
|
|
924
626
|
}
|
|
925
627
|
if (current.status !== "pending") {
|
|
926
|
-
throw new Error(`Proposal ${
|
|
628
|
+
throw new Error(`Proposal ${proposal.id} changed status during acceptance (${current.status}).`);
|
|
927
629
|
}
|
|
928
|
-
const
|
|
929
|
-
|
|
630
|
+
const root = decision.target.source.path;
|
|
631
|
+
const persisted = proposal.changes.length > 0 && proposal.changes.every((change) => change.path.length > 0)
|
|
632
|
+
? withProposalContent(proposal, decision.content)
|
|
930
633
|
: {
|
|
931
634
|
...proposal,
|
|
932
|
-
payload: { ...proposal.payload, content:
|
|
635
|
+
payload: { ...proposal.payload, content: decision.content },
|
|
933
636
|
changes: [
|
|
934
637
|
{
|
|
935
|
-
path: path.relative(
|
|
936
|
-
op:
|
|
937
|
-
after:
|
|
638
|
+
path: path.relative(root, decision.assetPath),
|
|
639
|
+
op: decision.existed ? "update" : "create",
|
|
640
|
+
after: decision.content,
|
|
938
641
|
},
|
|
939
642
|
],
|
|
940
|
-
proposedTarget: { source:
|
|
643
|
+
proposedTarget: { source: decision.target.source.name, root },
|
|
941
644
|
};
|
|
942
|
-
|
|
943
|
-
...
|
|
645
|
+
next = {
|
|
646
|
+
...persisted,
|
|
944
647
|
status: "accepted",
|
|
945
|
-
updatedAt: decidedAt,
|
|
946
|
-
review: { outcome: "accepted", decidedAt },
|
|
648
|
+
updatedAt: decision.decidedAt,
|
|
649
|
+
review: { outcome: "accepted", decidedAt: decision.decidedAt },
|
|
947
650
|
acceptedTarget: {
|
|
948
|
-
source:
|
|
949
|
-
root
|
|
950
|
-
path:
|
|
951
|
-
contentHash:
|
|
651
|
+
source: decision.target.source.name,
|
|
652
|
+
root,
|
|
653
|
+
path: decision.assetPath,
|
|
654
|
+
contentHash: publishedHash,
|
|
952
655
|
},
|
|
953
|
-
...(
|
|
954
|
-
? {
|
|
656
|
+
...(decision.gateDecision
|
|
657
|
+
? {
|
|
658
|
+
gateDecision: {
|
|
659
|
+
...decision.gateDecision,
|
|
660
|
+
decidedAt: decision.gateDecision.decidedAt ?? decision.decidedAt,
|
|
661
|
+
},
|
|
662
|
+
}
|
|
955
663
|
: {}),
|
|
956
|
-
...(backupContent !== undefined ? { backupContent } : {}),
|
|
664
|
+
...(decision.backupContent !== undefined ? { backupContent: decision.backupContent } : {}),
|
|
957
665
|
};
|
|
958
|
-
upsertProposal(db, accepted, p.stashDir);
|
|
959
|
-
return accepted;
|
|
960
666
|
}
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
667
|
+
else {
|
|
668
|
+
if (current.status === "reverted")
|
|
669
|
+
return current;
|
|
670
|
+
if (current.status !== "accepted") {
|
|
671
|
+
throw new Error(`Proposal ${proposal.id} changed status during reversion (${current.status}).`);
|
|
672
|
+
}
|
|
673
|
+
next = {
|
|
674
|
+
...current,
|
|
675
|
+
status: "reverted",
|
|
676
|
+
updatedAt: decision.decidedAt,
|
|
677
|
+
review: {
|
|
678
|
+
outcome: "rejected",
|
|
679
|
+
reason: "reverted: prior content restored from backup",
|
|
680
|
+
decidedAt: decision.decidedAt,
|
|
681
|
+
},
|
|
682
|
+
};
|
|
965
683
|
}
|
|
966
|
-
const
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
return reverted;
|
|
978
|
-
}));
|
|
979
|
-
}
|
|
980
|
-
function persistProposalEvent(txn, proposal, ctx) {
|
|
981
|
-
const p = txn.journal.payload;
|
|
982
|
-
withProposalsDb(p.stashDir, ctx, (db) => withImmediateTransaction(db, () => {
|
|
983
|
-
const metadata = {
|
|
984
|
-
proposalId: proposal.id,
|
|
985
|
-
source: proposal.source,
|
|
986
|
-
...(proposal.sourceRun !== undefined ? { sourceRun: proposal.sourceRun } : {}),
|
|
987
|
-
assetPath: p.assetPath,
|
|
988
|
-
...(proposal.eligibilitySource !== undefined ? { eligibilitySource: proposal.eligibilitySource } : {}),
|
|
989
|
-
...(p.eventMetadata ?? {}),
|
|
990
|
-
proposalTransactionId: txn.journal.transactionId,
|
|
991
|
-
};
|
|
684
|
+
const accept = decision.operation === "accept";
|
|
685
|
+
upsertProposal(db, next, stashDir);
|
|
686
|
+
recordImproveLedgerDecision(db, {
|
|
687
|
+
proposalId: next.id,
|
|
688
|
+
stashDir,
|
|
689
|
+
ref: next.ref,
|
|
690
|
+
source: next.source,
|
|
691
|
+
outcome: ledgerOutcomeForDecision(accept ? "accepted" : "reverted"),
|
|
692
|
+
at: decision.decidedAt,
|
|
693
|
+
...(accept ? {} : { detail: "reverted" }),
|
|
694
|
+
});
|
|
992
695
|
insertEventOnce(db, {
|
|
993
|
-
eventType:
|
|
994
|
-
ts:
|
|
995
|
-
ref:
|
|
996
|
-
metadata
|
|
997
|
-
|
|
696
|
+
eventType: accept ? "promoted" : "proposal_reverted",
|
|
697
|
+
ts: decision.decidedAt,
|
|
698
|
+
ref: next.ref,
|
|
699
|
+
metadata: {
|
|
700
|
+
proposalId: next.id,
|
|
701
|
+
source: next.source,
|
|
702
|
+
...(next.sourceRun !== undefined ? { sourceRun: next.sourceRun } : {}),
|
|
703
|
+
assetPath: decision.assetPath,
|
|
704
|
+
...(next.eligibilitySource !== undefined ? { eligibilitySource: next.eligibilitySource } : {}),
|
|
705
|
+
...(decision.operation === "accept" && decision.eventMetadata ? decision.eventMetadata : {}),
|
|
706
|
+
},
|
|
707
|
+
idempotencyKey: `${next.id}:${accept ? "promoted" : "reverted"}`,
|
|
998
708
|
});
|
|
709
|
+
return next;
|
|
999
710
|
}));
|
|
1000
711
|
}
|
|
1001
|
-
|
|
1002
|
-
const
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
message: `${p.operation === "accept" ? "Update" : "Revert"} ${p.ref}`,
|
|
1015
|
-
paths: [commitPath],
|
|
1016
|
-
snapshots: p.gitSnapshots ?? {},
|
|
1017
|
-
onCommitRecorded: (commit) => {
|
|
1018
|
-
const publication = p.gitPublication;
|
|
1019
|
-
if (publication.commit !== commit) {
|
|
1020
|
-
publication.commit = commit;
|
|
1021
|
-
advanceTxn(txn, "asset-published");
|
|
1022
|
-
}
|
|
712
|
+
export function rejectProposalDurably(stashDir, proposalId, reason, ctx, gateDecision) {
|
|
713
|
+
const decidedAt = nowIso(ctx);
|
|
714
|
+
const rejected = archiveProposal(stashDir, proposalId, "rejected", reason, { ...ctx, now: () => Date.parse(decidedAt) }, gateDecision);
|
|
715
|
+
withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
|
|
716
|
+
insertEventOnce(db, {
|
|
717
|
+
eventType: "rejected",
|
|
718
|
+
ts: decidedAt,
|
|
719
|
+
ref: rejected.ref,
|
|
720
|
+
metadata: {
|
|
721
|
+
proposalId: rejected.id,
|
|
722
|
+
source: rejected.source,
|
|
723
|
+
...(rejected.sourceRun !== undefined ? { sourceRun: rejected.sourceRun } : {}),
|
|
724
|
+
...(reason !== undefined ? { reason } : {}),
|
|
1023
725
|
},
|
|
726
|
+
idempotencyKey: `${rejected.id}:rejected`,
|
|
1024
727
|
});
|
|
1025
|
-
|
|
1026
|
-
advanceTxn(txn, "proposal-persisted");
|
|
1027
|
-
}
|
|
1028
|
-
let accepted = getProposal(p.stashDir, p.proposalId, ctx);
|
|
1029
|
-
if (txn.journal.phase === "proposal-persisted") {
|
|
1030
|
-
if (!(await indexWrittenAssets(txn.journal.root, [p.assetPath], { bundleId: target.source.name }))) {
|
|
1031
|
-
throw new Error(`Proposal ${p.proposalId} index finalization failed.`);
|
|
1032
|
-
}
|
|
1033
|
-
advanceTxn(txn, "index-finalized");
|
|
1034
|
-
}
|
|
1035
|
-
if (txn.journal.phase === "index-finalized") {
|
|
1036
|
-
accepted = getProposal(p.stashDir, p.proposalId, ctx);
|
|
1037
|
-
persistProposalEvent(txn, accepted, ctx);
|
|
1038
|
-
txnMutationHook("event-persisted");
|
|
1039
|
-
advanceTxn(txn, "event-finalized");
|
|
1040
|
-
}
|
|
1041
|
-
if (txn.journal.phase === "event-finalized")
|
|
1042
|
-
advanceTxn(txn, "committed");
|
|
1043
|
-
return accepted;
|
|
1044
|
-
}
|
|
1045
|
-
/**
|
|
1046
|
-
* Kind-level safety fence for a `proposal` journal, run before any recovery
|
|
1047
|
-
* action. The engine fences root binding and the uniform changes[] separately.
|
|
1048
|
-
*/
|
|
1049
|
-
function fenceProposalTxnJournal(journal, txnDir, root) {
|
|
1050
|
-
const p = journal.payload;
|
|
1051
|
-
const refIdentity = proposalRefIdentity(p.ref);
|
|
1052
|
-
if (!["accept", "revert"].includes(p.operation) ||
|
|
1053
|
-
!p.targetSource ||
|
|
1054
|
-
!p.targetKind ||
|
|
1055
|
-
refIdentity?.bundle === undefined ||
|
|
1056
|
-
!isWithin(p.assetPath, root) ||
|
|
1057
|
-
![p.contentPath, p.backupPath]
|
|
1058
|
-
.filter((candidate) => candidate !== null)
|
|
1059
|
-
.every((candidate) => isWithin(candidate, txnDir)) ||
|
|
1060
|
-
![p.publishPath, p.displacedPath].every((candidate) => isWithin(candidate, root) && path.dirname(candidate) === path.dirname(p.assetPath))) {
|
|
1061
|
-
throw new Error(`Refusing unsafe proposal transaction journal at ${path.join(txnDir, "journal.json")}.`);
|
|
1062
|
-
}
|
|
1063
|
-
}
|
|
1064
|
-
function resolveProposalRecoveryTarget(config, journal) {
|
|
1065
|
-
let target;
|
|
1066
|
-
try {
|
|
1067
|
-
target = resolveBundleWriteTarget(config, journal.payload.targetSource);
|
|
1068
|
-
}
|
|
1069
|
-
catch {
|
|
1070
|
-
throw new UsageError(`Proposal transaction ${journal.transactionId} target is no longer configured.`, "INVALID_FLAG_VALUE");
|
|
1071
|
-
}
|
|
1072
|
-
const bundleId = canonicalBundleIdForTarget(config, target);
|
|
1073
|
-
return { ...target, source: { ...target.source, name: bundleId } };
|
|
1074
|
-
}
|
|
1075
|
-
async function recoverProposalTransactions(target, stashDir, ctx) {
|
|
1076
|
-
const completed = new Map();
|
|
1077
|
-
const nsDir = txnNamespaceDir(target.source.path);
|
|
1078
|
-
if (!fs.existsSync(nsDir))
|
|
1079
|
-
return completed;
|
|
1080
|
-
for (const entry of fs.readdirSync(nsDir, { withFileTypes: true })) {
|
|
1081
|
-
if (!entry.isDirectory())
|
|
1082
|
-
continue;
|
|
1083
|
-
const transactionDir = path.join(nsDir, entry.name);
|
|
1084
|
-
const journalPath = path.join(transactionDir, "journal.json");
|
|
1085
|
-
if (!fs.existsSync(journalPath)) {
|
|
1086
|
-
// Journal-less dirs may be a SIBLING kind's beginTxn window (shared
|
|
1087
|
-
// per-root namespace) — sweep only when demonstrably stale.
|
|
1088
|
-
sweepJournallessTxnDir(transactionDir);
|
|
1089
|
-
continue;
|
|
1090
|
-
}
|
|
1091
|
-
const journal = JSON.parse(fs.readFileSync(journalPath, "utf8"));
|
|
1092
|
-
if (journal.kind !== PROPOSAL_TXN_KIND)
|
|
1093
|
-
continue;
|
|
1094
|
-
if (path.resolve(journal.payload.stashDir) !== path.resolve(stashDir))
|
|
1095
|
-
continue;
|
|
1096
|
-
if (journal.version !== 1 ||
|
|
1097
|
-
canonicalTxnRoot(journal.root) !== canonicalTxnRoot(target.source.path) ||
|
|
1098
|
-
journal.payload.targetSource !== target.source.name ||
|
|
1099
|
-
journal.payload.targetKind !== target.source.kind) {
|
|
1100
|
-
throw new Error(`Refusing unsafe proposal transaction journal at ${journalPath}.`);
|
|
1101
|
-
}
|
|
1102
|
-
fenceProposalTxnJournal(journal, transactionDir, target.source.path);
|
|
1103
|
-
const txn = { journal, journalPath, dir: transactionDir };
|
|
1104
|
-
if (journal.phase === "prepared") {
|
|
1105
|
-
rollbackPreparedProposalTransaction(txn);
|
|
1106
|
-
}
|
|
1107
|
-
else if (journal.phase !== "committed") {
|
|
1108
|
-
const proposal = getProposal(stashDir, journal.payload.proposalId, ctx);
|
|
1109
|
-
completed.set(journal.payload.proposalId, await finalizeProposalTransaction(txn, target, proposal, ctx));
|
|
1110
|
-
}
|
|
1111
|
-
else {
|
|
1112
|
-
completed.set(journal.payload.proposalId, getProposal(stashDir, journal.payload.proposalId, ctx));
|
|
1113
|
-
}
|
|
1114
|
-
cleanupProposalPublication(journal.payload);
|
|
1115
|
-
cleanupTxn(transactionDir);
|
|
1116
|
-
}
|
|
1117
|
-
return completed;
|
|
1118
|
-
}
|
|
1119
|
-
export async function recoverProposalTransactionsForStash(stashDir, config, ctx, proposalId) {
|
|
1120
|
-
const completed = new Map();
|
|
1121
|
-
const matches = listTxnJournals((j) => j.kind === PROPOSAL_TXN_KIND &&
|
|
1122
|
-
path.resolve(j.payload.stashDir) === path.resolve(stashDir) &&
|
|
1123
|
-
(proposalId === undefined || j.payload.proposalId === proposalId));
|
|
1124
|
-
const irreversible = matches.filter((journal) => journal.phase !== "prepared" && journal.phase !== "committed");
|
|
1125
|
-
if (proposalId !== undefined && irreversible.length > 1) {
|
|
1126
|
-
throw new Error(`Conflicting durable proposal transactions exist for ${proposalId}; refusing recovery.`);
|
|
1127
|
-
}
|
|
1128
|
-
const recoveredRoots = new Set();
|
|
1129
|
-
for (const journal of matches) {
|
|
1130
|
-
let target = resolveProposalRecoveryTarget(config, journal);
|
|
1131
|
-
const requiresGitPublication = matches.some((candidate) => candidate.phase === "asset-published" && canonicalTxnRoot(candidate.root) === canonicalTxnRoot(journal.root));
|
|
1132
|
-
if (requiresGitPublication)
|
|
1133
|
-
target = prepareWriteTargetForMutation(target, { allowAhead: true });
|
|
1134
|
-
if (canonicalTxnRoot(target.source.path) !== canonicalTxnRoot(journal.root) ||
|
|
1135
|
-
journal.payload.targetKind !== target.source.kind) {
|
|
1136
|
-
throw new Error(`Proposal transaction ${journal.transactionId} is bound to a different target root.`);
|
|
1137
|
-
}
|
|
1138
|
-
const key = path.resolve(target.source.path);
|
|
1139
|
-
if (recoveredRoots.has(key))
|
|
1140
|
-
continue;
|
|
1141
|
-
const recovered = await recoverProposalTransactions(target, stashDir, ctx);
|
|
1142
|
-
for (const [id, proposal] of recovered)
|
|
1143
|
-
completed.set(id, proposal);
|
|
1144
|
-
recoveredRoots.add(key);
|
|
1145
|
-
}
|
|
1146
|
-
return completed;
|
|
1147
|
-
}
|
|
1148
|
-
const REJECT_TXN_KIND = "proposal-reject";
|
|
1149
|
-
const REJECT_TXN_PHASES = ["prepared", "state-persisted", "event-finalized", "committed"];
|
|
1150
|
-
function finalizeRejectTransaction(txn, ctx) {
|
|
1151
|
-
const p = txn.journal.payload;
|
|
1152
|
-
const decidedAt = txn.journal.decidedAt;
|
|
1153
|
-
let proposal = getProposal(p.stashDir, p.proposalId, ctx);
|
|
1154
|
-
if (txn.journal.phase === "prepared") {
|
|
1155
|
-
if (proposal.status === "pending") {
|
|
1156
|
-
proposal = archiveProposal(p.stashDir, p.proposalId, "rejected", p.reason, { ...ctx, now: () => Date.parse(decidedAt) }, p.gateDecision);
|
|
1157
|
-
}
|
|
1158
|
-
else if (proposal.status !== "rejected") {
|
|
1159
|
-
throw new Error(`Proposal ${p.proposalId} changed status during rejection (${proposal.status}).`);
|
|
1160
|
-
}
|
|
1161
|
-
advanceTxn(txn, "state-persisted");
|
|
1162
|
-
txnMutationHook("reject-state-persisted");
|
|
1163
|
-
}
|
|
1164
|
-
if (txn.journal.phase === "state-persisted") {
|
|
1165
|
-
proposal = getProposal(p.stashDir, p.proposalId, ctx);
|
|
1166
|
-
const eventRef = proposal.ref;
|
|
1167
|
-
const eventMeta = {
|
|
1168
|
-
proposalId: proposal.id,
|
|
1169
|
-
source: proposal.source,
|
|
1170
|
-
...(proposal.sourceRun !== undefined ? { sourceRun: proposal.sourceRun } : {}),
|
|
1171
|
-
...(p.reason !== undefined ? { reason: p.reason } : {}),
|
|
1172
|
-
proposalTransactionId: txn.journal.transactionId,
|
|
1173
|
-
};
|
|
1174
|
-
withProposalsDb(p.stashDir, ctx, (db) => withImmediateTransaction(db, () => {
|
|
1175
|
-
insertEventOnce(db, {
|
|
1176
|
-
eventType: "rejected",
|
|
1177
|
-
ts: decidedAt,
|
|
1178
|
-
ref: eventRef,
|
|
1179
|
-
metadata: eventMeta,
|
|
1180
|
-
idempotencyKey: txn.journal.transactionId,
|
|
1181
|
-
});
|
|
1182
|
-
}));
|
|
1183
|
-
txnMutationHook("reject-event-persisted");
|
|
1184
|
-
advanceTxn(txn, "event-finalized");
|
|
1185
|
-
}
|
|
1186
|
-
if (txn.journal.phase === "event-finalized")
|
|
1187
|
-
advanceTxn(txn, "committed");
|
|
1188
|
-
return proposal;
|
|
1189
|
-
}
|
|
1190
|
-
function recoverRejectTransaction(stashDir, proposalId, ctx) {
|
|
1191
|
-
const nsDir = txnNamespaceDir(stashDir);
|
|
1192
|
-
if (!fs.existsSync(nsDir))
|
|
1193
|
-
return undefined;
|
|
1194
|
-
for (const entry of fs.readdirSync(nsDir, { withFileTypes: true })) {
|
|
1195
|
-
if (!entry.isDirectory())
|
|
1196
|
-
continue;
|
|
1197
|
-
const transactionDir = path.join(nsDir, entry.name);
|
|
1198
|
-
const journalPath = path.join(transactionDir, "journal.json");
|
|
1199
|
-
if (!fs.existsSync(journalPath))
|
|
1200
|
-
continue;
|
|
1201
|
-
const journal = JSON.parse(fs.readFileSync(journalPath, "utf8"));
|
|
1202
|
-
if (journal.kind !== REJECT_TXN_KIND)
|
|
1203
|
-
continue;
|
|
1204
|
-
if (journal.payload.proposalId !== proposalId)
|
|
1205
|
-
continue;
|
|
1206
|
-
if (journal.version !== 1 || path.resolve(journal.payload.stashDir) !== path.resolve(stashDir)) {
|
|
1207
|
-
throw new Error(`Refusing unsafe proposal rejection journal at ${journalPath}.`);
|
|
1208
|
-
}
|
|
1209
|
-
const proposal = finalizeRejectTransaction({ journal, journalPath, dir: transactionDir }, ctx);
|
|
1210
|
-
cleanupTxn(transactionDir);
|
|
1211
|
-
return proposal;
|
|
1212
|
-
}
|
|
1213
|
-
return undefined;
|
|
1214
|
-
}
|
|
1215
|
-
export function rejectProposalDurably(stashDir, proposalId, reason, ctx, gateDecision) {
|
|
1216
|
-
const recovered = recoverRejectTransaction(stashDir, proposalId, ctx);
|
|
1217
|
-
if (recovered)
|
|
1218
|
-
return recovered;
|
|
1219
|
-
const proposal = getProposal(stashDir, proposalId, ctx);
|
|
1220
|
-
if (proposal.status !== "pending") {
|
|
1221
|
-
throw new UsageError(`Proposal ${proposalId} is not pending (current status: ${proposal.status}). Only pending proposals can be rejected.`, "INVALID_FLAG_VALUE");
|
|
1222
|
-
}
|
|
1223
|
-
const txn = beginTxn({
|
|
1224
|
-
kind: REJECT_TXN_KIND,
|
|
1225
|
-
root: stashDir,
|
|
1226
|
-
changes: [],
|
|
1227
|
-
payload: {
|
|
1228
|
-
proposalId,
|
|
1229
|
-
stashDir,
|
|
1230
|
-
...(reason !== undefined ? { reason } : {}),
|
|
1231
|
-
...(gateDecision ? { gateDecision } : {}),
|
|
1232
|
-
},
|
|
1233
|
-
decidedAt: nowIso(ctx),
|
|
1234
|
-
});
|
|
1235
|
-
const rejected = finalizeRejectTransaction(txn, ctx);
|
|
1236
|
-
cleanupTxn(txn.dir);
|
|
728
|
+
}));
|
|
1237
729
|
return rejected;
|
|
1238
730
|
}
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
assertAkmAssetWrite(target.source);
|
|
1242
|
-
const assetPath = resolveAssetFilePathSafe(target.source, ref);
|
|
1243
|
-
if (!assetPath)
|
|
1244
|
-
throw new Error(`Cannot resolve proposal target ${proposal.ref}.`);
|
|
731
|
+
/** Write `content` atomically (temp file + rename), keeping an existing file's mode. */
|
|
732
|
+
function writeProposalAssetFile(assetPath, content) {
|
|
1245
733
|
fs.mkdirSync(path.dirname(assetPath), { recursive: true });
|
|
1246
|
-
const
|
|
1247
|
-
const
|
|
1248
|
-
|
|
1249
|
-
// and the initial `prepared` journal must be written exactly ONCE with its
|
|
1250
|
-
// final contents (crash runners intercept the first rename per phase).
|
|
1251
|
-
const transactionId = mintTxnId();
|
|
1252
|
-
const gitPublication = captureGitPublication(target);
|
|
1253
|
-
const transactionDir = txnDirFor(target.source.path, transactionId);
|
|
1254
|
-
fs.mkdirSync(transactionDir, { recursive: true, mode: 0o700 });
|
|
1255
|
-
const contentPath = path.join(transactionDir, "published-content");
|
|
1256
|
-
const publishPath = path.join(path.dirname(assetPath), `.akm-proposal-${transactionId}.publish`);
|
|
1257
|
-
const displacedPath = path.join(path.dirname(assetPath), `.akm-proposal-${transactionId}.displaced`);
|
|
1258
|
-
fs.writeFileSync(contentPath, normalized, { encoding: "utf8", mode: 0o600 });
|
|
1259
|
-
fsyncTxnFile(contentPath);
|
|
1260
|
-
let persistedBackupPath = null;
|
|
1261
|
-
if (options.backup) {
|
|
1262
|
-
const backupPath = path.join(transactionDir, "backup-content");
|
|
1263
|
-
fs.writeFileSync(backupPath, options.backup, { mode: 0o600 });
|
|
1264
|
-
fsyncTxnFile(backupPath);
|
|
1265
|
-
persistedBackupPath = backupPath;
|
|
1266
|
-
}
|
|
1267
|
-
const txn = beginTxn({
|
|
1268
|
-
kind: PROPOSAL_TXN_KIND,
|
|
1269
|
-
root: target.source.path,
|
|
1270
|
-
transactionId,
|
|
1271
|
-
changes: [
|
|
1272
|
-
{
|
|
1273
|
-
path: assetPath,
|
|
1274
|
-
op: options.originalHash === null ? "create" : "update",
|
|
1275
|
-
beforeHash: options.originalHash,
|
|
1276
|
-
afterHash: publishedHash,
|
|
1277
|
-
},
|
|
1278
|
-
],
|
|
1279
|
-
payload: {
|
|
1280
|
-
operation: options.operation,
|
|
1281
|
-
proposalId: proposal.id,
|
|
1282
|
-
stashDir,
|
|
1283
|
-
targetSource: target.source.name,
|
|
1284
|
-
targetKind: target.source.kind,
|
|
1285
|
-
assetPath,
|
|
1286
|
-
ref: proposal.ref,
|
|
1287
|
-
contentPath,
|
|
1288
|
-
publishPath,
|
|
1289
|
-
displacedPath,
|
|
1290
|
-
backupPath: persistedBackupPath,
|
|
1291
|
-
originalHash: options.originalHash,
|
|
1292
|
-
publishedHash,
|
|
1293
|
-
...(gitPublication ? { gitPublication } : {}),
|
|
1294
|
-
...(options.eventMetadata ? { eventMetadata: options.eventMetadata } : {}),
|
|
1295
|
-
...(options.gateDecision ? { gateDecision: options.gateDecision } : {}),
|
|
1296
|
-
},
|
|
1297
|
-
decidedAt: nowIso(ctx),
|
|
1298
|
-
});
|
|
734
|
+
const mode = fs.existsSync(assetPath) ? fs.statSync(assetPath).mode & 0o777 : 0o644;
|
|
735
|
+
const tempPath = path.join(path.dirname(assetPath), `.akm-proposal-${process.pid}-${randomUUID()}.tmp`);
|
|
736
|
+
fs.writeFileSync(tempPath, content, { encoding: "utf8", mode });
|
|
1299
737
|
try {
|
|
1300
|
-
|
|
1301
|
-
fs.writeFileSync(publishPath, normalized, { encoding: "utf8", flag: "wx", mode });
|
|
1302
|
-
fsyncTxnFile(publishPath);
|
|
1303
|
-
fsyncTxnDir(path.dirname(assetPath));
|
|
738
|
+
fs.renameSync(tempPath, assetPath);
|
|
1304
739
|
}
|
|
1305
740
|
catch (error) {
|
|
1306
|
-
|
|
1307
|
-
cleanupTxn(txn.dir);
|
|
741
|
+
fs.rmSync(tempPath, { force: true });
|
|
1308
742
|
throw error;
|
|
1309
743
|
}
|
|
1310
|
-
|
|
744
|
+
recordWrittenPath(assetPath);
|
|
1311
745
|
}
|
|
1312
|
-
|
|
1313
|
-
|
|
746
|
+
/** Index the file just written; the next `akm index` catches up when this cannot. */
|
|
747
|
+
async function indexWrittenProposalAsset(target, assetPath) {
|
|
1314
748
|
try {
|
|
1315
|
-
if (
|
|
1316
|
-
|
|
1317
|
-
if (proposalFileHash(p.displacedPath) !== p.originalHash) {
|
|
1318
|
-
fs.renameSync(p.displacedPath, p.assetPath);
|
|
1319
|
-
throw new Error(`Proposal target changed while its backup was being acquired.`);
|
|
1320
|
-
}
|
|
749
|
+
if (!(await indexWrittenAssets(target.source.path, [assetPath], { bundleId: target.source.name }))) {
|
|
750
|
+
warn(`[proposals] ${assetPath} was written but not indexed; run \`akm index\`.`);
|
|
1321
751
|
}
|
|
1322
|
-
fs.linkSync(p.publishPath, p.assetPath);
|
|
1323
|
-
// #652: the accepted-proposal (and revert) target is the run's headline
|
|
1324
|
-
// write — journal it the instant the asset lands, before the txn advances.
|
|
1325
|
-
recordWrittenPath(p.assetPath);
|
|
1326
|
-
fsyncTxnDir(path.dirname(p.assetPath));
|
|
1327
|
-
const snapshot = captureWriteTargetPathSnapshot(target, p.assetPath);
|
|
1328
|
-
if (snapshot)
|
|
1329
|
-
p.gitSnapshots = { [snapshot.path]: snapshot.state };
|
|
1330
|
-
advanceTxn(txn, "asset-published");
|
|
1331
752
|
}
|
|
1332
753
|
catch (error) {
|
|
1333
|
-
|
|
1334
|
-
cleanupTxn(txn.dir);
|
|
1335
|
-
throw error;
|
|
754
|
+
warn(`[proposals] ${assetPath} was written but not indexed (${error instanceof Error ? error.message : String(error)}); run \`akm index\`.`);
|
|
1336
755
|
}
|
|
1337
756
|
}
|
|
1338
757
|
function resolveRecordedProposalTarget(config, proposalId, binding, explicitTarget) {
|
|
@@ -1353,125 +772,71 @@ function resolveRecordedProposalTarget(config, proposalId, binding, explicitTarg
|
|
|
1353
772
|
return { ...target, source: { ...target.source, name: targetBundleId } };
|
|
1354
773
|
}
|
|
1355
774
|
function resolveProposalWriteTarget(config, proposal, explicitTarget, queueTarget) {
|
|
775
|
+
const named = (target) => ({
|
|
776
|
+
...target,
|
|
777
|
+
source: { ...target.source, name: canonicalBundleIdForTarget(config, target) },
|
|
778
|
+
});
|
|
1356
779
|
if (!proposal.proposedTarget) {
|
|
1357
780
|
const identity = proposalRefIdentity(proposal.ref);
|
|
1358
781
|
if (!identity)
|
|
1359
782
|
throw new UsageError(`Proposal ${proposal.id} has an invalid ref.`, "INVALID_PROPOSAL");
|
|
1360
783
|
if (identity.bundle !== undefined) {
|
|
1361
|
-
const target = resolveBundleWriteTarget(config, identity.bundle);
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
if (canonicalBundleIdForTarget(config, explicit) !== identity.bundle) {
|
|
1366
|
-
throw new UsageError(`Proposal ${proposal.id} ref is bound to bundle "${identity.bundle}", which conflicts with --target "${explicitTarget}".`, "INVALID_FLAG_VALUE");
|
|
1367
|
-
}
|
|
784
|
+
const target = named(resolveBundleWriteTarget(config, identity.bundle));
|
|
785
|
+
if (explicitTarget !== undefined &&
|
|
786
|
+
canonicalBundleIdForTarget(config, resolveWriteTarget(config, explicitTarget)) !== identity.bundle) {
|
|
787
|
+
throw new UsageError(`Proposal ${proposal.id} ref is bound to bundle "${identity.bundle}", which conflicts with --target "${explicitTarget}".`, "INVALID_FLAG_VALUE");
|
|
1368
788
|
}
|
|
1369
789
|
if (queueTarget && canonicalBundleIdForTarget(config, queueTarget) !== identity.bundle) {
|
|
1370
790
|
throw new UsageError(`Proposal ${proposal.id} is bound to a different queue target.`, "INVALID_FLAG_VALUE");
|
|
1371
791
|
}
|
|
1372
|
-
return
|
|
792
|
+
return target;
|
|
1373
793
|
}
|
|
1374
794
|
const target = explicitTarget ? resolveWriteTarget(config, explicitTarget) : queueTarget;
|
|
1375
795
|
if (!target) {
|
|
1376
796
|
throw new UsageError(`Unbound short proposal ${proposal.id} requires an explicit --target or authenticated --queue context.`, "INVALID_PROPOSAL");
|
|
1377
797
|
}
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
throw new UsageError(`Proposal ${proposal.id} is bound to a different queue target.`, "INVALID_FLAG_VALUE");
|
|
1386
|
-
}
|
|
798
|
+
return named(target);
|
|
799
|
+
}
|
|
800
|
+
if (queueTarget &&
|
|
801
|
+
explicitTarget === undefined &&
|
|
802
|
+
(canonicalBundleIdForTarget(config, queueTarget) !== proposal.proposedTarget.source ||
|
|
803
|
+
path.resolve(queueTarget.source.path) !== path.resolve(proposal.proposedTarget.root))) {
|
|
804
|
+
throw new UsageError(`Proposal ${proposal.id} is bound to a different queue target.`, "INVALID_FLAG_VALUE");
|
|
1387
805
|
}
|
|
1388
806
|
return resolveRecordedProposalTarget(config, proposal.id, proposal.proposedTarget, explicitTarget);
|
|
1389
807
|
}
|
|
1390
|
-
// ──
|
|
1391
|
-
//
|
|
1392
|
-
//
|
|
1393
|
-
//
|
|
1394
|
-
// `
|
|
1395
|
-
// state.db. This section projects it onto the written asset's frontmatter at
|
|
1396
|
-
// promotion time, AKM-native assets only (an OKF-adapter target never reaches
|
|
1397
|
-
// this function — `assertAkmAssetWrite` rejects it earlier in
|
|
1398
|
-
// `promoteProposalWithLease`, before any of this runs).
|
|
1399
|
-
//
|
|
1400
|
-
// Two DISTINCT actors, deliberately not conflated (documented judgment call —
|
|
1401
|
-
// see the PR body for the alternative considered and rejected):
|
|
1402
|
-
// - `generated.by` answers "what produced the CONTENT" — keyed on
|
|
1403
|
-
// `isAutomatedProposalSource(proposal.source)`: an automated pipeline
|
|
1404
|
-
// (reflect/distill/consolidate/extract/improve/schema-repair) stamps
|
|
1405
|
-
// `akm/<pkgVersion>`; a human-initiated source (propose/remember/import)
|
|
1406
|
-
// or the semi-automated `feedback` source stamps `human:<actorId>`.
|
|
1407
|
-
// - `verified[0].by` answers "what accepted/reviewed THIS promotion" —
|
|
1408
|
-
// keyed on whether a `gateDecision` was supplied to THIS call: present
|
|
1409
|
-
// (the automated drain/triage path decided) stamps `akm/<pkgVersion>`;
|
|
1410
|
-
// absent (a human explicitly ran `akm proposal accept`) stamps
|
|
1411
|
-
// `human:<actorId>`. Every promotion reaches this function via exactly
|
|
1412
|
-
// one of those two paths, so `verified` is always stamped — there is no
|
|
1413
|
-
// third, unreviewed path to disk.
|
|
1414
|
-
/** 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. */
|
|
808
|
+
// ── OKF v0.2 provenance on promotion (#730) ─────────────────────────────────
|
|
809
|
+
// `generated.by` names what produced the content (an automated source is
|
|
810
|
+
// `akm/<version>`, anything else `human:<actor>`); `verified[].by` names what
|
|
811
|
+
// accepted this promotion (a gate decision is `akm/<version>`, a direct
|
|
812
|
+
// `akm proposal accept` is `human:<actor>`). Only AKM-native targets get here.
|
|
1415
813
|
function resolveActorId(ctx) {
|
|
1416
814
|
if (ctx?.actorId)
|
|
1417
815
|
return ctx.actorId();
|
|
1418
816
|
try {
|
|
1419
|
-
|
|
1420
|
-
return username ? username : "local";
|
|
817
|
+
return os.userInfo().username?.trim() || "local";
|
|
1421
818
|
}
|
|
1422
819
|
catch {
|
|
1423
820
|
return "local";
|
|
1424
821
|
}
|
|
1425
822
|
}
|
|
1426
|
-
/** `generated.by` — keyed on the SOURCE that produced the content (see file-header note above). */
|
|
1427
|
-
function generatedByActor(proposal, ctx) {
|
|
1428
|
-
return isAutomatedProposalSource(proposal.source) ? `akm/${pkgVersion}` : `human:${resolveActorId(ctx)}`;
|
|
1429
|
-
}
|
|
1430
|
-
/** `verified[0].by` — keyed on whether THIS promotion was gated (automated) or a direct human accept (see file-header note above). */
|
|
1431
|
-
function verifiedByActor(gateDecision, ctx) {
|
|
1432
|
-
return gateDecision !== undefined ? `akm/${pkgVersion}` : `human:${resolveActorId(ctx)}`;
|
|
1433
|
-
}
|
|
1434
|
-
/** True for a plain (non-null, non-array) object. */
|
|
1435
823
|
function isPlainRecord(value) {
|
|
1436
824
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1437
825
|
}
|
|
1438
826
|
/**
|
|
1439
|
-
* Stamp
|
|
1440
|
-
*
|
|
1441
|
-
*
|
|
1442
|
-
* -
|
|
1443
|
-
*
|
|
1444
|
-
* has any pre-existing AKM consumer, so spelling them the spec's way costs
|
|
1445
|
-
* nothing and makes `okf-support.md`'s "AKM Markdown is an OKF-compatible
|
|
1446
|
-
* superset" positioning actually true for trust metadata: a third-party OKF
|
|
1447
|
-
* v0.2 reader pointed at an AKM stash sees conformant provenance.
|
|
1448
|
-
* - `sources` stays namespaced under `provenance:`, because a bare top-level
|
|
1449
|
-
* `sources:` genuinely collides with the pre-existing AKM-native wiki
|
|
1450
|
-
* citation-**string** convention
|
|
1451
|
-
* (`indexer/passes/metadata.ts#applyWikiFrontmatter`, which silently drops
|
|
1452
|
-
* non-strings) on a promoted wiki page.
|
|
1453
|
-
*
|
|
1454
|
-
* The read side back into `IndexDocument.provenance` is
|
|
1455
|
-
* `metadata.ts#applyProvenanceFrontmatter` (which accepts both this shape and
|
|
1456
|
-
* the older fully-nested one), carried through `akm-adapter.ts`'s
|
|
1457
|
-
* `DOCUMENT_JSON_CARRIED_FIELDS` (D2.4).
|
|
1458
|
-
*
|
|
1459
|
-
* A no-op frontmatter mutation preserves the existing frontmatter block's raw
|
|
1460
|
-
* body bytes (mirrors `frontmatter.ts#mutateFrontmatter`'s documented
|
|
1461
|
-
* contract) rather than reshaping via `assembleAsset`, which would strip
|
|
1462
|
-
* leading body blank lines / force a trailing newline. A file with no
|
|
1463
|
-
* frontmatter block at all (non-conformant input) gains one via
|
|
1464
|
-
* `assembleAsset`, exactly as any other first-frontmatter write would.
|
|
827
|
+
* Stamp provenance onto a promoted asset's frontmatter: bare top-level
|
|
828
|
+
* `generated` and `verified` (as OKF v0.2 spells them; `verified` accumulates),
|
|
829
|
+
* and `sources` under `provenance:`, since a bare `sources:` is the wiki
|
|
830
|
+
* citation-string convention. An existing frontmatter block keeps its raw body
|
|
831
|
+
* bytes.
|
|
1465
832
|
*/
|
|
1466
833
|
function stampProposalProvenance(content, proposal, gateDecision, ctx, nowIsoStr) {
|
|
1467
834
|
const parsed = parseFrontmatter(content);
|
|
1468
835
|
const fm = { ...parsed.data };
|
|
1469
836
|
const existingProvenance = isPlainRecord(fm.provenance) ? fm.provenance : {};
|
|
1470
|
-
|
|
1471
|
-
fm.generated = { by:
|
|
1472
|
-
//
|
|
1473
|
-
// than the newest overwriting the record. Both the bare list and the older
|
|
1474
|
-
// nested spelling are absorbed, so a re-promotion never loses history.
|
|
837
|
+
const human = () => `human:${resolveActorId(ctx)}`;
|
|
838
|
+
fm.generated = { by: isAutomatedProposalSource(proposal.source) ? `akm/${pkgVersion}` : human(), at: nowIsoStr };
|
|
839
|
+
// The older nested `provenance.verified` spelling is absorbed too, so history is never lost.
|
|
1475
840
|
const priorVerified = Array.isArray(fm.verified)
|
|
1476
841
|
? fm.verified
|
|
1477
842
|
: Array.isArray(existingProvenance.verified)
|
|
@@ -1479,18 +844,14 @@ function stampProposalProvenance(content, proposal, gateDecision, ctx, nowIsoStr
|
|
|
1479
844
|
: [];
|
|
1480
845
|
fm.verified = [
|
|
1481
846
|
...priorVerified,
|
|
1482
|
-
{ by:
|
|
847
|
+
{ by: gateDecision !== undefined ? `akm/${pkgVersion}` : human(), at: gateDecision?.decidedAt ?? nowIsoStr },
|
|
1483
848
|
];
|
|
1484
|
-
// `sources` alone stays namespaced — bare `sources:` is the wiki
|
|
1485
|
-
// citation-string convention. Drop the nested provenance block entirely when
|
|
1486
|
-
// it would otherwise be empty, so unrelated assets gain no dead key.
|
|
1487
849
|
const provenance = { ...existingProvenance };
|
|
1488
850
|
delete provenance.generatedBy;
|
|
1489
851
|
delete provenance.generatedAt;
|
|
1490
852
|
delete provenance.verified;
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
const sources = evidenceSources
|
|
853
|
+
if (Array.isArray(fm.evidenceSources)) {
|
|
854
|
+
const sources = fm.evidenceSources
|
|
1494
855
|
.filter((s) => typeof s === "string" && s.trim().length > 0)
|
|
1495
856
|
.map((resource) => ({ resource: resource.trim() }));
|
|
1496
857
|
if (sources.length > 0)
|
|
@@ -1505,16 +866,9 @@ function stampProposalProvenance(content, proposal, gateDecision, ctx, nowIsoStr
|
|
|
1505
866
|
: assembleAsset(fm, parsed.content);
|
|
1506
867
|
}
|
|
1507
868
|
/**
|
|
1508
|
-
* Validate
|
|
1509
|
-
*
|
|
1510
|
-
* `
|
|
1511
|
-
* Validation failures throw a `UsageError` carrying every finding so the CLI
|
|
1512
|
-
* can render a single clear error envelope.
|
|
1513
|
-
*
|
|
1514
|
-
* Phase 6C: when the target asset already exists at the resolved write path,
|
|
1515
|
-
* its prior content is captured BEFORE the write and stored on the archived
|
|
1516
|
-
* proposal record (`backupContent`) so `akm proposal revert` can restore it.
|
|
1517
|
-
* Genuinely-new assets carry no backup.
|
|
869
|
+
* Validate, stamp and write an accepted proposal into its bound target, then
|
|
870
|
+
* archive it as accepted. Overwriting an existing asset keeps its prior content
|
|
871
|
+
* on the row (`backupContent`) for `akm proposal revert`.
|
|
1518
872
|
*/
|
|
1519
873
|
export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
|
|
1520
874
|
return withAssetMutationLease("proposal-accept", () => promoteProposalWithLease(stashDir, config, id, options, ctx));
|
|
@@ -1526,7 +880,7 @@ function promotionLintBlockers(raw, assetPath, targetRoot, refType, config) {
|
|
|
1526
880
|
if (refType === "task") {
|
|
1527
881
|
try {
|
|
1528
882
|
const parsed = parseYaml(raw);
|
|
1529
|
-
data =
|
|
883
|
+
data = isPlainRecord(parsed) ? parsed : {};
|
|
1530
884
|
}
|
|
1531
885
|
catch {
|
|
1532
886
|
data = {};
|
|
@@ -1538,9 +892,6 @@ function promotionLintBlockers(raw, assetPath, targetRoot, refType, config) {
|
|
|
1538
892
|
({ data, content: body, frontmatter } = parseFrontmatter(raw));
|
|
1539
893
|
}
|
|
1540
894
|
const resolvedRoot = path.resolve(targetRoot);
|
|
1541
|
-
const extraStashRoots = resolveSourceEntries(targetRoot, config)
|
|
1542
|
-
.map((source) => source.path)
|
|
1543
|
-
.filter((sourcePath) => path.resolve(sourcePath) !== resolvedRoot);
|
|
1544
895
|
return runBaseChecks({
|
|
1545
896
|
filePath: assetPath,
|
|
1546
897
|
relPath: path.relative(targetRoot, assetPath),
|
|
@@ -1550,140 +901,165 @@ function promotionLintBlockers(raw, assetPath, targetRoot, refType, config) {
|
|
|
1550
901
|
frontmatter,
|
|
1551
902
|
fix: false,
|
|
1552
903
|
stashRoot: targetRoot,
|
|
1553
|
-
extraStashRoots,
|
|
904
|
+
extraStashRoots: resolveSourceEntries(targetRoot, config)
|
|
905
|
+
.map((source) => source.path)
|
|
906
|
+
.filter((sourcePath) => path.resolve(sourcePath) !== resolvedRoot),
|
|
1554
907
|
}).filter((finding) => PROMOTION_LINT_ISSUE_TYPES.has(finding.issue));
|
|
1555
908
|
}
|
|
1556
|
-
/**
|
|
909
|
+
/** The exact stamped bytes a promotion would publish, validated, without writing. */
|
|
1557
910
|
export function preflightProposalPromotion(config, proposal, options = {}, ctx) {
|
|
1558
911
|
const repairedContent = repairProposalContent(proposalContent(proposal));
|
|
1559
|
-
const
|
|
1560
|
-
const report = validateProposal(
|
|
912
|
+
const prepared = repairedContent === proposalContent(proposal) ? proposal : withProposalContent(proposal, repairedContent);
|
|
913
|
+
const report = validateProposal(prepared);
|
|
1561
914
|
if (!report.ok) {
|
|
1562
|
-
|
|
1563
|
-
throw new UsageError(`Proposal ${proposal.id} failed validation:\n${message}`, "MISSING_REQUIRED_ARGUMENT", "Fix the proposal payload (frontmatter / content) and try again, or reject the proposal with a reason.");
|
|
915
|
+
throw new UsageError(`Proposal ${proposal.id} failed validation:\n${report.findings.map((f) => `[${f.kind}] ${f.message}`).join("\n")}`, "MISSING_REQUIRED_ARGUMENT", "Fix the proposal payload (frontmatter / content) and try again, or reject the proposal with a reason.");
|
|
1564
916
|
}
|
|
1565
|
-
const ref = parseRefInput(
|
|
917
|
+
const ref = parseRefInput(prepared.ref);
|
|
1566
918
|
if (!stashDirFor(ref.type)) {
|
|
1567
919
|
throw new UsageError(`Proposal ${proposal.id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
|
|
1568
920
|
}
|
|
1569
|
-
const target = resolveProposalWriteTarget(config,
|
|
921
|
+
const target = resolveProposalWriteTarget(config, prepared, options.target, options.queueTarget);
|
|
1570
922
|
const assetPath = resolveAssetFilePathSafe(target.source, ref);
|
|
1571
923
|
if (!assetPath)
|
|
1572
|
-
throw new UsageError(`Cannot resolve proposal target ${
|
|
924
|
+
throw new UsageError(`Cannot resolve proposal target ${prepared.ref}.`, "INVALID_PROPOSAL");
|
|
1573
925
|
assertAkmAssetWrite(target.source);
|
|
1574
|
-
const
|
|
1575
|
-
|
|
926
|
+
const markdown = assetPath.toLowerCase().endsWith(".md");
|
|
927
|
+
let stampedContent = markdown
|
|
928
|
+
? stampProposalProvenance(repairedContent, prepared, options.gateDecision, ctx, nowIso(ctx))
|
|
1576
929
|
: repairedContent;
|
|
930
|
+
if (markdown && fs.existsSync(assetPath)) {
|
|
931
|
+
// Keep the live target's bookkeeping frontmatter the proposal doesn't set
|
|
932
|
+
// (STALE, R20) — dropping `inferenceProcessed` would re-run inference.
|
|
933
|
+
try {
|
|
934
|
+
stampedContent = carryForwardBookkeepingFrontmatter(stampedContent, fs.readFileSync(assetPath, "utf8"));
|
|
935
|
+
}
|
|
936
|
+
catch {
|
|
937
|
+
// best-effort; the freshness check is the real staleness gate
|
|
938
|
+
}
|
|
939
|
+
}
|
|
1577
940
|
const lintBlockers = promotionLintBlockers(stampedContent, assetPath, target.source.path, ref.type, config);
|
|
1578
941
|
if (lintBlockers.length > 0) {
|
|
1579
942
|
const summary = lintBlockers.map((finding) => `[${finding.issue}] ${finding.detail}`).join("; ");
|
|
1580
943
|
warn(`[proposal] promotion lint for ${proposal.id} found (non-blocking): ${summary}`);
|
|
1581
944
|
}
|
|
1582
|
-
return { proposal:
|
|
945
|
+
return { proposal: prepared, repairedContent, ref, target, assetPath, stampedContent };
|
|
1583
946
|
}
|
|
1584
947
|
/**
|
|
1585
|
-
* The
|
|
1586
|
-
*
|
|
1587
|
-
*
|
|
1588
|
-
*
|
|
1589
|
-
*
|
|
1590
|
-
|
|
1591
|
-
|
|
948
|
+
* The target's current bytes, provided it is still the one the proposal was
|
|
949
|
+
* minted against (STALE, R20): compared bookkeeping-insensitively when the
|
|
950
|
+
* proposal carries a normalized before-hash, exactly otherwise. A target that
|
|
951
|
+
* already holds this proposal's content is a promotion that wrote but did not
|
|
952
|
+
* record — finishing it is allowed. Never overwrites newer content.
|
|
953
|
+
*/
|
|
954
|
+
export function readFreshProposalTarget(proposal, assetPath, stampedContent) {
|
|
955
|
+
const current = fs.existsSync(assetPath) ? fs.readFileSync(assetPath) : undefined;
|
|
956
|
+
if (proposal.beforeHash !== undefined) {
|
|
957
|
+
const fresh = current !== undefined &&
|
|
958
|
+
(proposal.beforeHashNormalized !== undefined
|
|
959
|
+
? contentHash(current, "normalized") === proposal.beforeHashNormalized
|
|
960
|
+
: contentHash(current) === proposal.beforeHash);
|
|
961
|
+
const alreadyPublished = current !== undefined && contentHash(current, "normalized") === contentHash(stampedContent, "normalized");
|
|
962
|
+
if (!fresh && !alreadyPublished) {
|
|
963
|
+
throw new UsageError(`Proposal target changed after proposal ${proposal.id} was created; refusing to overwrite newer content.`, "INVALID_FLAG_VALUE");
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
else if (current !== undefined && proposal.changes.some((change) => change.op === "create")) {
|
|
967
|
+
throw new UsageError(`Proposal target was created after proposal ${proposal.id}; refusing to overwrite newer content.`, "INVALID_FLAG_VALUE");
|
|
968
|
+
}
|
|
969
|
+
return current;
|
|
970
|
+
}
|
|
971
|
+
/** The recorded asset path of an accepted/reverted proposal, provided `target` is the same binding. */
|
|
972
|
+
function acceptedAssetPath(proposal, target, ref) {
|
|
973
|
+
const accepted = proposal.acceptedTarget;
|
|
974
|
+
const assetPath = resolveAssetFilePathSafe(target.source, ref);
|
|
975
|
+
if (!accepted ||
|
|
976
|
+
!assetPath ||
|
|
977
|
+
accepted.source !== target.source.name ||
|
|
978
|
+
path.resolve(accepted.root) !== path.resolve(target.source.path) ||
|
|
979
|
+
path.resolve(accepted.path) !== path.resolve(assetPath)) {
|
|
980
|
+
throw new UsageError(`proposal ${proposal.id} is bound to a different accepted target`, "INVALID_FLAG_VALUE");
|
|
981
|
+
}
|
|
982
|
+
return assetPath;
|
|
983
|
+
}
|
|
984
|
+
function requireAcceptedTarget(proposal) {
|
|
985
|
+
if (!proposal.acceptedTarget) {
|
|
986
|
+
const label = proposal.status === "reverted" ? "Reverted" : "Accepted";
|
|
987
|
+
throw new UsageError(`${label} proposal ${proposal.id} has no recorded target.`, "INVALID_PROPOSAL");
|
|
988
|
+
}
|
|
989
|
+
return proposal.acceptedTarget;
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* O1 (alpha.9): an accepted consolidate PROMOTION retires its source memory
|
|
993
|
+
* (and its `.derived` twin), so promotion no longer leaves a memory/
|
|
994
|
+
* knowledge duplicate behind. Runs whether a person accepted the promotion
|
|
995
|
+
* or triage auto-promotion did — this is called from inside
|
|
996
|
+
* `promoteProposalWithLease`'s ordinary accept path, below the drain/CLI
|
|
997
|
+
* layer, so both routes hit it the same way. Best-effort: a failure here
|
|
998
|
+
* only warns — the promotion itself already succeeded and is not undone —
|
|
999
|
+
* and a source already gone (raced with something else, or never existed)
|
|
1000
|
+
* is silently skipped, not an error.
|
|
1592
1001
|
*
|
|
1593
|
-
*
|
|
1594
|
-
*
|
|
1595
|
-
*
|
|
1596
|
-
*
|
|
1597
|
-
*
|
|
1598
|
-
*
|
|
1599
|
-
*
|
|
1600
|
-
* placement type. The legacy resolver treats an unrecognized type prefix as
|
|
1601
|
-
* "not a locally-checkable ref, skip it, never flag missing" (whole-hearted
|
|
1602
|
-
* leniency for cross-bundle / foreign-format refs); this module's core
|
|
1603
|
-
* resolver additionally tries the ref as a literal on-disk path — the
|
|
1604
|
-
* resolution non-akm adapters (OKF, llm-wiki) actually NEED for their own
|
|
1605
|
-
* same-component conceptIds — which means it CAN report `missing-ref` for a
|
|
1606
|
-
* foreign-typed prose ref the legacy checker always let through. Promoting a
|
|
1607
|
-
* proposal is a live, user-facing write path; blocking it on a diagnostic
|
|
1608
|
-
* that can disagree with the existing (already-tested, already-run)
|
|
1609
|
-
* `promotionLintBlockers` gate a few lines above is not a change to make
|
|
1610
|
-
* without a dedicated equivalence pass first. So: this computes and surfaces
|
|
1611
|
-
* the finding (visible via `warn`, and never thrown) without changing whether
|
|
1612
|
-
* ANY promotion succeeds or fails — proving the wiring end-to-end on real
|
|
1613
|
-
* proposal data while leaving today's blocking behavior completely
|
|
1614
|
-
* untouched. Never throws: a validate() failure here must not corrupt or
|
|
1615
|
-
* half-apply the transaction that follows.
|
|
1002
|
+
* B3: the promotion was queued against the source's content as it stood at
|
|
1003
|
+
* mint time (`accepted.promotionSourceHash`). If the source was edited since
|
|
1004
|
+
* — the freshest edit is exactly what a person would not want silently
|
|
1005
|
+
* discarded into the archive — this only warns and leaves the source alone;
|
|
1006
|
+
* the promotion itself still stands. A proposal minted before this field
|
|
1007
|
+
* existed carries no hash at all, so it is treated the same way: never
|
|
1008
|
+
* archived, not verified against a hash that was never recorded.
|
|
1616
1009
|
*/
|
|
1617
|
-
|
|
1010
|
+
function retirePromotionSource(mutationTarget, accepted) {
|
|
1011
|
+
if (!accepted.promotionSource)
|
|
1012
|
+
return;
|
|
1618
1013
|
try {
|
|
1619
|
-
const
|
|
1620
|
-
const
|
|
1621
|
-
if (!
|
|
1014
|
+
const sourceRef = parseRefInput(accepted.promotionSource);
|
|
1015
|
+
const typeDir = stashDirFor(sourceRef.type);
|
|
1016
|
+
if (!typeDir)
|
|
1017
|
+
return;
|
|
1018
|
+
const sourcePath = assetPathForName(sourceRef.type, path.join(mutationTarget.source.path, typeDir), sourceRef.name);
|
|
1019
|
+
if (!fs.existsSync(sourcePath))
|
|
1020
|
+
return;
|
|
1021
|
+
if (!accepted.promotionSourceHash) {
|
|
1022
|
+
warn(`[proposal] O1: ${accepted.id} has no recorded source hash (minted by an older release) — leaving its source ${accepted.promotionSource} unarchived.`);
|
|
1622
1023
|
return;
|
|
1623
|
-
|
|
1624
|
-
const
|
|
1625
|
-
if (
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1024
|
+
}
|
|
1025
|
+
const currentHash = contentHash(fs.readFileSync(sourcePath, "utf8"), "body");
|
|
1026
|
+
if (currentHash !== accepted.promotionSourceHash) {
|
|
1027
|
+
warn(`[proposal] O1: source ${accepted.promotionSource} for ${accepted.id} changed since the promotion was queued — leaving it unarchived.`);
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
const candidate = {
|
|
1031
|
+
ref: accepted.promotionSource,
|
|
1032
|
+
reason: "promoted",
|
|
1033
|
+
proposalId: accepted.id,
|
|
1034
|
+
successorRefs: [accepted.ref],
|
|
1631
1035
|
};
|
|
1632
|
-
const
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1036
|
+
const record = archiveCleanupCandidate(mutationTarget.source.path, candidate, sourcePath);
|
|
1037
|
+
const paths = [
|
|
1038
|
+
sourcePath,
|
|
1039
|
+
path.join(mutationTarget.source.path, record.archivedPath),
|
|
1040
|
+
path.join(mutationTarget.source.path, record.auditPath),
|
|
1041
|
+
];
|
|
1042
|
+
const twin = derivedTwinPath(sourcePath, sourceRef.type);
|
|
1043
|
+
if (twin) {
|
|
1044
|
+
const twinRecord = archiveCleanupCandidate(mutationTarget.source.path, candidate, twin);
|
|
1045
|
+
paths.push(twin, path.join(mutationTarget.source.path, twinRecord.archivedPath), path.join(mutationTarget.source.path, twinRecord.auditPath));
|
|
1640
1046
|
}
|
|
1047
|
+
commitWriteTargetBoundary(mutationTarget, `Retire promoted source ${accepted.promotionSource}`, { paths });
|
|
1641
1048
|
}
|
|
1642
1049
|
catch (error) {
|
|
1643
|
-
|
|
1644
|
-
// promotion transaction that follows.
|
|
1645
|
-
warn(`[proposal] pre-commit adapter check for ${preflight.proposal.id} threw (ignored, non-blocking): ${error instanceof Error ? error.message : String(error)}`);
|
|
1050
|
+
warn(`[proposal] O1: failed to retire promotion source ${accepted.promotionSource} for ${accepted.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1646
1051
|
}
|
|
1647
1052
|
}
|
|
1648
1053
|
async function promoteProposalWithLease(stashDir, config, id, options, ctx) {
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
const proposalToValidate = repairedContent === proposalContent(proposal) ? proposal : withProposalContent(proposal, repairedContent);
|
|
1653
|
-
const report = validateProposal(proposalToValidate);
|
|
1654
|
-
if (!report.ok) {
|
|
1655
|
-
const message = report.findings.map((finding) => `[${finding.kind}] ${finding.message}`).join("\n");
|
|
1656
|
-
throw new UsageError(`Proposal ${id} failed validation:\n${message}`, "MISSING_REQUIRED_ARGUMENT", "Fix the proposal payload (frontmatter / content) and try again, or reject the proposal with a reason.");
|
|
1657
|
-
}
|
|
1658
|
-
const ref = parseRefInput(proposalToValidate.ref);
|
|
1659
|
-
if (!stashDirFor(ref.type)) {
|
|
1660
|
-
throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
|
|
1661
|
-
}
|
|
1662
|
-
// Use the (possibly repaired) payload for the promotion write. Persist the
|
|
1663
|
-
// repaired content back onto the DB row so the audit trail reflects the
|
|
1664
|
-
// final promoted payload (not the defective original).
|
|
1665
|
-
if (repairedContent !== proposalContent(proposal)) {
|
|
1666
|
-
withProposalsDb(stashDir, ctx, (db) => {
|
|
1667
|
-
upsertProposal(db, proposalToValidate, stashDir);
|
|
1668
|
-
});
|
|
1669
|
-
}
|
|
1670
|
-
await recoverProposalTransactionsForStash(stashDir, config, ctx, id);
|
|
1671
|
-
proposal = getProposal(stashDir, id, ctx);
|
|
1054
|
+
const proposal = getProposal(stashDir, id, ctx);
|
|
1055
|
+
if (isRetireProposal(proposal))
|
|
1056
|
+
return retireProposalWithLease(stashDir, config, proposal, options, ctx);
|
|
1672
1057
|
const target = resolveProposalWriteTarget(config, proposal, options.target, options.queueTarget);
|
|
1673
1058
|
if (proposal.status === "accepted") {
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
if (proposal.acceptedTarget.source !== target.source.name ||
|
|
1679
|
-
path.resolve(proposal.acceptedTarget.root) !== path.resolve(target.source.path) ||
|
|
1680
|
-
!assetPath ||
|
|
1681
|
-
path.resolve(proposal.acceptedTarget.path) !== path.resolve(assetPath)) {
|
|
1682
|
-
throw new UsageError(`proposal ${id} is bound to a different accepted target`, "INVALID_FLAG_VALUE");
|
|
1683
|
-
}
|
|
1684
|
-
if (!assetPath ||
|
|
1685
|
-
!fs.existsSync(assetPath) ||
|
|
1686
|
-
proposalFileHash(assetPath) !== proposal.acceptedTarget.contentHash) {
|
|
1059
|
+
// Accepting again is a no-op, provided the published bytes are still there.
|
|
1060
|
+
const recorded = requireAcceptedTarget(proposal);
|
|
1061
|
+
const assetPath = acceptedAssetPath(proposal, target, parseRefInput(proposal.ref));
|
|
1062
|
+
if (!fs.existsSync(assetPath) || contentHash(fs.readFileSync(assetPath)) !== recorded.contentHash) {
|
|
1687
1063
|
throw new UsageError(`Accepted proposal ${id} does not match the current asset content.`, "INVALID_FLAG_VALUE");
|
|
1688
1064
|
}
|
|
1689
1065
|
return { proposal, assetPath, ref: proposal.ref };
|
|
@@ -1692,177 +1068,543 @@ async function promoteProposalWithLease(stashDir, config, id, options, ctx) {
|
|
|
1692
1068
|
throw new UsageError(`Proposal ${id} is not pending (current status: ${proposal.status}). Only pending proposals can be accepted.`, "INVALID_FLAG_VALUE");
|
|
1693
1069
|
}
|
|
1694
1070
|
const preflight = preflightProposalPromotion(config, proposal, { ...options, queueTarget: target }, ctx);
|
|
1695
|
-
await runAdapterPreCommitCheck(config, preflight);
|
|
1696
1071
|
const mutationTarget = prepareWriteTargetForMutation(target);
|
|
1697
|
-
const assetPath = resolveAssetFilePathSafe(mutationTarget.source, ref);
|
|
1072
|
+
const assetPath = resolveAssetFilePathSafe(mutationTarget.source, preflight.ref);
|
|
1698
1073
|
if (!assetPath)
|
|
1699
1074
|
throw new UsageError(`Cannot resolve proposal target ${proposal.ref}.`, "INVALID_PROPOSAL");
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1075
|
+
const backup = readFreshProposalTarget(proposal, assetPath, preflight.stampedContent);
|
|
1076
|
+
assertAkmAssetWrite(mutationTarget.source);
|
|
1077
|
+
const refIdentity = proposalRefIdentity(preflight.proposal.ref);
|
|
1078
|
+
const proposalForMutation = refIdentity?.bundle === undefined
|
|
1079
|
+
? { ...preflight.proposal, ref: `${target.source.name}//${refIdentity?.conceptId ?? ""}` }
|
|
1080
|
+
: preflight.proposal;
|
|
1081
|
+
const decidedAt = nowIso(ctx);
|
|
1082
|
+
const content = preflight.stampedContent.endsWith("\n") ? preflight.stampedContent : `${preflight.stampedContent}\n`;
|
|
1083
|
+
writeProposalAssetFile(assetPath, content);
|
|
1084
|
+
commitWriteTargetBoundary(mutationTarget, `Update ${proposalForMutation.ref}`, { paths: [assetPath] });
|
|
1085
|
+
const accepted = persistProposalDecision(stashDir, proposalForMutation, {
|
|
1086
|
+
operation: "accept",
|
|
1087
|
+
target: mutationTarget,
|
|
1088
|
+
assetPath,
|
|
1089
|
+
content,
|
|
1090
|
+
existed: backup !== undefined,
|
|
1091
|
+
...(backup !== undefined ? { backupContent: backup.toString("utf8") } : {}),
|
|
1092
|
+
...(options.eventMetadata ? { eventMetadata: options.eventMetadata } : {}),
|
|
1093
|
+
...(options.gateDecision ? { gateDecision: options.gateDecision } : {}),
|
|
1094
|
+
decidedAt,
|
|
1095
|
+
}, ctx);
|
|
1096
|
+
await indexWrittenProposalAsset(mutationTarget, assetPath);
|
|
1097
|
+
if (accepted.status === "accepted" && accepted.source === "consolidate")
|
|
1098
|
+
retirePromotionSource(mutationTarget, accepted);
|
|
1099
|
+
return { proposal: accepted, assetPath, ref: accepted.ref };
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* Every archive dir a tombstone under `.akm/memory-cleanup/archive/` claims
|
|
1103
|
+
* for `proposalId` — the primary asset's, and its `.derived` twin's if one
|
|
1104
|
+
* was archived alongside it. Used to detect what a resumed retire accept
|
|
1105
|
+
* (should-fix 5) has already moved.
|
|
1106
|
+
*/
|
|
1107
|
+
function findRetireArchiveDirsByProposalId(stashRoot, proposalId) {
|
|
1108
|
+
const archiveRoot = path.join(stashRoot, ".akm", "memory-cleanup", "archive");
|
|
1109
|
+
let entries;
|
|
1110
|
+
try {
|
|
1111
|
+
entries = fs.readdirSync(archiveRoot);
|
|
1112
|
+
}
|
|
1113
|
+
catch {
|
|
1114
|
+
return undefined;
|
|
1115
|
+
}
|
|
1116
|
+
const dirs = [];
|
|
1117
|
+
for (const name of entries) {
|
|
1118
|
+
let data;
|
|
1703
1119
|
try {
|
|
1704
|
-
|
|
1120
|
+
data = parseFrontmatter(fs.readFileSync(path.join(archiveRoot, name, "cleanup.md"), "utf8")).data;
|
|
1705
1121
|
}
|
|
1706
|
-
catch
|
|
1707
|
-
|
|
1122
|
+
catch {
|
|
1123
|
+
continue;
|
|
1708
1124
|
}
|
|
1125
|
+
if (data.proposalId === proposalId)
|
|
1126
|
+
dirs.push(path.relative(stashRoot, path.join(archiveRoot, name)));
|
|
1709
1127
|
}
|
|
1710
|
-
|
|
1711
|
-
|
|
1128
|
+
return dirs.length > 0 ? dirs : undefined;
|
|
1129
|
+
}
|
|
1130
|
+
/** The absolute original paths a set of archive dirs' own tombstones claim — for resume detection. */
|
|
1131
|
+
function alreadyArchivedOriginalPaths(stashRoot, dirs) {
|
|
1132
|
+
const paths = new Set();
|
|
1133
|
+
for (const dirRel of dirs) {
|
|
1134
|
+
try {
|
|
1135
|
+
const data = parseFrontmatter(fs.readFileSync(path.join(stashRoot, dirRel, "cleanup.md"), "utf8")).data;
|
|
1136
|
+
if (typeof data.originalPath === "string")
|
|
1137
|
+
paths.add(path.resolve(stashRoot, data.originalPath));
|
|
1138
|
+
}
|
|
1139
|
+
catch {
|
|
1140
|
+
// An unreadable tombstone just is not counted "already done" — the move below re-attempts that file.
|
|
1141
|
+
}
|
|
1712
1142
|
}
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1143
|
+
return paths;
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Should-fix 5: record a retire accept's intent — `backupContent` and which
|
|
1147
|
+
* files (asset, `.derived` twin) are about to move — on the still-pending
|
|
1148
|
+
* proposal BEFORE any file is moved. A crash after this point resumes from
|
|
1149
|
+
* exactly this record instead of re-deriving `backupContent` from whatever
|
|
1150
|
+
* is on disk afterward, or from the archived copy, which for a `supersedes`
|
|
1151
|
+
* judgement already carries the edge the accept itself is about to write.
|
|
1152
|
+
*/
|
|
1153
|
+
function recordRetireAcceptIntent(stashDir, proposalId, intent, ctx) {
|
|
1154
|
+
return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
|
|
1155
|
+
const current = requireProposal(db, stashDir, proposalId);
|
|
1156
|
+
if (current.retireAcceptIntent)
|
|
1157
|
+
return current;
|
|
1158
|
+
const next = { ...current, retireAcceptIntent: intent };
|
|
1159
|
+
upsertProposal(db, next, stashDir);
|
|
1160
|
+
return next;
|
|
1161
|
+
}));
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* Persist a retire's "accepted" decision — the row, its ledger decision and
|
|
1165
|
+
* its event — the one finalize step a fresh accept and one resumed after a
|
|
1166
|
+
* crash (should-fix 5) share: by the time either calls it, every file move
|
|
1167
|
+
* is already confirmed done. Mirrors the accept branch of
|
|
1168
|
+
* {@link persistProposalDecision}, kept separate since a retire's
|
|
1169
|
+
* accepted-shape fields (`retiredArchive`, no published `content`) do not
|
|
1170
|
+
* fit that function's create/update-shaped `decision` union.
|
|
1171
|
+
*/
|
|
1172
|
+
function persistRetireAcceptance(stashDir, proposal, info, ctx) {
|
|
1173
|
+
const decidedAt = nowIso(ctx);
|
|
1174
|
+
return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
|
|
1175
|
+
const current = requireProposal(db, stashDir, proposal.id);
|
|
1176
|
+
if (current.status === "accepted")
|
|
1177
|
+
return current;
|
|
1178
|
+
if (current.status !== "pending") {
|
|
1179
|
+
throw new Error(`Proposal ${proposal.id} changed status during acceptance (${current.status}).`);
|
|
1180
|
+
}
|
|
1181
|
+
const next = {
|
|
1182
|
+
...proposal,
|
|
1183
|
+
retireAcceptIntent: undefined, // finalized — the intent only matters while still pending
|
|
1184
|
+
status: "accepted",
|
|
1185
|
+
updatedAt: decidedAt,
|
|
1186
|
+
review: { outcome: "accepted", decidedAt },
|
|
1187
|
+
acceptedTarget: {
|
|
1188
|
+
source: info.targetName,
|
|
1189
|
+
root: info.targetRoot,
|
|
1190
|
+
path: info.assetPath,
|
|
1191
|
+
contentHash: info.contentHash,
|
|
1192
|
+
},
|
|
1193
|
+
retiredArchive: { dirs: info.archiveDirs },
|
|
1194
|
+
backupContent: info.backupContent,
|
|
1195
|
+
...(info.gateDecision
|
|
1196
|
+
? { gateDecision: { ...info.gateDecision, decidedAt: info.gateDecision.decidedAt ?? decidedAt } }
|
|
1197
|
+
: {}),
|
|
1198
|
+
};
|
|
1199
|
+
upsertProposal(db, next, stashDir);
|
|
1200
|
+
recordImproveLedgerDecision(db, {
|
|
1201
|
+
proposalId: next.id,
|
|
1202
|
+
stashDir,
|
|
1203
|
+
ref: next.ref,
|
|
1204
|
+
source: next.source,
|
|
1205
|
+
outcome: "accepted",
|
|
1206
|
+
at: decidedAt,
|
|
1207
|
+
});
|
|
1208
|
+
insertEventOnce(db, {
|
|
1209
|
+
eventType: "promoted",
|
|
1210
|
+
ts: decidedAt,
|
|
1211
|
+
ref: next.ref,
|
|
1212
|
+
metadata: {
|
|
1213
|
+
proposalId: next.id,
|
|
1214
|
+
source: next.source,
|
|
1215
|
+
...(next.sourceRun !== undefined ? { sourceRun: next.sourceRun } : {}),
|
|
1216
|
+
assetPath: info.assetPath,
|
|
1217
|
+
retired: true,
|
|
1218
|
+
...(info.eventMetadata ? info.eventMetadata : {}),
|
|
1219
|
+
},
|
|
1220
|
+
idempotencyKey: `${next.id}:promoted`,
|
|
1221
|
+
});
|
|
1222
|
+
return next;
|
|
1223
|
+
}));
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* B2: throws a stale-retire `UsageError` unless the successor still exists
|
|
1227
|
+
* and both sides' recorded body hashes still match their current files — the
|
|
1228
|
+
* durable half of the chain guard. Used for a fresh accept, and (4b, third
|
|
1229
|
+
* review round) to re-check a resumed accept whose intent was recorded but
|
|
1230
|
+
* nothing has moved yet: a separate proposal accepted in between (e.g. this
|
|
1231
|
+
* one's successor itself retired by a B->C accept) can make the decision
|
|
1232
|
+
* stale even though nothing about the retired side's own file changed.
|
|
1233
|
+
*/
|
|
1234
|
+
function assertRetirementStillFresh(proposalId, proposalRef, retirement, targetSource, retiredCurrentBytes) {
|
|
1235
|
+
const successorPath = resolveAssetFilePathSafe(targetSource, parseRefInput(retirement.successorRef));
|
|
1236
|
+
const successorBytes = successorPath && fs.existsSync(successorPath) ? fs.readFileSync(successorPath) : undefined;
|
|
1237
|
+
const retiredFresh = contentHash(retiredCurrentBytes, "body") === retirement.retiredContentHash;
|
|
1238
|
+
const successorFresh = successorBytes !== undefined && contentHash(successorBytes, "body") === retirement.successorContentHash;
|
|
1239
|
+
if (!successorBytes || !retiredFresh || !successorFresh) {
|
|
1240
|
+
throw new UsageError(`Retire proposal ${proposalId} is stale — successor ${retirement.successorRef} ` +
|
|
1241
|
+
`${successorBytes === undefined ? "no longer exists" : !successorFresh ? "changed" : `and ${proposalRef} changed`} ` +
|
|
1242
|
+
"since judging; refusing to retire.", "INVALID_FLAG_VALUE");
|
|
1717
1243
|
}
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
const
|
|
1738
|
-
const
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1244
|
+
}
|
|
1245
|
+
/**
|
|
1246
|
+
* Accept a `retire` proposal (0.9.17-alpha.9, the consolidate pair pass): no
|
|
1247
|
+
* new content is written. Should-fix 5 (second review round) makes this a
|
|
1248
|
+
* three-phase, resume-safe sequence: (1) record intent — `backupContent`
|
|
1249
|
+
* and the exact files about to move — on the still-pending proposal; (2)
|
|
1250
|
+
* move each file into the recoverable cleanup archive
|
|
1251
|
+
* (`archiveCleanupCandidate`), skipping any the tombstone scan shows a prior,
|
|
1252
|
+
* crashed attempt already moved; (3) finalize via
|
|
1253
|
+
* {@link persistRetireAcceptance}. A `supersedes` judgement writes the
|
|
1254
|
+
* supersede edge on the retired (older) side AFTER intent is recorded (4a,
|
|
1255
|
+
* third review round — recording it first means a crash before the edge
|
|
1256
|
+
* write can never cause a resume to re-read the file and capture its own
|
|
1257
|
+
* edge into `backupContent`), so the archived copy still preserves it. A
|
|
1258
|
+
* target already gone with no recorded intent (raced with something else)
|
|
1259
|
+
* fails cleanly with a `UsageError`, the same clean-error idiom every other
|
|
1260
|
+
* staleness check in this file uses — never an unhandled throw.
|
|
1261
|
+
*/
|
|
1262
|
+
async function retireProposalWithLease(stashDir, config, proposal, options, ctx) {
|
|
1263
|
+
const target = resolveProposalWriteTarget(config, proposal, options.target, options.queueTarget);
|
|
1264
|
+
const ref = parseRefInput(proposal.ref);
|
|
1265
|
+
if (proposal.status === "accepted") {
|
|
1266
|
+
// Accepting again is a no-op, provided the target is still gone (retired) and its archive is still there.
|
|
1267
|
+
const recorded = requireAcceptedTarget(proposal);
|
|
1268
|
+
const assetPath = acceptedAssetPath(proposal, target, ref);
|
|
1269
|
+
const archive = proposal.retiredArchive;
|
|
1270
|
+
const archivedStill = archive?.dirs.every((dir) => fs.existsSync(path.join(target.source.path, dir))) === true;
|
|
1271
|
+
if (fs.existsSync(assetPath) || !archivedStill) {
|
|
1272
|
+
throw new UsageError(`Accepted retire proposal ${proposal.id} no longer matches its archive.`, "INVALID_FLAG_VALUE");
|
|
1273
|
+
}
|
|
1274
|
+
return { proposal, assetPath: recorded.path, ref: proposal.ref };
|
|
1275
|
+
}
|
|
1276
|
+
if (proposal.status !== "pending") {
|
|
1277
|
+
throw new UsageError(`Proposal ${proposal.id} is not pending (current status: ${proposal.status}). Only pending proposals can be accepted.`, "INVALID_FLAG_VALUE");
|
|
1278
|
+
}
|
|
1279
|
+
const assetPath = resolveAssetFilePathSafe(target.source, ref);
|
|
1280
|
+
if (!assetPath)
|
|
1281
|
+
throw new UsageError(`Cannot resolve proposal target ${proposal.ref}.`, "INVALID_PROPOSAL");
|
|
1282
|
+
let working = proposal;
|
|
1283
|
+
let intent = proposal.retireAcceptIntent;
|
|
1284
|
+
if (!intent) {
|
|
1285
|
+
// Fresh accept — no recorded intent yet, so the target must still be there.
|
|
1286
|
+
if (!fs.existsSync(assetPath)) {
|
|
1287
|
+
throw new UsageError(`Retire proposal ${proposal.id} target (${proposal.ref}) no longer exists — it may already have been retired, promoted away, or removed by another proposal.`, "INVALID_FLAG_VALUE");
|
|
1288
|
+
}
|
|
1289
|
+
const currentBytes = fs.readFileSync(assetPath);
|
|
1290
|
+
const retirement = proposal.retirement;
|
|
1291
|
+
if (!retirement) {
|
|
1292
|
+
// createRetireProposal always sets this — a row without one is corrupt, not merely stale.
|
|
1293
|
+
throw new Error(`Retire proposal ${proposal.id} has no retirement metadata.`);
|
|
1294
|
+
}
|
|
1295
|
+
// B2 (this is the durable half of the chain guard; the same-run half is
|
|
1296
|
+
// `retiredThisRun` in pair-pass.ts):
|
|
1297
|
+
assertRetirementStillFresh(proposal.id, proposal.ref, retirement, target.source, currentBytes);
|
|
1298
|
+
assertAkmAssetWrite(target.source);
|
|
1299
|
+
// Phase 1: record intent BEFORE any move, and BEFORE the supersede edge
|
|
1300
|
+
// (4a, third review round) — `backupContent` is `currentBytes`, read
|
|
1301
|
+
// above, before any mutation of this file. Recording first means a crash
|
|
1302
|
+
// between here and the edge write below can never cause a resume to
|
|
1303
|
+
// re-read the file and capture the edge INTO backupContent as if it were
|
|
1304
|
+
// the original.
|
|
1305
|
+
intent = { assetPath, backupContent: currentBytes.toString("utf8") };
|
|
1306
|
+
working = recordRetireAcceptIntent(stashDir, proposal.id, intent, ctx);
|
|
1307
|
+
if (retirement.judgeLabel === "supersedes") {
|
|
1308
|
+
try {
|
|
1309
|
+
writeSupersededEdge(assetPath, retirement.successorRef);
|
|
1310
|
+
}
|
|
1311
|
+
catch (error) {
|
|
1312
|
+
warn(`[proposal] failed to write the supersede edge for ${proposal.id} (continuing with the retire): ${error instanceof Error ? error.message : String(error)}`);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
else {
|
|
1317
|
+
assertAkmAssetWrite(target.source);
|
|
1318
|
+
// 4b (third review round): intent was recorded but Phase 2 never moved
|
|
1319
|
+
// anything yet — re-run the B2 freshness check before resuming. A
|
|
1320
|
+
// separate proposal accepted in the meantime (this one's successor
|
|
1321
|
+
// itself retired by a B->C accept) can make the decision stale even
|
|
1322
|
+
// though nothing here changed. Once something has moved, it is too late
|
|
1323
|
+
// to cleanly refuse — Phase 2 below already tolerates a partial move.
|
|
1324
|
+
if (working.retirement && fs.existsSync(intent.assetPath)) {
|
|
1325
|
+
assertRetirementStillFresh(proposal.id, proposal.ref, working.retirement, target.source, fs.readFileSync(intent.assetPath));
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
// Phase 2: move, idempotently — a resumed call skips whichever file a
|
|
1329
|
+
// tombstone under this proposal's own id already claims.
|
|
1330
|
+
const mutationTarget = prepareWriteTargetForMutation(target);
|
|
1331
|
+
const already = findRetireArchiveDirsByProposalId(mutationTarget.source.path, proposal.id) ?? [];
|
|
1332
|
+
const alreadyDone = alreadyArchivedOriginalPaths(mutationTarget.source.path, already);
|
|
1333
|
+
const archiveDirs = [...already];
|
|
1334
|
+
const paths = [];
|
|
1335
|
+
const candidate = {
|
|
1336
|
+
ref: proposal.ref,
|
|
1337
|
+
reason: working.retirement?.reason ?? "duplicate",
|
|
1338
|
+
proposalId: proposal.id,
|
|
1339
|
+
...(working.retirement?.successorRef ? { successorRefs: [working.retirement.successorRef] } : {}),
|
|
1340
|
+
};
|
|
1341
|
+
if (!alreadyDone.has(path.resolve(intent.assetPath)) && fs.existsSync(intent.assetPath)) {
|
|
1342
|
+
const record = archiveCleanupCandidate(mutationTarget.source.path, candidate, intent.assetPath);
|
|
1343
|
+
archiveDirs.push(path.dirname(record.auditPath));
|
|
1344
|
+
paths.push(intent.assetPath, path.join(mutationTarget.source.path, record.archivedPath), path.join(mutationTarget.source.path, record.auditPath));
|
|
1345
|
+
}
|
|
1346
|
+
// 4d (third review round): the twin path is re-derived, not carried on the
|
|
1347
|
+
// intent — it is a pure function of assetPath and the ref's type, and the
|
|
1348
|
+
// tombstone scan above (`alreadyDone`) already finds one archived earlier,
|
|
1349
|
+
// so storing it was redundant persisted state.
|
|
1350
|
+
const twinPath = derivedTwinPath(intent.assetPath, ref.type);
|
|
1351
|
+
if (twinPath && !alreadyDone.has(path.resolve(twinPath)) && fs.existsSync(twinPath)) {
|
|
1352
|
+
const twinRecord = archiveCleanupCandidate(mutationTarget.source.path, candidate, twinPath);
|
|
1353
|
+
archiveDirs.push(path.dirname(twinRecord.auditPath));
|
|
1354
|
+
paths.push(twinPath, path.join(mutationTarget.source.path, twinRecord.archivedPath), path.join(mutationTarget.source.path, twinRecord.auditPath));
|
|
1355
|
+
}
|
|
1356
|
+
if (paths.length > 0)
|
|
1357
|
+
commitWriteTargetBoundary(mutationTarget, `Retire ${proposal.ref}`, { paths });
|
|
1358
|
+
if (archiveDirs.length === 0) {
|
|
1359
|
+
// Recorded intent, but neither file is at its original location NOR
|
|
1360
|
+
// archived under this proposal's id: something else removed the target
|
|
1361
|
+
// between intent and move. Refuse cleanly rather than finalize on
|
|
1362
|
+
// nothing.
|
|
1363
|
+
throw new UsageError(`Retire proposal ${proposal.id} target (${proposal.ref}) no longer exists and was not archived by this proposal — refusing to accept.`, "INVALID_FLAG_VALUE");
|
|
1364
|
+
}
|
|
1365
|
+
// Phase 3: finalize — always from the recorded intent's own backupContent,
|
|
1366
|
+
// never a hash guessed from the archived copy.
|
|
1367
|
+
const accepted = persistRetireAcceptance(stashDir, working, {
|
|
1368
|
+
targetName: mutationTarget.source.name,
|
|
1369
|
+
targetRoot: mutationTarget.source.path,
|
|
1370
|
+
assetPath: intent.assetPath,
|
|
1371
|
+
contentHash: contentHash(intent.backupContent),
|
|
1372
|
+
archiveDirs,
|
|
1373
|
+
backupContent: intent.backupContent,
|
|
1374
|
+
...(options.gateDecision ? { gateDecision: options.gateDecision } : {}),
|
|
1375
|
+
...(options.eventMetadata ? { eventMetadata: options.eventMetadata } : {}),
|
|
1747
1376
|
}, ctx);
|
|
1748
|
-
|
|
1749
|
-
const accepted = await finalizeProposalTransaction(transaction, mutationTarget, proposalForMutation, ctx);
|
|
1750
|
-
cleanupTxn(transaction.dir);
|
|
1751
|
-
return { proposal: accepted, assetPath: transaction.journal.payload.assetPath, ref: accepted.ref };
|
|
1377
|
+
return { proposal: accepted, assetPath: intent.assetPath, ref: accepted.ref };
|
|
1752
1378
|
}
|
|
1753
1379
|
/**
|
|
1754
|
-
* Restore
|
|
1755
|
-
*
|
|
1756
|
-
*
|
|
1757
|
-
* Pre-conditions:
|
|
1758
|
-
* - `id` resolves to a proposal with `status === "accepted"`.
|
|
1759
|
-
* - The proposal carries `backupContent` (captured by promoteProposal when
|
|
1760
|
-
* the target asset existed before the write).
|
|
1761
|
-
*
|
|
1762
|
-
* On success:
|
|
1763
|
-
* - The backup content is written back through {@link writeAssetToSource},
|
|
1764
|
-
* so the canonical write-dispatch invariant is preserved.
|
|
1765
|
-
* - The proposal record is updated to `status: "reverted"`.
|
|
1766
|
-
* - Caller emits a `proposal_reverted` event in the CLI layer (mirrors how
|
|
1767
|
-
* `promoted` / `rejected` are emitted by the CLI command, not the core).
|
|
1768
|
-
*
|
|
1769
|
-
* Errors are thrown as `UsageError` / `NotFoundError` so the CLI can map them
|
|
1770
|
-
* cleanly to exit codes — see `src/commands/proposal/proposal.ts` for the
|
|
1771
|
-
* wrapper.
|
|
1380
|
+
* Restore an accepted proposal's target from the backup taken at promotion.
|
|
1381
|
+
* New-asset proposals have no backup; a target edited since acceptance is
|
|
1382
|
+
* never clobbered. Reverting twice is a no-op.
|
|
1772
1383
|
*/
|
|
1773
1384
|
export async function revertProposal(stashDir, config, id, options = {}, ctx) {
|
|
1774
1385
|
return withAssetMutationLease("proposal-revert", () => revertProposalWithLease(stashDir, config, id, options, ctx));
|
|
1775
1386
|
}
|
|
1776
|
-
|
|
1777
|
-
|
|
1387
|
+
/**
|
|
1388
|
+
* Revert an accepted `retire` proposal (0.9.17-alpha.9): move the archived
|
|
1389
|
+
* file(s) — the retired asset, and its `.derived` twin when one was archived
|
|
1390
|
+
* alongside it — back to where they lived, then overwrite the primary with
|
|
1391
|
+
* the exact pre-retire bytes `backupContent` recorded at accept (S4) —
|
|
1392
|
+
* byte-exact, so it also undoes any supersede edge accept wrote without
|
|
1393
|
+
* touching one a person had already written, and without appending a
|
|
1394
|
+
* trailing newline the original never had. Each archive dir is located from
|
|
1395
|
+
* `retiredArchive.dirs` (set at accept time) and its own `cleanup.md`
|
|
1396
|
+
* tombstone names the exact paths to restore — no re-scan of every
|
|
1397
|
+
* tombstone in the archive.
|
|
1398
|
+
*
|
|
1399
|
+
* Should-fix 5 (second review round): every archive dir is resolved and
|
|
1400
|
+
* validated before any of them are moved, AND that validation tells "not
|
|
1401
|
+
* yet moved" apart from "already moved by an earlier, crashed attempt of
|
|
1402
|
+
* our own" (original present, archived copy gone) rather than treating the
|
|
1403
|
+
* latter as a conflict — so a retry of a crashed revert resumes instead of
|
|
1404
|
+
* erroring on its own prior work. The archive dirs (tombstones) are removed
|
|
1405
|
+
* only after the "reverted" decision is durably recorded, not interleaved
|
|
1406
|
+
* with the moves — a crash between moving a file and recording the
|
|
1407
|
+
* decision used to delete that file's tombstone first, leaving an
|
|
1408
|
+
* "accepted" proposal a retry could neither finish nor re-validate.
|
|
1409
|
+
*/
|
|
1410
|
+
async function unretireProposalWithLease(stashDir, config, proposal, options, ctx) {
|
|
1778
1411
|
const ref = parseRefInput(proposal.ref);
|
|
1779
1412
|
if (!stashDirFor(ref.type)) {
|
|
1780
|
-
throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
|
|
1413
|
+
throw new UsageError(`Proposal ${proposal.id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
|
|
1414
|
+
}
|
|
1415
|
+
if (proposal.status !== "accepted" && proposal.status !== "reverted") {
|
|
1416
|
+
throw new UsageError(`only accepted proposals can be reverted (proposal ${proposal.id} status: ${proposal.status})`, "INVALID_FLAG_VALUE");
|
|
1781
1417
|
}
|
|
1782
|
-
|
|
1783
|
-
|
|
1418
|
+
const recorded = requireAcceptedTarget(proposal);
|
|
1419
|
+
const boundTarget = resolveRecordedProposalTarget(config, proposal.id, recorded, options.target);
|
|
1420
|
+
const assetPath = acceptedAssetPath(proposal, boundTarget, ref);
|
|
1784
1421
|
if (proposal.status === "reverted") {
|
|
1785
|
-
|
|
1786
|
-
|
|
1422
|
+
return { proposal, assetPath, ref: proposal.ref };
|
|
1423
|
+
}
|
|
1424
|
+
const archive = proposal.retiredArchive;
|
|
1425
|
+
if (!archive || archive.dirs.length === 0) {
|
|
1426
|
+
throw new UsageError(`no archive recorded for this retire proposal (id: ${proposal.id})`, "MISSING_REQUIRED_ARGUMENT", "A retire proposal's archive is recorded at accept time; a proposal missing it cannot be reverted through this path.");
|
|
1427
|
+
}
|
|
1428
|
+
const target = prepareWriteTargetForMutation(boundTarget);
|
|
1429
|
+
const pending = [];
|
|
1430
|
+
for (const dirRel of archive.dirs) {
|
|
1431
|
+
const dirAbs = path.join(target.source.path, dirRel);
|
|
1432
|
+
const auditPath = path.join(dirAbs, "cleanup.md");
|
|
1433
|
+
let tombstoneData;
|
|
1434
|
+
try {
|
|
1435
|
+
tombstoneData = parseFrontmatter(fs.readFileSync(auditPath, "utf8")).data;
|
|
1436
|
+
}
|
|
1437
|
+
catch (error) {
|
|
1438
|
+
throw new UsageError(`Archive for proposal ${proposal.id} is missing its tombstone (${dirRel}); cannot revert: ${error instanceof Error ? error.message : String(error)}`, "INVALID_FLAG_VALUE");
|
|
1439
|
+
}
|
|
1440
|
+
const originalRel = typeof tombstoneData.originalPath === "string" ? tombstoneData.originalPath : undefined;
|
|
1441
|
+
const archivedRel = typeof tombstoneData.archivedPath === "string" ? tombstoneData.archivedPath : undefined;
|
|
1442
|
+
if (!originalRel || !archivedRel) {
|
|
1443
|
+
throw new UsageError(`Archive tombstone for proposal ${proposal.id} (${dirRel}) is malformed.`, "INVALID_FLAG_VALUE");
|
|
1444
|
+
}
|
|
1445
|
+
const originalAbs = path.join(target.source.path, originalRel);
|
|
1446
|
+
const archivedAbs = path.join(target.source.path, archivedRel);
|
|
1447
|
+
const originalExists = fs.existsSync(originalAbs);
|
|
1448
|
+
const archivedExists = fs.existsSync(archivedAbs);
|
|
1449
|
+
if (originalExists && archivedExists) {
|
|
1450
|
+
throw new UsageError(`Cannot revert proposal ${proposal.id}: ${originalRel} already exists (created since retirement); refusing to overwrite it.`, "INVALID_FLAG_VALUE");
|
|
1451
|
+
}
|
|
1452
|
+
if (!originalExists && !archivedExists) {
|
|
1453
|
+
throw new UsageError(`Cannot revert proposal ${proposal.id}: archived copy ${archivedRel} is missing.`, "INVALID_FLAG_VALUE");
|
|
1454
|
+
}
|
|
1455
|
+
// Must-fix 2 (third review round): "original present, archived copy
|
|
1456
|
+
// missing" is not necessarily our own earlier, crashed revert — once a
|
|
1457
|
+
// purge can delete an archived copy on its own, a LATER, unrelated file
|
|
1458
|
+
// can occupy this same path (a new memory reusing a retired one's name),
|
|
1459
|
+
// and the write step below would overwrite it with the retired asset's
|
|
1460
|
+
// pre-retire bytes. Only the primary has a recorded pre-retire hash
|
|
1461
|
+
// (`retirement.retiredContentHash`) to tell the two apart; resume only
|
|
1462
|
+
// when it matches the file actually sitting there, otherwise refuse
|
|
1463
|
+
// exactly as the conflict case above does.
|
|
1464
|
+
if (originalExists && !archivedExists && path.resolve(originalAbs) === path.resolve(assetPath)) {
|
|
1465
|
+
const expectedHash = proposal.retirement?.retiredContentHash;
|
|
1466
|
+
let currentHash;
|
|
1467
|
+
try {
|
|
1468
|
+
currentHash = contentHash(fs.readFileSync(originalAbs, "utf8"), "body");
|
|
1469
|
+
}
|
|
1470
|
+
catch {
|
|
1471
|
+
currentHash = undefined;
|
|
1472
|
+
}
|
|
1473
|
+
if (!expectedHash || currentHash !== expectedHash) {
|
|
1474
|
+
throw new UsageError(`Cannot revert proposal ${proposal.id}: ${originalRel} exists but its content does not match what was retired (its path may have been reused since); refusing to overwrite it.`, "INVALID_FLAG_VALUE");
|
|
1475
|
+
}
|
|
1787
1476
|
}
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1477
|
+
pending.push({ dirAbs, auditPath, originalAbs, archivedAbs, alreadyDone: originalExists });
|
|
1478
|
+
}
|
|
1479
|
+
const restoredPaths = [];
|
|
1480
|
+
let primaryOriginalAbs;
|
|
1481
|
+
for (const p of pending) {
|
|
1482
|
+
if (!p.alreadyDone) {
|
|
1483
|
+
fs.mkdirSync(path.dirname(p.originalAbs), { recursive: true });
|
|
1484
|
+
fs.renameSync(p.archivedAbs, p.originalAbs);
|
|
1485
|
+
recordWrittenPath(p.archivedAbs);
|
|
1486
|
+
recordWrittenPath(p.originalAbs);
|
|
1795
1487
|
}
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1488
|
+
restoredPaths.push(p.originalAbs, p.archivedAbs, p.auditPath);
|
|
1489
|
+
if (path.resolve(p.originalAbs) === path.resolve(assetPath))
|
|
1490
|
+
primaryOriginalAbs = p.originalAbs;
|
|
1491
|
+
}
|
|
1492
|
+
// S4 / nit: overwrite the primary with the EXACT pre-retire bytes recorded
|
|
1493
|
+
// at accept (`backupContent`) — no appended trailing newline either, so
|
|
1494
|
+
// YAML comments, key order, a pre-existing human `supersededBy` edge, and
|
|
1495
|
+
// even the exact absence of a final newline all survive the round trip.
|
|
1496
|
+
// The archived copy just moved back may carry a `supersededBy` edge THIS
|
|
1497
|
+
// accept wrote (a `supersedes` judgement); restoring the recorded original
|
|
1498
|
+
// bytes already removes exactly that edge, so no separate
|
|
1499
|
+
// removeSupersededEdge mutation runs here — one that could not tell "the
|
|
1500
|
+
// edge accept wrote" from "an edge a person had already written" apart,
|
|
1501
|
+
// and would delete either.
|
|
1502
|
+
if (primaryOriginalAbs && proposal.backupContent !== undefined) {
|
|
1503
|
+
writeProposalAssetFile(primaryOriginalAbs, proposal.backupContent);
|
|
1504
|
+
}
|
|
1505
|
+
commitWriteTargetBoundary(target, `Revert ${proposal.ref}`, { paths: restoredPaths });
|
|
1506
|
+
const decidedAt = nowIso(ctx);
|
|
1507
|
+
const reverted = withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
|
|
1508
|
+
const current = requireProposal(db, stashDir, proposal.id);
|
|
1509
|
+
if (current.status === "reverted")
|
|
1510
|
+
return current;
|
|
1511
|
+
const next = {
|
|
1512
|
+
...current,
|
|
1513
|
+
status: "reverted",
|
|
1514
|
+
updatedAt: decidedAt,
|
|
1515
|
+
review: { outcome: "rejected", reason: "reverted: archived asset restored", decidedAt },
|
|
1800
1516
|
};
|
|
1517
|
+
upsertProposal(db, next, stashDir);
|
|
1518
|
+
recordImproveLedgerDecision(db, {
|
|
1519
|
+
proposalId: next.id,
|
|
1520
|
+
stashDir,
|
|
1521
|
+
ref: next.ref,
|
|
1522
|
+
source: next.source,
|
|
1523
|
+
outcome: "rejected",
|
|
1524
|
+
at: decidedAt,
|
|
1525
|
+
detail: "reverted",
|
|
1526
|
+
});
|
|
1527
|
+
insertEventOnce(db, {
|
|
1528
|
+
eventType: "proposal_reverted",
|
|
1529
|
+
ts: decidedAt,
|
|
1530
|
+
ref: next.ref,
|
|
1531
|
+
metadata: { proposalId: next.id, source: next.source, assetPath },
|
|
1532
|
+
idempotencyKey: `${next.id}:reverted`,
|
|
1533
|
+
});
|
|
1534
|
+
return next;
|
|
1535
|
+
}));
|
|
1536
|
+
// Only now — after the decision is durably recorded — remove the archive
|
|
1537
|
+
// dirs (tombstones). See the function doc comment for why this ordering
|
|
1538
|
+
// matters.
|
|
1539
|
+
for (const p of pending) {
|
|
1540
|
+
fs.rmSync(p.dirAbs, { recursive: true, force: true });
|
|
1541
|
+
}
|
|
1542
|
+
try {
|
|
1543
|
+
if (!(await indexWrittenAssets(target.source.path, restoredPaths, { bundleId: target.source.name }))) {
|
|
1544
|
+
warn(`[proposals] ${restoredPaths.join(", ")} were restored but not indexed; run \`akm index\`.`);
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
catch (error) {
|
|
1548
|
+
warn(`[proposals] restored paths were not indexed (${error instanceof Error ? error.message : String(error)}); run \`akm index\`.`);
|
|
1549
|
+
}
|
|
1550
|
+
return { proposal: reverted, assetPath, ref: proposal.ref };
|
|
1551
|
+
}
|
|
1552
|
+
async function revertProposalWithLease(stashDir, config, id, options, ctx) {
|
|
1553
|
+
const proposal = getProposal(stashDir, id, ctx);
|
|
1554
|
+
if (isRetireProposal(proposal))
|
|
1555
|
+
return unretireProposalWithLease(stashDir, config, proposal, options, ctx);
|
|
1556
|
+
const ref = parseRefInput(proposal.ref);
|
|
1557
|
+
if (!stashDirFor(ref.type)) {
|
|
1558
|
+
throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
|
|
1801
1559
|
}
|
|
1802
|
-
if (proposal.status !== "accepted") {
|
|
1560
|
+
if (proposal.status !== "accepted" && proposal.status !== "reverted") {
|
|
1803
1561
|
throw new UsageError(`only accepted proposals can be reverted (proposal ${id} status: ${proposal.status})`, "INVALID_FLAG_VALUE");
|
|
1804
1562
|
}
|
|
1805
1563
|
const backupContent = proposal.backupContent;
|
|
1806
|
-
if (backupContent === undefined) {
|
|
1564
|
+
if (proposal.status === "accepted" && backupContent === undefined) {
|
|
1807
1565
|
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.");
|
|
1808
1566
|
}
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
cleanupTxn(transaction.dir);
|
|
1567
|
+
const recorded = requireAcceptedTarget(proposal);
|
|
1568
|
+
const boundTarget = resolveRecordedProposalTarget(config, id, recorded, options.target);
|
|
1569
|
+
const assetPath = acceptedAssetPath(proposal, boundTarget, ref);
|
|
1570
|
+
if (proposal.status === "reverted" || backupContent === undefined) {
|
|
1571
|
+
return { proposal, assetPath, ref: proposal.ref };
|
|
1572
|
+
}
|
|
1573
|
+
const target = prepareWriteTargetForMutation(boundTarget);
|
|
1574
|
+
if (!fs.existsSync(assetPath) || contentHash(fs.readFileSync(assetPath)) !== recorded.contentHash) {
|
|
1575
|
+
// Nit (third review round): by-ref resolution skips retire proposals
|
|
1576
|
+
// (should-fix 7), so reverting by ref when a SEPARATE retire proposal
|
|
1577
|
+
// for the same ref exists (pending or already accepted) lands here with
|
|
1578
|
+
// no clue that proposal is the real story — name it when one does.
|
|
1579
|
+
const siblingRetire = listProposalsReadOnly(stashDir, { ref: proposal.ref, includeArchive: true }, ctx).find((p) => isRetireProposal(p) && (p.status === "pending" || p.status === "accepted"));
|
|
1580
|
+
throw new UsageError(`asset content changed after proposal ${id} was accepted; refusing to clobber the newer content` +
|
|
1581
|
+
(siblingRetire ? ` (a retire proposal for this ref exists: ${siblingRetire.id})` : ""), "INVALID_FLAG_VALUE");
|
|
1582
|
+
}
|
|
1583
|
+
const decidedAt = nowIso(ctx);
|
|
1584
|
+
writeProposalAssetFile(assetPath, backupContent.endsWith("\n") ? backupContent : `${backupContent}\n`);
|
|
1585
|
+
commitWriteTargetBoundary(target, `Revert ${proposal.ref}`, { paths: [assetPath] });
|
|
1586
|
+
const reverted = persistProposalDecision(stashDir, proposal, { operation: "revert", assetPath, decidedAt }, ctx);
|
|
1587
|
+
await indexWrittenProposalAsset(target, assetPath);
|
|
1831
1588
|
return { proposal: reverted, assetPath, ref: proposal.ref };
|
|
1832
1589
|
}
|
|
1833
|
-
/**
|
|
1834
|
-
* Compute a diff between a proposal payload and the existing on-disk asset.
|
|
1835
|
-
* Uses {@link resolveWriteTarget} to find where the asset would land — so the
|
|
1836
|
-
* diff matches exactly what `accept` will write. Falls back to "new asset"
|
|
1837
|
-
* when no asset is currently materialised at the target ref.
|
|
1838
|
-
*/
|
|
1590
|
+
/** The proposal against the asset its accept would overwrite (same target resolution as accept). */
|
|
1839
1591
|
export function diffProposal(stashDir, config, id, options = {}, ctx) {
|
|
1840
1592
|
const proposal = getProposal(stashDir, id, ctx);
|
|
1841
|
-
const
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
};
|
|
1850
|
-
readTarget(resolveProposalWriteTarget(config, proposal, options.target, options.queueTarget));
|
|
1851
|
-
const proposed = proposalContent(proposal);
|
|
1852
|
-
if (existing === null) {
|
|
1853
|
-
return {
|
|
1854
|
-
existing: null,
|
|
1855
|
-
proposed,
|
|
1856
|
-
unified: formatNewAssetDiff(proposal.ref, proposed),
|
|
1857
|
-
isNew: true,
|
|
1858
|
-
...(targetPath ? { targetPath } : {}),
|
|
1859
|
-
};
|
|
1860
|
-
}
|
|
1593
|
+
const target = resolveProposalWriteTarget(config, proposal, options.target, options.queueTarget);
|
|
1594
|
+
const targetPath = resolveAssetFilePathSafe(target.source, parseRefInput(proposal.ref));
|
|
1595
|
+
const existing = targetPath && fs.existsSync(targetPath) ? fs.readFileSync(targetPath, "utf8") : null;
|
|
1596
|
+
// A retire proposal's primary change deletes its target rather than
|
|
1597
|
+
// writing content: "proposed" is empty and the diff shows the whole body
|
|
1598
|
+
// being removed, reusing the ordinary unified-diff formatter instead of
|
|
1599
|
+
// proposalContent() (which has nothing to read for a delete).
|
|
1600
|
+
const proposed = isRetireProposal(proposal) ? "" : proposalContent(proposal);
|
|
1861
1601
|
return {
|
|
1862
1602
|
existing,
|
|
1863
1603
|
proposed,
|
|
1864
|
-
unified:
|
|
1865
|
-
|
|
1604
|
+
unified: existing === null
|
|
1605
|
+
? formatNewAssetDiff(proposal.ref, proposed)
|
|
1606
|
+
: formatUnifiedDiff(existing, proposed, proposal.ref),
|
|
1607
|
+
isNew: existing === null,
|
|
1866
1608
|
...(targetPath ? { targetPath } : {}),
|
|
1867
1609
|
};
|
|
1868
1610
|
}
|
|
@@ -1870,49 +1612,10 @@ function resolveAssetFilePathSafe(source, ref) {
|
|
|
1870
1612
|
const typeDir = stashDirFor(ref.type);
|
|
1871
1613
|
if (!typeDir)
|
|
1872
1614
|
return undefined;
|
|
1873
|
-
const typeRoot = path.join(source.path, typeDir);
|
|
1874
1615
|
try {
|
|
1875
|
-
return assetPathForName(ref.type,
|
|
1616
|
+
return assetPathForName(ref.type, path.join(source.path, typeDir), ref.name);
|
|
1876
1617
|
}
|
|
1877
1618
|
catch {
|
|
1878
1619
|
return undefined;
|
|
1879
1620
|
}
|
|
1880
1621
|
}
|
|
1881
|
-
// Register the proposal transaction kinds with the unified engine so ANY
|
|
1882
|
-
// recovery entry point (mv pre-flight, indexer, write-path indexer) can
|
|
1883
|
-
// finish or roll back an interrupted proposal mutation for a root it
|
|
1884
|
-
// touches. The proposal-owned entry points below keep their richer,
|
|
1885
|
-
// ctx-threaded recovery paths over the same journals.
|
|
1886
|
-
registerTxnKind(PROPOSAL_TXN_KIND, {
|
|
1887
|
-
phases: PROPOSAL_TXN_PHASES,
|
|
1888
|
-
commitPhase: "asset-published",
|
|
1889
|
-
validate: (journal, txnDir, root) => fenceProposalTxnJournal(journal, txnDir, root),
|
|
1890
|
-
rollback: (txn) => {
|
|
1891
|
-
rollbackPreparedProposalTransaction(txn);
|
|
1892
|
-
},
|
|
1893
|
-
finalize: async (txn) => {
|
|
1894
|
-
const p = txn.journal.payload;
|
|
1895
|
-
const config = loadConfig();
|
|
1896
|
-
let target = resolveProposalRecoveryTarget(config, txn.journal);
|
|
1897
|
-
if (txn.journal.phase === "asset-published") {
|
|
1898
|
-
target = prepareWriteTargetForMutation(target, { allowAhead: true });
|
|
1899
|
-
}
|
|
1900
|
-
if (canonicalTxnRoot(target.source.path) !== canonicalTxnRoot(txn.journal.root) ||
|
|
1901
|
-
p.targetKind !== target.source.kind) {
|
|
1902
|
-
throw new Error(`Proposal transaction ${txn.journal.transactionId} is bound to a different target root.`);
|
|
1903
|
-
}
|
|
1904
|
-
const proposal = getProposal(p.stashDir, p.proposalId);
|
|
1905
|
-
await finalizeProposalTransaction(txn, target, proposal);
|
|
1906
|
-
cleanupProposalPublication(p);
|
|
1907
|
-
},
|
|
1908
|
-
});
|
|
1909
|
-
registerTxnKind(REJECT_TXN_KIND, {
|
|
1910
|
-
phases: REJECT_TXN_PHASES,
|
|
1911
|
-
// A reject is roll-forward from its very first phase (DB-only; the archive
|
|
1912
|
-
// decision is durable the moment the journal exists).
|
|
1913
|
-
commitPhase: "prepared",
|
|
1914
|
-
rollback: () => { },
|
|
1915
|
-
finalize: (txn) => {
|
|
1916
|
-
finalizeRejectTransaction(txn);
|
|
1917
|
-
},
|
|
1918
|
-
});
|