akm-cli 0.9.0-rc.2 → 0.9.0-rc.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 +40 -12
- package/README.md +7 -8
- package/SECURITY.md +5 -3
- package/dist/akm +44 -33
- package/dist/akm-migrate-storage +44 -33
- package/dist/assets/backends/schtasks-template.xml +2 -1
- package/dist/assets/improve-strategies/catchup.json +3 -1
- package/dist/assets/improve-strategies/consolidate.json +3 -1
- package/dist/assets/improve-strategies/frequent.json +3 -1
- package/dist/assets/improve-strategies/graph-refresh.json +3 -1
- package/dist/assets/improve-strategies/memory-focus.json +4 -1
- package/dist/assets/improve-strategies/proactive-maintenance.json +1 -0
- package/dist/assets/improve-strategies/quick.json +3 -1
- package/dist/assets/improve-strategies/recombine-only.json +2 -0
- package/dist/assets/improve-strategies/reflect-distill.json +1 -0
- package/dist/assets/improve-strategies/synthesize.json +2 -0
- package/dist/assets/tasks/core/backup.yml +2 -2
- package/dist/cli/config-migrate.js +554 -26
- package/dist/cli.js +28 -11
- package/dist/commands/backup-cli.js +6 -4
- package/dist/commands/config-cli.js +10 -3
- package/dist/commands/feedback-cli.js +24 -7
- package/dist/commands/health/checks.js +94 -15
- package/dist/commands/health/html-report.js +23 -2
- package/dist/commands/health/improve-metrics.js +45 -6
- package/dist/commands/health/md-report.js +10 -1
- package/dist/commands/health/surfaces.js +2 -1
- package/dist/commands/health/windows.js +1 -1
- package/dist/commands/health.js +1 -1
- package/dist/commands/improve/anti-collapse.js +3 -3
- package/dist/commands/improve/collapse-detector.js +5 -5
- package/dist/commands/improve/consolidate.js +123 -66
- package/dist/commands/improve/distill/promote-memory.js +8 -6
- package/dist/commands/improve/distill/quality-gate.js +1 -1
- package/dist/commands/improve/distill-guards.js +1 -1
- package/dist/commands/improve/distill-promotion-policy.js +32 -26
- package/dist/commands/improve/distill.js +52 -36
- package/dist/commands/improve/eligibility.js +9 -8
- package/dist/commands/improve/extract-prompt.js +2 -2
- package/dist/commands/improve/extract.js +43 -11
- package/dist/commands/improve/improve-auto-accept.js +14 -28
- package/dist/commands/improve/improve-cli.js +4 -2
- package/dist/commands/improve/improve-strategies.js +2 -1
- package/dist/commands/improve/improve.js +49 -8
- package/dist/commands/improve/loop-stages.js +11 -6
- package/dist/commands/improve/preparation.js +51 -17
- package/dist/commands/improve/procedural.js +10 -6
- package/dist/commands/improve/recombine.js +31 -7
- package/dist/commands/improve/reflect.js +24 -14
- package/dist/commands/improve/salience.js +5 -1
- package/dist/commands/improve/source-identity.js +43 -0
- package/dist/commands/migrate-cli.js +36 -0
- package/dist/commands/mv-cli.js +647 -258
- package/dist/commands/proposal/drain.js +16 -4
- package/dist/commands/proposal/proposal-cli.js +3 -2
- package/dist/commands/proposal/proposal.js +16 -55
- package/dist/commands/proposal/repository.js +664 -58
- package/dist/commands/read/curate.js +4 -2
- package/dist/commands/read/knowledge.js +4 -1
- package/dist/commands/read/search.js +6 -2
- package/dist/commands/read/show.js +8 -7
- package/dist/commands/sources/self-update.js +156 -112
- package/dist/commands/sources/sources-cli.js +7 -2
- package/dist/commands/tasks/default-tasks.js +5 -5
- package/dist/commands/tasks/tasks-cli.js +7 -3
- package/dist/commands/tasks/tasks.js +261 -68
- package/dist/core/common.js +36 -3
- package/dist/core/config/config-io.js +2 -2
- package/dist/core/config/config-schema.js +15 -3
- package/dist/core/config/config.js +14 -13
- package/dist/core/file-lock.js +2 -2
- package/dist/core/improve-result.js +87 -7
- package/dist/core/migration-backup.js +816 -171
- package/dist/core/migration-operation.js +44 -0
- package/dist/core/state/migrations.js +4 -7
- package/dist/core/state-db.js +23 -7
- package/dist/core/write-source.js +86 -18
- package/dist/indexer/db/db.js +109 -53
- package/dist/indexer/index-writer-lock.js +38 -37
- package/dist/indexer/index-written-assets.js +73 -56
- package/dist/indexer/indexer.js +5 -1
- package/dist/indexer/search/search-source.js +2 -2
- package/dist/indexer/usage/usage-events.js +8 -2
- package/dist/integrations/agent/engine-resolution.js +14 -7
- package/dist/scripts/migrate-storage.js +887 -992
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +494 -574
- package/dist/setup/detected-engines.js +7 -13
- package/dist/setup/engine-config.js +14 -4
- package/dist/setup/setup.js +1 -1
- package/dist/setup/steps/connection.js +1 -1
- package/dist/setup/steps/tasks.js +1 -1
- package/dist/sources/providers/git-stash.js +58 -21
- package/dist/sources/providers/git.js +1 -1
- package/dist/storage/engines/sqlite-migrations.js +138 -3
- package/dist/storage/repositories/events-repository.js +24 -0
- package/dist/storage/repositories/proposals-repository.js +14 -0
- package/dist/storage/repositories/task-history-repository.js +30 -3
- package/dist/tasks/backends/cron.js +71 -38
- package/dist/tasks/backends/exec-utils.js +55 -3
- package/dist/tasks/backends/launchd.js +241 -50
- package/dist/tasks/backends/schtasks.js +434 -59
- package/dist/tasks/command-executable.js +93 -0
- package/dist/tasks/embedded.js +2 -0
- package/dist/tasks/parser.js +142 -6
- package/dist/tasks/resolve-akm-bin.js +19 -4
- package/dist/tasks/runner.js +258 -93
- package/dist/tasks/schedule.js +108 -19
- package/dist/tasks/scheduler-invocation.js +86 -0
- package/dist/tasks/task-id.js +37 -0
- package/dist/workflows/db.js +24 -13
- package/dist/workflows/validator.js +24 -2
- package/docs/README.md +103 -0
- package/docs/migration/release-notes/0.9.0.md +28 -9
- package/docs/migration/v0.8-to-v0.9.md +186 -26
- package/package.json +2 -3
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
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
|
-
import { createHash } from "node:crypto";
|
|
5
|
-
import { ConfigError } from "../core/errors.js";
|
|
6
4
|
const CHAT_SUFFIX = "/chat/completions";
|
|
7
5
|
export function normalizeChatCompletionsEndpoint(input) {
|
|
8
6
|
const url = new URL(input);
|
|
@@ -84,19 +82,13 @@ function availableName(config, preferred, fingerprint) {
|
|
|
84
82
|
return name;
|
|
85
83
|
}
|
|
86
84
|
const base = slug(preferred);
|
|
87
|
-
const expectedKind = fingerprint.startsWith("llm:") ? "llm" : "agent";
|
|
88
|
-
const baseEngine = config.engines?.[base];
|
|
89
|
-
if (baseEngine && baseEngine.kind !== expectedKind) {
|
|
90
|
-
throw new ConfigError(`Detected ${expectedKind} engine name ${JSON.stringify(base)} conflicts with configured ${baseEngine.kind} engine of the same name. Rename one engine before rerunning setup.`, "INVALID_CONFIG_FILE");
|
|
91
|
-
}
|
|
92
85
|
if (!config.engines?.[base])
|
|
93
86
|
return base;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
throw new ConfigError(`Detected engine identity collision at ${JSON.stringify(candidate)}. Choose an explicit engine name before rerunning setup.`, "INVALID_CONFIG_FILE");
|
|
87
|
+
for (let suffix = 2;; suffix++) {
|
|
88
|
+
const candidate = `${base}-${suffix}`;
|
|
89
|
+
if (!config.engines?.[candidate])
|
|
90
|
+
return candidate;
|
|
91
|
+
}
|
|
100
92
|
}
|
|
101
93
|
/** Insert or reuse an LLM engine by canonical endpoint without modifying a match. */
|
|
102
94
|
export function upsertDetectedLlmEngine(config, candidate) {
|
|
@@ -123,6 +115,8 @@ export function upsertDetectedLlmEngine(config, candidate) {
|
|
|
123
115
|
(config.engines?.[currentDefault] && engineFingerprint(config.engines[currentDefault]) === fingerprint)) {
|
|
124
116
|
defaults.llmEngine = name;
|
|
125
117
|
}
|
|
118
|
+
if (!defaults.engine)
|
|
119
|
+
defaults.engine = defaults.llmEngine ?? name;
|
|
126
120
|
return { config: { ...config, engines, defaults }, name, reused };
|
|
127
121
|
}
|
|
128
122
|
/** Insert or reuse an agent engine by canonical platform without modifying a match. */
|
|
@@ -35,6 +35,8 @@ export function writeLlmEngine(config, llm) {
|
|
|
35
35
|
delete engines[name];
|
|
36
36
|
const defaults = { ...(config.defaults ?? {}) };
|
|
37
37
|
delete defaults.llmEngine;
|
|
38
|
+
if (defaults.engine === name)
|
|
39
|
+
delete defaults.engine;
|
|
38
40
|
return { engines, defaults };
|
|
39
41
|
}
|
|
40
42
|
if (config.engines?.[name] && config.engines[name].kind !== "llm") {
|
|
@@ -54,7 +56,7 @@ export function writeLlmEngine(config, llm) {
|
|
|
54
56
|
...(capabilities?.structuredOutput !== undefined ? { supportsJsonSchema: capabilities.structuredOutput } : {}),
|
|
55
57
|
},
|
|
56
58
|
},
|
|
57
|
-
defaults: { ...(config.defaults ?? {}), llmEngine: name },
|
|
59
|
+
defaults: { ...(config.defaults ?? {}), engine: config.defaults?.engine ?? name, llmEngine: name },
|
|
58
60
|
};
|
|
59
61
|
}
|
|
60
62
|
/** Merge setup-selected agent engines without translating profile-shaped data. */
|
|
@@ -66,9 +68,17 @@ export function writeAgentEngines(config, selection) {
|
|
|
66
68
|
}
|
|
67
69
|
}
|
|
68
70
|
const defaults = { ...(config.defaults ?? {}) };
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
if (selection?.disabled) {
|
|
72
|
+
const currentDefault = defaults.engine;
|
|
73
|
+
if (currentDefault && config.engines?.[currentDefault]?.kind === "agent") {
|
|
74
|
+
const llmDefault = defaults.llmEngine;
|
|
75
|
+
if (llmDefault && config.engines?.[llmDefault]?.kind === "llm")
|
|
76
|
+
defaults.engine = llmDefault;
|
|
77
|
+
else
|
|
78
|
+
delete defaults.engine;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (selection?.default)
|
|
72
82
|
defaults.engine = selection.default;
|
|
73
83
|
const engines = { ...(config.engines ?? {}), ...(selection?.engines ?? {}) };
|
|
74
84
|
if (selection?.default && engines[selection.default] && engines[selection.default].kind !== "agent") {
|
package/dist/setup/setup.js
CHANGED
|
@@ -430,7 +430,7 @@ export async function runSetupWizard(opts) {
|
|
|
430
430
|
const registries = newConfig.registries;
|
|
431
431
|
const allStashes = newConfig.sources ?? [];
|
|
432
432
|
// Feature capability summary
|
|
433
|
-
const agentConfigured = Boolean(agentConfig);
|
|
433
|
+
const agentConfigured = Boolean(agentConfig && !agentConfig.disabled);
|
|
434
434
|
printCapabilitySummary(smallModelResult.skipped, agentConfigured);
|
|
435
435
|
// Confirm before saving
|
|
436
436
|
const effectiveRegistries = registries ?? DEFAULT_CONFIG.registries ?? [];
|
|
@@ -653,7 +653,7 @@ export async function stepAgentConnection(current, smallModel) {
|
|
|
653
653
|
"",
|
|
654
654
|
"You can configure this later with `akm setup`.",
|
|
655
655
|
].join("\n"), "Warning");
|
|
656
|
-
return
|
|
656
|
+
return { disabled: true };
|
|
657
657
|
}
|
|
658
658
|
if (agentChoice === "same-connection") {
|
|
659
659
|
if (smallModel.skipped || !smallModel.llm) {
|
|
@@ -56,7 +56,7 @@ const DEFAULT_SCHEDULED_TASKS_DEPS = {
|
|
|
56
56
|
gitSync: saveGitStash,
|
|
57
57
|
};
|
|
58
58
|
export async function stepScheduledTasks(deps = DEFAULT_SCHEDULED_TASKS_DEPS) {
|
|
59
|
-
const embedded = listEmbeddedTasks();
|
|
59
|
+
const embedded = listEmbeddedTasks().filter((task) => task.enabled);
|
|
60
60
|
if (embedded.length === 0)
|
|
61
61
|
return;
|
|
62
62
|
// Snapshot current state so we can diff against the user's selection.
|
|
@@ -22,6 +22,27 @@ import { getCachePaths, parseGitRepoUrl } from "./git-provider.js";
|
|
|
22
22
|
export function isGitBackedStash(stashDir) {
|
|
23
23
|
return fs.existsSync(path.join(stashDir, ".git"));
|
|
24
24
|
}
|
|
25
|
+
/** Return repo-relative dirty/staged paths without changing the index. */
|
|
26
|
+
export function listGitChangedPaths(repoDir) {
|
|
27
|
+
const result = runGit(["-C", repoDir, "status", "--porcelain", "-z", "--untracked-files=all"]);
|
|
28
|
+
if (result.status !== 0)
|
|
29
|
+
return [];
|
|
30
|
+
const records = result.stdout.split("\0");
|
|
31
|
+
const paths = [];
|
|
32
|
+
for (let i = 0; i < records.length; i++) {
|
|
33
|
+
const record = records[i];
|
|
34
|
+
if (!record)
|
|
35
|
+
continue;
|
|
36
|
+
const status = record.slice(0, 2);
|
|
37
|
+
paths.push(record.slice(3));
|
|
38
|
+
if (status.includes("R") || status.includes("C")) {
|
|
39
|
+
const previousPath = records[++i];
|
|
40
|
+
if (previousPath)
|
|
41
|
+
paths.push(previousPath);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return paths;
|
|
45
|
+
}
|
|
25
46
|
/**
|
|
26
47
|
* Resolve the writable-override flag for an end-of-run / `akm sync` commit on
|
|
27
48
|
* the primary stash. Returns `true` when the root config explicitly marks the
|
|
@@ -110,16 +131,25 @@ export function saveGitStash(name, message, writableOverride, options) {
|
|
|
110
131
|
// 2. Managed pathspecs (TYPE_DIRS values + `.akm`) that exist on disk —
|
|
111
132
|
// stages everything akm owns and, by construction, never stages non-akm
|
|
112
133
|
// WIP. This preserves the #476 protection WITHOUT refusing.
|
|
113
|
-
// 3.
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (!stageScopedChanges(repoDir, options?.paths)) {
|
|
134
|
+
// 3. No resolvable managed path — no commit. Broad staging is never safe
|
|
135
|
+
// because it can absorb unrelated work already present in the index.
|
|
136
|
+
const staged = stageScopedChanges(repoDir, options?.paths);
|
|
137
|
+
if (!staged.ok) {
|
|
118
138
|
throw new Error(`git add failed while staging akm changes in ${repoDir}`);
|
|
119
139
|
}
|
|
140
|
+
if (staged.pathspecs && staged.pathspecs.length === 0) {
|
|
141
|
+
return { committed: false, pushed: false, skipped: false, output: "nothing to commit" };
|
|
142
|
+
}
|
|
120
143
|
// Nothing actually staged → don't create an empty commit. This happens when
|
|
121
144
|
// only non-akm files were dirty (precedence 2 staged nothing).
|
|
122
|
-
const stagedResult = runGit([
|
|
145
|
+
const stagedResult = runGit([
|
|
146
|
+
"-C",
|
|
147
|
+
repoDir,
|
|
148
|
+
"diff",
|
|
149
|
+
"--cached",
|
|
150
|
+
"--quiet",
|
|
151
|
+
...(staged.pathspecs ? ["--", ...staged.pathspecs] : []),
|
|
152
|
+
]);
|
|
123
153
|
if (stagedResult.status === 0) {
|
|
124
154
|
return { committed: false, pushed: false, skipped: false, output: "nothing to commit" };
|
|
125
155
|
}
|
|
@@ -135,6 +165,7 @@ export function saveGitStash(name, message, writableOverride, options) {
|
|
|
135
165
|
"commit",
|
|
136
166
|
"-m",
|
|
137
167
|
commitMessage,
|
|
168
|
+
...(staged.pathspecs ? ["--only", "--", ...staged.pathspecs] : []),
|
|
138
169
|
]);
|
|
139
170
|
if (commitResult.status !== 0) {
|
|
140
171
|
throw new Error(`git commit failed: ${commitResult.stderr?.trim() || "unknown error"}`);
|
|
@@ -161,34 +192,40 @@ export function saveGitStash(name, message, writableOverride, options) {
|
|
|
161
192
|
}
|
|
162
193
|
/**
|
|
163
194
|
* Stage akm's changes in `repoDir` using the scoped-staging precedence
|
|
164
|
-
* documented at the call site (#476).
|
|
165
|
-
*
|
|
166
|
-
* the caller then detects "nothing staged" via `git diff --cached --quiet`).
|
|
195
|
+
* documented at the call site (#476). The returned pathspecs are reduced to
|
|
196
|
+
* exact staged files so `git commit --only` cannot absorb unrelated index state.
|
|
167
197
|
*
|
|
168
198
|
* @param paths Optional explicit repo-relative paths akm wrote this run. When
|
|
169
199
|
* provided and non-empty, exactly those are staged (chunked to stay under
|
|
170
200
|
* argv length limits). Otherwise we fall back to the managed pathspecs, and
|
|
171
|
-
*
|
|
201
|
+
* skip the commit when no managed pathspec exists on disk.
|
|
172
202
|
*/
|
|
173
203
|
function stageScopedChanges(repoDir, paths) {
|
|
204
|
+
if (paths !== undefined && paths.length === 0)
|
|
205
|
+
return { ok: true, pathspecs: [] };
|
|
174
206
|
// Precedence 1: explicit modified-file list.
|
|
175
207
|
const explicit = (paths ?? []).filter((p) => typeof p === "string" && p.length > 0);
|
|
176
208
|
if (explicit.length > 0) {
|
|
177
|
-
|
|
209
|
+
const ok = addPathspecsChunked(repoDir, explicit);
|
|
210
|
+
return { ok, pathspecs: ok ? listStagedPaths(repoDir, explicit) : [] };
|
|
178
211
|
}
|
|
179
212
|
// Precedence 2: managed pathspecs that exist on disk (TYPE_DIRS + `.akm`).
|
|
180
213
|
const managed = [...Object.values(TYPE_DIRS), ".akm"].filter((dir) => fs.existsSync(path.join(repoDir, dir)));
|
|
181
214
|
if (managed.length > 0) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
215
|
+
const ok = addPathspecsChunked(repoDir, managed);
|
|
216
|
+
return { ok, pathspecs: ok ? listStagedPaths(repoDir, managed) : [] };
|
|
217
|
+
}
|
|
218
|
+
// No managed target means there is no safe commit scope.
|
|
219
|
+
return { ok: true, pathspecs: [] };
|
|
220
|
+
}
|
|
221
|
+
function listStagedPaths(repoDir, pathspecs) {
|
|
222
|
+
const result = runGit(["-C", repoDir, "diff", "--cached", "--name-only", "--", ...pathspecs]);
|
|
223
|
+
if (result.status !== 0)
|
|
224
|
+
return [];
|
|
225
|
+
return result.stdout
|
|
226
|
+
.split("\n")
|
|
227
|
+
.map((value) => value.trim())
|
|
228
|
+
.filter(Boolean);
|
|
192
229
|
}
|
|
193
230
|
/**
|
|
194
231
|
* Run `git add -- <pathspec>...` in chunks so a very large path list never
|
|
@@ -10,4 +10,4 @@
|
|
|
10
10
|
// keeps importing from a single module namespace.
|
|
11
11
|
export { classifyCloneFailure, cloneRepo, syncRegistryGitRef } from "./git-install.js";
|
|
12
12
|
export { ensureGitMirror, GitSourceProvider, getCachePaths, parseGitRepoUrl, syncMirroredRepo, } from "./git-provider.js";
|
|
13
|
-
export { isGitBackedStash, resolveWritableOverride, saveGitStash, } from "./git-stash.js";
|
|
13
|
+
export { isGitBackedStash, listGitChangedPaths, resolveWritableOverride, saveGitStash, } from "./git-stash.js";
|
|
@@ -1,6 +1,115 @@
|
|
|
1
1
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
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
|
+
/**
|
|
5
|
+
* Shared SQLite migration engine.
|
|
6
|
+
*
|
|
7
|
+
* state.db (`src/core/state-db.ts`) and workflow.db (`src/workflows/db.ts`)
|
|
8
|
+
* both evolve their schema through an identical, idempotent, transaction-per-
|
|
9
|
+
* migration runner backed by a `schema_migrations` ledger. The two runners
|
|
10
|
+
* were byte-identical except for ONE line: workflow.db must back-fill a
|
|
11
|
+
* `schema_migrations` row for pre-versioning databases before evaluating the
|
|
12
|
+
* migration list (see `bootstrapPreVersioningDb` in workflows/db.ts).
|
|
13
|
+
*
|
|
14
|
+
* This module factors that runner out once. Each DB module supplies only its
|
|
15
|
+
* own `MIGRATIONS` array; workflow.db additionally passes a `bootstrap` hook.
|
|
16
|
+
*
|
|
17
|
+
* Migration-safety contract:
|
|
18
|
+
* - `id` is permanent and must never be reused.
|
|
19
|
+
* - `up` must be idempotent (use IF NOT EXISTS, INSERT OR IGNORE, etc.).
|
|
20
|
+
* - `up` must not DROP any table that holds durable (non-regenerable) data.
|
|
21
|
+
* - `up` must not RENAME or change the type of an existing column.
|
|
22
|
+
* - To add a column: use `ALTER TABLE … ADD COLUMN … DEFAULT …`.
|
|
23
|
+
* - Applied IDs must be an exact ordered prefix of the registry.
|
|
24
|
+
* - Released migration bodies are sealed by SHA-256 in the same ledger.
|
|
25
|
+
*/
|
|
26
|
+
import crypto from "node:crypto";
|
|
27
|
+
export function migrationChecksum(migration) {
|
|
28
|
+
return crypto.createHash("sha256").update(migration.id).update("\0").update(migration.up).digest("hex");
|
|
29
|
+
}
|
|
30
|
+
function migrationsTableExists(db) {
|
|
31
|
+
return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'").get();
|
|
32
|
+
}
|
|
33
|
+
function ledgerHasChecksum(db) {
|
|
34
|
+
return db.prepare("PRAGMA table_info(schema_migrations)").all().some((column) => column.name === "checksum");
|
|
35
|
+
}
|
|
36
|
+
export function inspectMigrationLedger(db, migrations) {
|
|
37
|
+
const registryIds = migrations.map((migration) => migration.id);
|
|
38
|
+
if (new Set(registryIds).size !== registryIds.length) {
|
|
39
|
+
return {
|
|
40
|
+
status: "inconsistent",
|
|
41
|
+
migrationIds: [],
|
|
42
|
+
checksums: [],
|
|
43
|
+
detail: "migration registry contains duplicate IDs",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
if (!migrationsTableExists(db))
|
|
47
|
+
return { status: registryIds.length === 0 ? "current" : "old", migrationIds: [], checksums: [] };
|
|
48
|
+
const hasChecksum = ledgerHasChecksum(db);
|
|
49
|
+
const rows = db
|
|
50
|
+
.prepare(`SELECT id${hasChecksum ? ", checksum" : ""} FROM schema_migrations ORDER BY rowid`)
|
|
51
|
+
.all();
|
|
52
|
+
const migrationIds = rows.map((row) => row.id);
|
|
53
|
+
const checksums = rows.map((row) => row.checksum ?? null);
|
|
54
|
+
for (let index = 0; index < rows.length; index += 1) {
|
|
55
|
+
const expected = migrations[index];
|
|
56
|
+
const row = rows[index];
|
|
57
|
+
if (!expected) {
|
|
58
|
+
return { status: "newer", migrationIds, checksums, detail: `unknown migration ID ${row.id}` };
|
|
59
|
+
}
|
|
60
|
+
if (row.id !== expected.id) {
|
|
61
|
+
const knownLater = registryIds.includes(row.id);
|
|
62
|
+
return {
|
|
63
|
+
status: knownLater ? "inconsistent" : "newer",
|
|
64
|
+
migrationIds,
|
|
65
|
+
checksums,
|
|
66
|
+
detail: knownLater
|
|
67
|
+
? `migration ledger is not an exact ordered prefix at position ${index + 1}`
|
|
68
|
+
: `unknown migration ID ${row.id}`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const expectedChecksum = migrationChecksum(expected);
|
|
72
|
+
if (row.checksum && row.checksum !== expectedChecksum) {
|
|
73
|
+
return {
|
|
74
|
+
status: "inconsistent",
|
|
75
|
+
migrationIds,
|
|
76
|
+
checksums,
|
|
77
|
+
detail: `migration ${row.id} checksum does not match the released migration body`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const missingChecksum = checksums.indexOf(null);
|
|
82
|
+
if (missingChecksum >= 0) {
|
|
83
|
+
return {
|
|
84
|
+
status: "old",
|
|
85
|
+
migrationIds,
|
|
86
|
+
checksums,
|
|
87
|
+
detail: `migration ${migrationIds[missingChecksum]} has not been sealed with a checksum`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
status: rows.length === migrations.length ? "current" : "old",
|
|
92
|
+
migrationIds,
|
|
93
|
+
checksums,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export function assertMigrationLedger(db, migrations) {
|
|
97
|
+
const state = inspectMigrationLedger(db, migrations);
|
|
98
|
+
if (state.status === "newer") {
|
|
99
|
+
throw new Error(`Refusing to open a database with a newer migration ledger: ${state.detail}.`);
|
|
100
|
+
}
|
|
101
|
+
if (state.status === "inconsistent") {
|
|
102
|
+
throw new Error(`Refusing a database whose migrations are not an exact ordered prefix: ${state.detail}.`);
|
|
103
|
+
}
|
|
104
|
+
return state;
|
|
105
|
+
}
|
|
106
|
+
export function assertCurrentMigrationLedger(db, migrations) {
|
|
107
|
+
const state = assertMigrationLedger(db, migrations);
|
|
108
|
+
if (state.status !== "current") {
|
|
109
|
+
throw new Error(`Refusing to open an obsolete writable schema; run \`akm migrate apply\`: ${state.detail ?? "pending migrations"}.`);
|
|
110
|
+
}
|
|
111
|
+
return state;
|
|
112
|
+
}
|
|
4
113
|
/**
|
|
5
114
|
* Create the migrations ledger table if it does not exist. Must be called
|
|
6
115
|
* unconditionally on every open so a fresh database bootstraps correctly.
|
|
@@ -9,9 +118,12 @@ export function ensureMigrationsTable(db) {
|
|
|
9
118
|
db.exec(`
|
|
10
119
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
11
120
|
id TEXT PRIMARY KEY,
|
|
12
|
-
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
121
|
+
applied_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
122
|
+
checksum TEXT
|
|
13
123
|
);
|
|
14
124
|
`);
|
|
125
|
+
if (!ledgerHasChecksum(db))
|
|
126
|
+
db.exec("ALTER TABLE schema_migrations ADD COLUMN checksum TEXT");
|
|
15
127
|
}
|
|
16
128
|
/**
|
|
17
129
|
* Apply every pending migration, one transaction per migration.
|
|
@@ -27,9 +139,32 @@ export function ensureMigrationsTable(db) {
|
|
|
27
139
|
* @param opts Optional `bootstrap` hook (see {@link RunMigrationsOptions}).
|
|
28
140
|
*/
|
|
29
141
|
export function runMigrations(db, migrations, opts) {
|
|
142
|
+
if (opts?.applyPending === false) {
|
|
143
|
+
assertMigrationLedger(db, migrations);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (migrationsTableExists(db))
|
|
147
|
+
assertMigrationLedger(db, migrations);
|
|
30
148
|
ensureMigrationsTable(db);
|
|
31
149
|
opts?.bootstrap?.(db);
|
|
32
|
-
const
|
|
150
|
+
const ledger = assertMigrationLedger(db, migrations);
|
|
151
|
+
db.transaction(() => {
|
|
152
|
+
const update = db.prepare("UPDATE schema_migrations SET checksum = ? WHERE id = ? AND checksum IS NULL");
|
|
153
|
+
for (let index = 0; index < ledger.migrationIds.length; index += 1) {
|
|
154
|
+
update.run(migrationChecksum(migrations[index]), ledger.migrationIds[index]);
|
|
155
|
+
}
|
|
156
|
+
if (opts?.generationMarker) {
|
|
157
|
+
db.exec(`
|
|
158
|
+
CREATE TABLE IF NOT EXISTS akm_migration_generation (
|
|
159
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
160
|
+
operation_id TEXT NOT NULL,
|
|
161
|
+
phase TEXT NOT NULL
|
|
162
|
+
)
|
|
163
|
+
`);
|
|
164
|
+
db.prepare("INSERT INTO akm_migration_generation(singleton, operation_id, phase) VALUES (1, ?, ?) ON CONFLICT(singleton) DO UPDATE SET operation_id=excluded.operation_id, phase=excluded.phase").run(opts.generationMarker.operationId, opts.generationMarker.phase);
|
|
165
|
+
}
|
|
166
|
+
})();
|
|
167
|
+
const appliedRows = db.prepare("SELECT id FROM schema_migrations ORDER BY rowid").all();
|
|
33
168
|
const applied = new Set(appliedRows.map((r) => r.id));
|
|
34
169
|
for (const migration of migrations) {
|
|
35
170
|
if (applied.has(migration.id))
|
|
@@ -37,7 +172,7 @@ export function runMigrations(db, migrations, opts) {
|
|
|
37
172
|
opts?.beforeMigration?.(migration);
|
|
38
173
|
db.transaction(() => {
|
|
39
174
|
db.exec(migration.up);
|
|
40
|
-
db.prepare("INSERT INTO schema_migrations (id) VALUES (?)").run(migration.id);
|
|
175
|
+
db.prepare("INSERT INTO schema_migrations (id, checksum) VALUES (?, ?)").run(migration.id, migrationChecksum(migration));
|
|
41
176
|
})();
|
|
42
177
|
}
|
|
43
178
|
}
|
|
@@ -51,6 +51,30 @@ export function insertEvent(db, input) {
|
|
|
51
51
|
return undefined;
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
|
+
/** Strict idempotent event insert for durable mutation journals. */
|
|
55
|
+
export function insertEventOnce(db, input) {
|
|
56
|
+
const rows = db
|
|
57
|
+
.prepare("SELECT id, metadata_json FROM events WHERE event_type = ? AND ref IS ? ORDER BY id")
|
|
58
|
+
.all(input.eventType, input.ref ?? null);
|
|
59
|
+
for (const row of rows) {
|
|
60
|
+
try {
|
|
61
|
+
const metadata = JSON.parse(row.metadata_json);
|
|
62
|
+
if (metadata[input.idempotencyMetadataKey ?? "proposalTransactionId"] === input.idempotencyKey)
|
|
63
|
+
return row.id;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Corrupt unrelated event metadata cannot satisfy this idempotency key.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const result = db
|
|
70
|
+
.prepare(`INSERT INTO events (event_type, ts, ref, metadata_json)
|
|
71
|
+
VALUES (?, ?, ?, ?)
|
|
72
|
+
RETURNING id`)
|
|
73
|
+
.get(input.eventType, input.ts, input.ref ?? null, JSON.stringify(input.metadata));
|
|
74
|
+
if (!result)
|
|
75
|
+
throw new Error(`Failed to persist ${input.eventType} event.`);
|
|
76
|
+
return result.id;
|
|
77
|
+
}
|
|
54
78
|
/**
|
|
55
79
|
* Read events from the database matching the filter. Returns events in
|
|
56
80
|
* ascending id order so consumers can process them in emission order.
|
|
@@ -37,6 +37,10 @@ export function proposalRowToProposal(row) {
|
|
|
37
37
|
...(typeof meta.confidence === "number" ? { confidence: meta.confidence } : {}),
|
|
38
38
|
...(meta.gateDecision !== undefined ? { gateDecision: meta.gateDecision } : {}),
|
|
39
39
|
...(typeof meta.backupContent === "string" ? { backupContent: meta.backupContent } : {}),
|
|
40
|
+
...(typeof meta.acceptedContentHash === "string" ? { acceptedContentHash: meta.acceptedContentHash } : {}),
|
|
41
|
+
...(meta.acceptedTarget !== undefined ? { acceptedTarget: meta.acceptedTarget } : {}),
|
|
42
|
+
...(meta.legacyAcceptedTargetDerived === true ? { legacyAcceptedTargetDerived: true } : {}),
|
|
43
|
+
...(meta.legacyAcceptedAssetWasAbsent === true ? { legacyAcceptedAssetWasAbsent: true } : {}),
|
|
40
44
|
...(typeof meta.eligibilitySource === "string"
|
|
41
45
|
? { eligibilitySource: meta.eligibilitySource }
|
|
42
46
|
: {}),
|
|
@@ -59,6 +63,16 @@ export function proposalToRowValues(proposal, stashDir) {
|
|
|
59
63
|
metaObj.gateDecision = proposal.gateDecision;
|
|
60
64
|
if (proposal.backupContent !== undefined)
|
|
61
65
|
metaObj.backupContent = proposal.backupContent;
|
|
66
|
+
if (proposal.acceptedContentHash !== undefined)
|
|
67
|
+
metaObj.acceptedContentHash = proposal.acceptedContentHash;
|
|
68
|
+
if (proposal.acceptedTarget !== undefined)
|
|
69
|
+
metaObj.acceptedTarget = proposal.acceptedTarget;
|
|
70
|
+
if (proposal.legacyAcceptedTargetDerived !== undefined) {
|
|
71
|
+
metaObj.legacyAcceptedTargetDerived = proposal.legacyAcceptedTargetDerived;
|
|
72
|
+
}
|
|
73
|
+
if (proposal.legacyAcceptedAssetWasAbsent !== undefined) {
|
|
74
|
+
metaObj.legacyAcceptedAssetWasAbsent = proposal.legacyAcceptedAssetWasAbsent;
|
|
75
|
+
}
|
|
62
76
|
if (proposal.eligibilitySource !== undefined)
|
|
63
77
|
metaObj.eligibilitySource = proposal.eligibilitySource;
|
|
64
78
|
return {
|
|
@@ -79,6 +79,33 @@ export function decodeTaskHistoryMetadata(input) {
|
|
|
79
79
|
}
|
|
80
80
|
metadataError(`unsupported metadataVersion: ${String(version)}`);
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Atomically reserve one task-attempt identity.
|
|
84
|
+
*
|
|
85
|
+
* The existing unique `(task_id, started_at)` index arbitrates concurrent
|
|
86
|
+
* processes: exactly one caller receives `true` for a candidate timestamp.
|
|
87
|
+
* Callers can advance by one millisecond and retry without a schema change.
|
|
88
|
+
*/
|
|
89
|
+
export function reserveTaskHistoryAttempt(db, row) {
|
|
90
|
+
const result = db
|
|
91
|
+
.prepare(`INSERT OR IGNORE INTO task_history
|
|
92
|
+
(task_id, status, started_at, completed_at, failed_at, log_path,
|
|
93
|
+
target_kind, target_ref, metadata_json)
|
|
94
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
95
|
+
.run(row.task_id, row.status, row.started_at, row.completed_at ?? null, row.failed_at ?? null, row.log_path ?? null, row.target_kind ?? null, row.target_ref ?? null, row.metadata_json);
|
|
96
|
+
return Number(result.changes) === 1;
|
|
97
|
+
}
|
|
98
|
+
/** Finalize a row previously created by {@link reserveTaskHistoryAttempt}. */
|
|
99
|
+
export function finalizeTaskHistoryAttempt(db, row) {
|
|
100
|
+
const result = db
|
|
101
|
+
.prepare(`UPDATE task_history
|
|
102
|
+
SET status = ?, completed_at = ?, failed_at = ?, log_path = ?,
|
|
103
|
+
target_kind = ?, target_ref = ?, metadata_json = ?
|
|
104
|
+
WHERE task_id = ? AND started_at = ?
|
|
105
|
+
AND status = 'active' AND completed_at IS NULL`)
|
|
106
|
+
.run(row.status, row.completed_at ?? null, row.failed_at ?? null, row.log_path ?? null, row.target_kind ?? null, row.target_ref ?? null, row.metadata_json, row.task_id, row.started_at);
|
|
107
|
+
return Number(result.changes) === 1;
|
|
108
|
+
}
|
|
82
109
|
/**
|
|
83
110
|
* Upsert a task history row.
|
|
84
111
|
*/
|
|
@@ -102,7 +129,7 @@ export function getTaskHistory(db, taskId) {
|
|
|
102
129
|
return db
|
|
103
130
|
.prepare(`SELECT id, task_id, status, started_at, completed_at, failed_at, log_path,
|
|
104
131
|
target_kind, target_ref, metadata_json
|
|
105
|
-
FROM task_history WHERE task_id = ? ORDER BY started_at DESC LIMIT 1`)
|
|
132
|
+
FROM task_history WHERE task_id = ? ORDER BY started_at DESC, id DESC LIMIT 1`)
|
|
106
133
|
.get(taskId);
|
|
107
134
|
}
|
|
108
135
|
/**
|
|
@@ -112,7 +139,7 @@ export function getTaskHistoryRuns(db, taskId, limit = 50) {
|
|
|
112
139
|
return db
|
|
113
140
|
.prepare(`SELECT id, task_id, status, started_at, completed_at, failed_at, log_path,
|
|
114
141
|
target_kind, target_ref, metadata_json
|
|
115
|
-
FROM task_history WHERE task_id = ? ORDER BY started_at DESC LIMIT ?`)
|
|
142
|
+
FROM task_history WHERE task_id = ? ORDER BY started_at DESC, id DESC LIMIT ?`)
|
|
116
143
|
.all(taskId, limit);
|
|
117
144
|
}
|
|
118
145
|
/**
|
|
@@ -145,7 +172,7 @@ export function queryTaskHistory(db, options = {}) {
|
|
|
145
172
|
return db
|
|
146
173
|
.prepare(`SELECT task_id, status, started_at, completed_at, failed_at, log_path,
|
|
147
174
|
target_kind, target_ref, metadata_json
|
|
148
|
-
FROM task_history ${where} ORDER BY started_at DESC`)
|
|
175
|
+
FROM task_history ${where} ORDER BY started_at DESC, id DESC`)
|
|
149
176
|
.all(...params);
|
|
150
177
|
}
|
|
151
178
|
/**
|