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