akm-cli 0.9.8-beta.1 → 0.9.8-beta.3
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 +134 -0
- package/dist/commands/health/data-dir-usage.js +182 -0
- package/dist/commands/health.js +19 -4
- package/dist/commands/migrate-cli.js +24 -5
- package/dist/commands/workflow-cli.js +11 -61
- package/dist/core/config/config-io.js +21 -8
- package/dist/core/state/migrations.js +22 -0
- package/dist/core/warn.js +15 -0
- package/dist/indexer/indexer.js +42 -21
- package/dist/indexer/passes/dir-staleness.js +60 -21
- package/dist/scripts/akm-migrate-node.js +37 -4
- package/dist/scripts/akm-migrate.js +37 -4
- package/dist/storage/repositories/index-entries-repository.js +24 -11
- package/dist/storage/repositories/index-meta-repository.js +6 -4
- package/dist/storage/repositories/index-schema.js +16 -1
- package/dist/storage/repositories/proposals-repository.js +4 -1
- package/dist/tasks/resolve-akm-bin.js +15 -0
- package/dist/tasks/source/task-to-v3.js +10 -2
- package/docs/migration/v0.9.1-to-v0.9.2.md +18 -4
- package/docs/reference/cli.md +1 -1
- package/docs/reference/data-and-telemetry.md +1 -0
- package/docs/reference/tasks.md +11 -0
- package/package.json +1 -5
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,105 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
6
|
|
|
7
|
+
## [0.9.8-beta.3] - 2026-09-02
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **The incremental index no longer misses an edit whose timestamp did not move
|
|
12
|
+
forward.** The per-directory freshness check summarised a directory as its
|
|
13
|
+
file-name set plus the single newest mtime, which lost two kinds of change.
|
|
14
|
+
An edit to any file other than the newest one landed below that maximum and
|
|
15
|
+
was invisible even though its own mtime changed — so a restore, checkout, or
|
|
16
|
+
archive extraction that stamped a plausible older date left stale content in
|
|
17
|
+
the index. And because mtime is writable by ordinary tooling (`touch -r`,
|
|
18
|
+
`rsync --times`, `cp -p`), an edit with a restored timestamp was invisible
|
|
19
|
+
outright. The directory is now digested per file over
|
|
20
|
+
`(basename, size, mtime, ctime)` at nanosecond resolution. It is the same one
|
|
21
|
+
`stat` call per file, so the incremental fast path costs what it did before.
|
|
22
|
+
Both gaps predate 0.9.8 and applied to every earlier release.
|
|
23
|
+
|
|
24
|
+
Trade-off worth knowing: `ctime` also moves on metadata-only changes such as
|
|
25
|
+
`chmod`, and after copying a tree, so those now cost one extra rescan. That
|
|
26
|
+
direction is deliberate — extra work, never stale content. Existing indexes
|
|
27
|
+
rescan once as the digest changes shape, then return to the fast path.
|
|
28
|
+
|
|
29
|
+
- **`akm migrate apply` can now clear a legacy `extraParams` config.** A config
|
|
30
|
+
still carrying a liftable key such as `extraParams.temperature` fails config
|
|
31
|
+
load closed, and that error names `akm migrate apply` as the fix — but the
|
|
32
|
+
migrate command resolved the stash directory and ran the task migrator, both
|
|
33
|
+
of which load config, so it died on the very error it exists to clear. An
|
|
34
|
+
operator hitting this had no reachable way forward. The config lift now runs
|
|
35
|
+
before anything that loads config, and `akm migrate status` reports the
|
|
36
|
+
pending lift as its blocker instead of re-raising the same error. A genuine
|
|
37
|
+
conflict, where an `extraParams` key and its first-class field disagree, still
|
|
38
|
+
hard-rejects and names both values rather than guessing.
|
|
39
|
+
|
|
40
|
+
- **`akm health` no longer warns about disk usage on a fresh install.** The
|
|
41
|
+
`data-dir-usage` advisory added earlier in 0.9.8 counted SQLite's `-wal` and
|
|
42
|
+
`-shm` sidecars toward the data directory's total but not toward the live
|
|
43
|
+
databases they belong to. On an untouched install the write-ahead log is most
|
|
44
|
+
of the directory, so the very first `akm health` reported a ~126x ratio and
|
|
45
|
+
exited `warn` with no user data present. Sidecars now count as part of their
|
|
46
|
+
database, and the advisory stays quiet below 1 GB, where a ratio says nothing
|
|
47
|
+
useful about disk pressure.
|
|
48
|
+
|
|
49
|
+
## [0.9.8-beta.2] - 2026-09-02
|
|
50
|
+
|
|
51
|
+
> **Adds state migration `026-proposals-strip-legacy-fragment-refs`.** The
|
|
52
|
+
> one-way caveat below applies to it as well: once this build opens
|
|
53
|
+
> `state.db`, 0.9.8-beta.1 and earlier refuse it with `unknown migration ID
|
|
54
|
+
> 026-proposals-strip-legacy-fragment-refs`.
|
|
55
|
+
|
|
56
|
+
### Added
|
|
57
|
+
|
|
58
|
+
- **`akm health` reports data-dir disk usage** (#896). A `data-dir-usage`
|
|
59
|
+
advisory sums the data directory with a stat-only walk and warns when it is
|
|
60
|
+
more than 3× the three live databases (state.db, index.db, logs.db) or when
|
|
61
|
+
one top-level subdirectory holds more than half of it, naming that
|
|
62
|
+
subdirectory with its size and share (for example `backups/ is 70G (94% of
|
|
63
|
+
data dir)`). The walk stops after 100,000 entries and says so. Silent when
|
|
64
|
+
nothing looks wrong.
|
|
65
|
+
|
|
66
|
+
### Fixed
|
|
67
|
+
|
|
68
|
+
- **`akm task sync` no longer spawns `npm root --global` on every call** (#901).
|
|
69
|
+
The npm-global-root probe behind `resolveAkmInvocation` is memoized for the
|
|
70
|
+
process, so a `task sync --rebind` cycle spawns npm at most once instead of
|
|
71
|
+
twice, and an installation that loops it every minute stops accumulating an
|
|
72
|
+
npm debug log per spawn.
|
|
73
|
+
- **A blocked v2 task now says how to convert it** (#902, #899). The
|
|
74
|
+
`argv-array-has-no-portable-shell-string` blocker printed by `akm migrate`
|
|
75
|
+
and the `TASK_SCHEMA_VERSION_UNSUPPORTED` read error now state that manual
|
|
76
|
+
conversion is required and name the rewrite (`command:` argv array →
|
|
77
|
+
`run:` string plus `shell:`). The full v2 → v4 field mapping is documented in
|
|
78
|
+
`docs/migration/v0.9.1-to-v0.9.2.md`.
|
|
79
|
+
- **Legacy `#fragment` proposal rows are repaired instead of warned about
|
|
80
|
+
forever** (#898). State migration 026 strips the retired export-fragment
|
|
81
|
+
selector from `proposals.ref` in place so the rows parse again, and an
|
|
82
|
+
unparseable proposal row now warns once per process instead of once per
|
|
83
|
+
read (`akm health --report` read the table seven times).
|
|
84
|
+
|
|
85
|
+
- **A no-op incremental `akm index` no longer costs minutes of CPU** (#900).
|
|
86
|
+
Two causes: the per-directory freshness check ran two full scans of the
|
|
87
|
+
`entries` table for every directory (O(directories × entries)), and every
|
|
88
|
+
file was read, hashed, and parsed before the freshness check decided the
|
|
89
|
+
directory was unchanged. The directory lookup now uses the existing
|
|
90
|
+
`file_path` index, and a stat-based gate over each directory's walked file
|
|
91
|
+
set skips unchanged directories before any file is read. On a synthetic
|
|
92
|
+
800-directory, 4,000-entry corpus a no-op pass fell from ~37 s to under 1 s
|
|
93
|
+
of CPU with identical entries and search results. The persisted directory
|
|
94
|
+
fingerprint now covers every walked file and `index_dir_state` gains a
|
|
95
|
+
`row_count` column; an existing index.db drains each directory once more
|
|
96
|
+
after upgrading, then takes the fast path.
|
|
97
|
+
|
|
98
|
+
- **Task-migration snapshots are capped at the five most recent** (#897).
|
|
99
|
+
`akm migrate apply` writes one snapshot directory per run under
|
|
100
|
+
`backups/task-v3` and `backups/task-v4` and never pruned them; each apply
|
|
101
|
+
now keeps the five newest and removes the rest, the same policy config
|
|
102
|
+
backups already use. Nothing in the current code writes the legacy
|
|
103
|
+
`backups/migrations`, `manual`, `releases`, or `operations` directories,
|
|
104
|
+
so they are left alone; the new health advisory is what surfaces them.
|
|
105
|
+
|
|
7
106
|
## [0.9.8-beta.1] - 2026-09-01
|
|
8
107
|
|
|
9
108
|
A cleanup and stabilization release: deletion of machinery that policed the
|
|
@@ -11,10 +110,45 @@ codebase's shape rather than its behaviour, and — because auditing for that
|
|
|
11
110
|
machinery meant reading the code closely — a run of real defects it had been
|
|
12
111
|
sitting on top of.
|
|
13
112
|
|
|
113
|
+
> **Upgrading is one-way for `state.db`.** This release adds migration
|
|
114
|
+
> `025-task-history-vocabulary-backfill`. Once any 0.9.8 command opens
|
|
115
|
+
> `state.db`, the ledger contains an ID that 0.9.7 does not know, and 0.9.7
|
|
116
|
+
> refuses to open it: `Refusing to open a database with a newer migration
|
|
117
|
+
> ledger: unknown migration ID 025-task-history-vocabulary-backfill`.
|
|
118
|
+
>
|
|
119
|
+
> The refusal is deliberate — an older binary must not write a database whose
|
|
120
|
+
> schema it cannot reason about — but the practical effect is that
|
|
121
|
+
> **downgrading to 0.9.7 requires restoring a `state.db` backup.** Commands
|
|
122
|
+
> that only read the derived index (`akm info`, `akm search`) keep working on
|
|
123
|
+
> 0.9.7; everything that touches `state.db` (`akm health`, `akm task`,
|
|
124
|
+
> `akm improve`, proposals) does not.
|
|
125
|
+
>
|
|
126
|
+
> Snapshot `state.db` before upgrading if you may need to go back:
|
|
127
|
+
>
|
|
128
|
+
> ```sh
|
|
129
|
+
> akm info --format json # confirm your data dir
|
|
130
|
+
> sqlite3 "$DATA_DIR/state.db" "VACUUM INTO '''state.db.pre-0.9.8.bak'''"
|
|
131
|
+
> ```
|
|
132
|
+
|
|
14
133
|
Two security holes, two search-correctness bugs, a locale-dependent hash, a
|
|
15
134
|
deletion shield that failed open, and sixteen places that answered a failure
|
|
16
135
|
with a confident wrong answer instead of an error.
|
|
17
136
|
|
|
137
|
+
### Changed
|
|
138
|
+
|
|
139
|
+
- **`akm workflow plan` returns JSON by default**, like every other command
|
|
140
|
+
(#903). It was the one verb whose unmarked default was a human summary. That
|
|
141
|
+
exception cost a bespoke branch which could not reliably distinguish "no
|
|
142
|
+
format named" from "`--format json` named globally before the subcommand" —
|
|
143
|
+
citty parses each command level against its own argv, so the leaf read
|
|
144
|
+
`undefined` in both cases. Working around that meant reading the invocation
|
|
145
|
+
singleton, folding in a persisted `output.format`, and finally leaving a
|
|
146
|
+
resolved `"json"` on the text branch because it was indistinguishable from
|
|
147
|
+
"nothing configured" — which meant an explicit `--format json` silently did
|
|
148
|
+
nothing for anyone whose config already resolved to json. Deleted, along with
|
|
149
|
+
~60 lines of comment justifying it. `--format text` still renders the same
|
|
150
|
+
summary through the same formatter; it is simply no longer the default.
|
|
151
|
+
|
|
18
152
|
### Fixed
|
|
19
153
|
|
|
20
154
|
- **Historical state migrations are reachable where akm cannot reinstall
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* `data-dir-usage` advisory for `akm health` (#896).
|
|
6
|
+
*
|
|
7
|
+
* A real environment's `$XDG_DATA_HOME/akm` grew to 74 GB with none of the
|
|
8
|
+
* ~20 health checks saying a word about disk. 70 of the 74 GB turned out to
|
|
9
|
+
* be `backups/` (unpruned migration snapshots, see #897); the live working
|
|
10
|
+
* set (state.db + index.db + logs.db) was ~4.2 GB. Naming the largest
|
|
11
|
+
* top-level contributor and its share of the total is most of the value —
|
|
12
|
+
* `backups/ is 70G (94% of data dir)` is self-diagnosing where "akm health
|
|
13
|
+
* says nothing" is not.
|
|
14
|
+
*
|
|
15
|
+
* Best-effort and read-only: a plain recursive `fs` stat walk over the data
|
|
16
|
+
* dir, no `du` shell-out. Silent (returns `undefined`) whenever nothing
|
|
17
|
+
* looks wrong, matching the stash-exposure/type-directory-check house
|
|
18
|
+
* pattern — this is not a "always show a pass line" check.
|
|
19
|
+
*/
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
/**
|
|
23
|
+
* Warn when the data dir's total size is more than this many times the
|
|
24
|
+
* combined size of the three live databases (state.db + index.db +
|
|
25
|
+
* logs.db). Chosen so a healthy install (backups roughly comparable to the
|
|
26
|
+
* live working set) stays quiet, while an order-of-magnitude blowup like
|
|
27
|
+
* the 74 GB/4.2 GB (~17x) incident trips it.
|
|
28
|
+
*/
|
|
29
|
+
const DATA_DIR_BLOAT_RATIO_THRESHOLD = 3;
|
|
30
|
+
/**
|
|
31
|
+
* Warn when a single top-level subdirectory accounts for more than this
|
|
32
|
+
* percentage of the data dir's total size — the "one thing ate the disk"
|
|
33
|
+
* signal (94% for `backups/` in the incident).
|
|
34
|
+
*/
|
|
35
|
+
const DOMINANT_SUBDIR_PERCENT_THRESHOLD = 50;
|
|
36
|
+
/**
|
|
37
|
+
* Cap on the number of filesystem entries the recursive size walk will
|
|
38
|
+
* `stat`. A 70 GB tree of a few thousand backup copies is cheap to walk
|
|
39
|
+
* (stat-only), but a data dir polluted with hundreds of thousands of small
|
|
40
|
+
* files (task logs, npm logs) must not make `akm health` slow. Past this
|
|
41
|
+
* cap the walk stops descending further and the advisory says its size
|
|
42
|
+
* figures are a lower bound.
|
|
43
|
+
*/
|
|
44
|
+
const MAX_WALK_ENTRIES = 100_000;
|
|
45
|
+
/**
|
|
46
|
+
* Below this the data dir is not worth an opinion. The advisory exists for
|
|
47
|
+
* disk blowups (74 GB in the incident); on a small directory a ratio is
|
|
48
|
+
* arithmetic noise, and akm's own housekeeping can dominate it outright.
|
|
49
|
+
*/
|
|
50
|
+
const MIN_TOTAL_BYTES_TO_REPORT = 1_000_000_000;
|
|
51
|
+
const LIVE_DB_FILES = ["state.db", "index.db", "logs.db"];
|
|
52
|
+
/**
|
|
53
|
+
* SQLite writes `-wal` and `-shm` beside each database. They are part of the
|
|
54
|
+
* live working set, not overhead sitting next to it, so they must count as
|
|
55
|
+
* live: on a fresh install the WAL is most of the data dir, and leaving it
|
|
56
|
+
* out of the denominator made an empty install report a ~126x ratio.
|
|
57
|
+
*/
|
|
58
|
+
function liveDbBytesFor(name, sizes) {
|
|
59
|
+
return ((sizes.get(name)?.bytes ?? 0) + (sizes.get(`${name}-wal`)?.bytes ?? 0) + (sizes.get(`${name}-shm`)?.bytes ?? 0));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Recursively sum file sizes under `root` (stat-only, symlinks not
|
|
63
|
+
* followed so a cyclic or huge-target symlink can't blow up the walk).
|
|
64
|
+
* `budget` is a shared mutable counter across the whole tree so the
|
|
65
|
+
* `MAX_WALK_ENTRIES` cap applies to the walk as a whole, not per-branch.
|
|
66
|
+
*/
|
|
67
|
+
function sizeOfPath(root, budget) {
|
|
68
|
+
let stat;
|
|
69
|
+
try {
|
|
70
|
+
stat = fs.lstatSync(root);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return { bytes: 0, truncated: false };
|
|
74
|
+
}
|
|
75
|
+
if (stat.isSymbolicLink())
|
|
76
|
+
return { bytes: 0, truncated: false };
|
|
77
|
+
if (!stat.isDirectory())
|
|
78
|
+
return { bytes: stat.size, truncated: false };
|
|
79
|
+
let entries;
|
|
80
|
+
try {
|
|
81
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return { bytes: 0, truncated: false };
|
|
85
|
+
}
|
|
86
|
+
let bytes = 0;
|
|
87
|
+
let truncated = false;
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
if (budget.remaining <= 0) {
|
|
90
|
+
truncated = true;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
budget.remaining--;
|
|
94
|
+
const sub = sizeOfPath(path.join(root, entry.name), budget);
|
|
95
|
+
bytes += sub.bytes;
|
|
96
|
+
if (sub.truncated)
|
|
97
|
+
truncated = true;
|
|
98
|
+
}
|
|
99
|
+
return { bytes, truncated };
|
|
100
|
+
}
|
|
101
|
+
/** `1610612736` -> `"1.5G"`. Values under 10 in a unit keep one decimal; 10+ round to an integer. */
|
|
102
|
+
function formatBytes(bytes) {
|
|
103
|
+
const units = ["B", "K", "M", "G", "T"];
|
|
104
|
+
let value = bytes;
|
|
105
|
+
let unit = 0;
|
|
106
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
107
|
+
value /= 1024;
|
|
108
|
+
unit++;
|
|
109
|
+
}
|
|
110
|
+
const rendered = unit === 0 ? String(Math.round(value)) : value < 10 ? value.toFixed(1) : String(Math.round(value));
|
|
111
|
+
return `${rendered}${units[unit]}`;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Build the `data-dir-usage` advisory, or `undefined` when the data dir is
|
|
115
|
+
* missing/empty/unreadable or its size looks unremarkable (neither
|
|
116
|
+
* threshold trips). `dataDir` is the caller-resolved `getDataDir()` path —
|
|
117
|
+
* this module never resolves paths or reads env itself.
|
|
118
|
+
*/
|
|
119
|
+
export function collectDataDirUsageAdvisory(dataDir) {
|
|
120
|
+
let topEntries;
|
|
121
|
+
try {
|
|
122
|
+
topEntries = fs.readdirSync(dataDir, { withFileTypes: true });
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return undefined; // no data dir yet — nothing to report.
|
|
126
|
+
}
|
|
127
|
+
const budget = { remaining: MAX_WALK_ENTRIES };
|
|
128
|
+
let totalBytes = 0;
|
|
129
|
+
let truncated = false;
|
|
130
|
+
const sizes = new Map();
|
|
131
|
+
for (const entry of topEntries) {
|
|
132
|
+
const size = sizeOfPath(path.join(dataDir, entry.name), budget);
|
|
133
|
+
totalBytes += size.bytes;
|
|
134
|
+
if (size.truncated)
|
|
135
|
+
truncated = true;
|
|
136
|
+
sizes.set(entry.name, { bytes: size.bytes, isDirectory: entry.isDirectory() });
|
|
137
|
+
}
|
|
138
|
+
if (totalBytes === 0)
|
|
139
|
+
return undefined;
|
|
140
|
+
const subdirs = [...sizes]
|
|
141
|
+
.filter(([, size]) => size.isDirectory)
|
|
142
|
+
.map(([name, size]) => ({ name, bytes: size.bytes, percent: (size.bytes / totalBytes) * 100 }))
|
|
143
|
+
.sort((a, b) => b.bytes - a.bytes);
|
|
144
|
+
const largest = subdirs[0];
|
|
145
|
+
const liveDbBreakdown = Object.fromEntries(LIVE_DB_FILES.map((f) => [f, liveDbBytesFor(f, sizes)]));
|
|
146
|
+
const liveDbBytes = Object.values(liveDbBreakdown).reduce((a, b) => a + b, 0);
|
|
147
|
+
const ratio = liveDbBytes > 0 ? totalBytes / liveDbBytes : undefined;
|
|
148
|
+
if (totalBytes < MIN_TOTAL_BYTES_TO_REPORT)
|
|
149
|
+
return undefined;
|
|
150
|
+
const bloatWarn = ratio !== undefined && ratio > DATA_DIR_BLOAT_RATIO_THRESHOLD;
|
|
151
|
+
const dominantWarn = largest !== undefined && largest.percent > DOMINANT_SUBDIR_PERCENT_THRESHOLD;
|
|
152
|
+
if (!bloatWarn && !dominantWarn)
|
|
153
|
+
return undefined;
|
|
154
|
+
const parts = [`data dir is ${formatBytes(totalBytes)} at ${dataDir}`];
|
|
155
|
+
if (largest) {
|
|
156
|
+
parts.push(`${largest.name}/ is ${formatBytes(largest.bytes)} (${Math.round(largest.percent)}% of data dir)`);
|
|
157
|
+
}
|
|
158
|
+
if (ratio !== undefined) {
|
|
159
|
+
parts.push(`live databases (${LIVE_DB_FILES.join("+")}) total ${formatBytes(liveDbBytes)}, ~${ratio.toFixed(1)}x smaller`);
|
|
160
|
+
}
|
|
161
|
+
if (truncated) {
|
|
162
|
+
parts.push(`size figures are a lower bound — the walk stopped after ${MAX_WALK_ENTRIES} entries`);
|
|
163
|
+
}
|
|
164
|
+
const message = `${parts.join("; ")}.`;
|
|
165
|
+
return {
|
|
166
|
+
name: "data-dir-usage",
|
|
167
|
+
kind: "deterministic",
|
|
168
|
+
status: "warn",
|
|
169
|
+
confidence: "medium",
|
|
170
|
+
message,
|
|
171
|
+
evidence: {
|
|
172
|
+
dataDir,
|
|
173
|
+
totalBytes,
|
|
174
|
+
liveDbBytes,
|
|
175
|
+
liveDbBreakdown,
|
|
176
|
+
largestSubdir: largest,
|
|
177
|
+
ratio,
|
|
178
|
+
walkBounded: truncated,
|
|
179
|
+
maxWalkEntries: MAX_WALK_ENTRIES,
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
package/dist/commands/health.js
CHANGED
|
@@ -9,7 +9,7 @@ import { ConfigError, UsageError } from "../core/errors.js";
|
|
|
9
9
|
import { readEvents } from "../core/events.js";
|
|
10
10
|
import { openLogsDatabase } from "../core/logs-db.js";
|
|
11
11
|
import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js";
|
|
12
|
-
import { getConfigPath, getDbPath, getStateDbPathInDataDir } from "../core/paths.js";
|
|
12
|
+
import { getConfigPath, getDataDir, getDbPath, getStateDbPathInDataDir } from "../core/paths.js";
|
|
13
13
|
import { listExistingTableNames, openStateDatabase } from "../core/state-db.js";
|
|
14
14
|
import { DURATION_UNITS, parseDuration, parseSinceToIso } from "../core/time.js";
|
|
15
15
|
import { closeDatabase, openReadonlyExistingDatabase } from "../storage/repositories/index-connection.js";
|
|
@@ -18,6 +18,7 @@ import { queryTaskHistory } from "../storage/repositories/task-history-repositor
|
|
|
18
18
|
import { pkgVersion } from "../version.js";
|
|
19
19
|
import { collectImproveAdvisories } from "./health/advisories.js";
|
|
20
20
|
import { HEALTH_CHECKS, runHealthEngineProbes } from "./health/checks.js";
|
|
21
|
+
import { collectDataDirUsageAdvisory } from "./health/data-dir-usage.js";
|
|
21
22
|
import { buildImproveSkipSummary, computeWallTimeStats, isAgentTaskHistoryRow, roundRate, summarizeImproveCompleted, summarizeImproveRuns, taskFailureDetail, } from "./health/improve-metrics.js";
|
|
22
23
|
import { emptyLlmUsageAggregate, readLlmUsageAggregate } from "./health/llm-usage.js";
|
|
23
24
|
import { computeDegradationMetrics, computeDenominatorFixedCoverage, computeEnrichmentMintingRollup, probeStateDbRoundTrip, } from "./health/metrics.js";
|
|
@@ -197,9 +198,10 @@ function gatherImproveSummaryPhase(db, stateDbPath, since, now) {
|
|
|
197
198
|
return { improveSummary, perRunSummaries };
|
|
198
199
|
}
|
|
199
200
|
/**
|
|
200
|
-
* The
|
|
201
|
-
*
|
|
202
|
-
* (binary-config-skew, egress-endpoints),
|
|
201
|
+
* The best-effort advisory groups beyond the health-check registry: improve
|
|
202
|
+
* advisories, the `stash-git-exposure` probe, the 08 surfaces group
|
|
203
|
+
* (binary-config-skew, egress-endpoints), `type-directory-disagreement`
|
|
204
|
+
* (#831), `data-dir-usage` (#896), and `plugin-version` (itlackey/akm#832).
|
|
203
205
|
* Order matches emission order in the returned array. A probe/filesystem
|
|
204
206
|
* failure in any try/catch must not abort the health report — each group
|
|
205
207
|
* degrades to "no advisory" independently.
|
|
@@ -251,6 +253,19 @@ function gatherAncillaryAdvisories(db, stateDbPath, since, improveSummary, optio
|
|
|
251
253
|
catch {
|
|
252
254
|
// Non-fatal.
|
|
253
255
|
}
|
|
256
|
+
// #896: report the data dir's total size and its largest top-level
|
|
257
|
+
// subdirectory, so a disk-usage blowup (e.g. unpruned migration snapshot
|
|
258
|
+
// backups, #897) is self-diagnosing instead of requiring `du` archaeology.
|
|
259
|
+
// Best-effort — an unreadable/missing data dir must not abort the health
|
|
260
|
+
// report.
|
|
261
|
+
try {
|
|
262
|
+
const dataDirUsage = collectDataDirUsageAdvisory(getDataDir());
|
|
263
|
+
if (dataDirUsage)
|
|
264
|
+
advisories.push(dataDirUsage);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
// Non-fatal.
|
|
268
|
+
}
|
|
254
269
|
// itlackey/akm#832: report installed Claude Code harness plugin version(s)
|
|
255
270
|
// and warn when stale or when the plugin's own akm-cli version range no
|
|
256
271
|
// longer admits this CLI. Best-effort — no plugin installed, an unreadable
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
4
|
import { defineGroupCommand, defineJsonCommand, EXIT_CODES, output } from "../cli/shared.js";
|
|
5
5
|
import { resolveStashDir } from "../core/common.js";
|
|
6
|
+
import { resetConfigCache } from "../core/config/config.js";
|
|
6
7
|
import { ConfigError } from "../core/errors.js";
|
|
7
8
|
import { getConfigPath } from "../core/paths.js";
|
|
8
9
|
import { applyConfigExtraParamsLift, findConfigExtraParamsLift } from "./migrate/config-extra-params.js";
|
|
@@ -94,6 +95,29 @@ export async function runMigrateSubcommand(command, genOneArgs, genTwoArgs, runT
|
|
|
94
95
|
// No configured bundle means there is no stash to scan — an empty domain,
|
|
95
96
|
// not an error — so migrate still works before `akm bundle create`. Any
|
|
96
97
|
// OTHER ConfigError propagates.
|
|
98
|
+
const configPath = getConfigPath();
|
|
99
|
+
const applyResidue = command === "migrate-apply" && !genOneArgs.includes("--dry-run");
|
|
100
|
+
// The config lift runs BEFORE anything that loads config. A config still
|
|
101
|
+
// carrying legacy extraParams keys fails `loadConfig` closed, and the error
|
|
102
|
+
// it fails with names `akm migrate apply` as the remedy -- but both
|
|
103
|
+
// `resolveStashDir` below and the task migrator itself load config, so that
|
|
104
|
+
// remedy could never reach the lift that fixes it. Applying it first is what
|
|
105
|
+
// makes the advice true. `resetConfigCache` so every load below sees the
|
|
106
|
+
// rewritten file rather than the rejected one.
|
|
107
|
+
const configExtraParams = applyResidue
|
|
108
|
+
? applyConfigExtraParamsLift(configPath)
|
|
109
|
+
: { pending: findConfigExtraParamsLift(configPath) };
|
|
110
|
+
if (applyResidue && configExtraParams.applied)
|
|
111
|
+
resetConfigCache();
|
|
112
|
+
// status and --dry-run cannot rewrite the file, so a pending lift still
|
|
113
|
+
// blocks every config load below. Report it as the blocker rather than
|
|
114
|
+
// letting the operator hit the same circular error again.
|
|
115
|
+
const pendingLift = applyResidue ? undefined : configExtraParams.pending;
|
|
116
|
+
if (pendingLift && pendingLift.lifted.length > 0) {
|
|
117
|
+
output(command, { status: "blocked", blockers: pendingLift.lifted, configExtraParams });
|
|
118
|
+
process.exitCode = EXIT_CODES.GENERAL;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
97
121
|
let stashDir;
|
|
98
122
|
try {
|
|
99
123
|
stashDir = resolveStashDir();
|
|
@@ -102,8 +126,6 @@ export async function runMigrateSubcommand(command, genOneArgs, genTwoArgs, runT
|
|
|
102
126
|
if (!(error instanceof ConfigError) || error.code !== "STASH_DIR_NOT_FOUND")
|
|
103
127
|
throw error;
|
|
104
128
|
}
|
|
105
|
-
const configPath = getConfigPath();
|
|
106
|
-
const applyResidue = command === "migrate-apply" && !genOneArgs.includes("--dry-run");
|
|
107
129
|
const first = await callMigrateTool(genOneArgs, runTool);
|
|
108
130
|
if (first.status !== EXIT_CODES.SUCCESS && first.status !== EXIT_CODES.GENERAL) {
|
|
109
131
|
process.exitCode = first.status;
|
|
@@ -133,9 +155,6 @@ export async function runMigrateSubcommand(command, genOneArgs, genTwoArgs, runT
|
|
|
133
155
|
? { recovered: await recoverStaleTxns(stashDir) }
|
|
134
156
|
: { pending: findStaleTxnEntries(stashDir) },
|
|
135
157
|
};
|
|
136
|
-
const configExtraParams = applyResidue
|
|
137
|
-
? applyConfigExtraParamsLift(configPath)
|
|
138
|
-
: { pending: findConfigExtraParamsLift(configPath) };
|
|
139
158
|
output(command, { ...combined, ...stashSections, configExtraParams });
|
|
140
159
|
if (combined.status === "blocked")
|
|
141
160
|
process.exitCode = EXIT_CODES.GENERAL;
|
|
@@ -7,18 +7,12 @@
|
|
|
7
7
|
* `create --print` emits Markdown; execution accepts peer `.md` and
|
|
8
8
|
* GitHub-shaped `.yml` workflow sources. Validate with `akm lint --type workflows`.
|
|
9
9
|
*/
|
|
10
|
-
import { getParsedInvocation } from "../cli/invocation.js";
|
|
11
10
|
import { getStringArg } from "../cli/parse-args.js";
|
|
12
11
|
import { defineGroupCommand, defineJsonCommand, EXIT_CODES, output } from "../cli/shared.js";
|
|
13
12
|
import { armAbortDeadline } from "../core/abort-deadline.js";
|
|
14
13
|
import { assertFlatAssetName, combineCreatePath, normalizeCreateSubPath } from "../core/asset/asset-create.js";
|
|
15
14
|
import { NotFoundError, UsageError } from "../core/errors.js";
|
|
16
15
|
import { akmIndex } from "../indexer/indexer.js";
|
|
17
|
-
import { getOutputMode } from "../output/context.js";
|
|
18
|
-
import { renderGenericText } from "../output/generic-render.js";
|
|
19
|
-
import { deliverRendered } from "../output/html-render.js";
|
|
20
|
-
import { shapeForCommand } from "../output/shapes.js";
|
|
21
|
-
import { formatPlain } from "../output/text.js";
|
|
22
16
|
import { assertWorkflowMarkdownName, createWorkflowAsset, getWorkflowTemplate } from "../workflows/authoring/authoring.js";
|
|
23
17
|
import { WORKFLOW_MAX_TIMEOUT_MS } from "../workflows/ir/schema.js";
|
|
24
18
|
import { abandonWorkflowRun, getWorkflowStatus, hasWorkflowRun, listWorkflowRuns, resumeWorkflowRun, } from "../workflows/runtime/runs.js";
|
|
@@ -319,61 +313,17 @@ const workflowPlanCommand = defineJsonCommand({
|
|
|
319
313
|
},
|
|
320
314
|
async run({ args }) {
|
|
321
315
|
const result = await akmWorkflowPlan(args.ref);
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
// (e.g. `akm --format json workflow plan <ref>`) is consumed by the ROOT
|
|
334
|
-
// command's own declared `format` arg before the `workflow`/`plan`
|
|
335
|
-
// subcommand tokens are even resolved — this LEAF's `args.format` reads
|
|
336
|
-
// `undefined` in exactly that case too, indistinguishable from "no
|
|
337
|
-
// format was named anywhere". Reproduced live: that invocation printed
|
|
338
|
-
// the human TEXT summary at exit 0 even though `getOutputMode().format`
|
|
339
|
-
// was already `"json"` (the control, `akm --format json workflow list`,
|
|
340
|
-
// correctly emitted JSON — only this leaf's own arg-read was wrong).
|
|
341
|
-
// Detect it instead off the process-wide invocation singleton
|
|
342
|
-
// (`getParsedInvocation`, src/cli/invocation.ts) — the same canonical,
|
|
343
|
-
// position-independent argv parse `src/cli.ts` mints ONCE at startup
|
|
344
|
-
// (`setParsedInvocation`, immediately before `initOutputMode` builds the
|
|
345
|
-
// `getOutputMode()` singleton from that identical argv), so this agrees
|
|
346
|
-
// with `getOutputMode()` regardless of where `--format` appeared. A bare
|
|
347
|
-
// `process.argv` read is reserved for `src/cli.ts`/`cli/invocation.ts`
|
|
348
|
-
// themselves (`lint-process-argv.ts`); every other module reads through
|
|
349
|
-
// this singleton instead. When explicit, this defers to the normal
|
|
350
|
-
// `output()` path (json/yaml/text/md/html/jsonl, `--output <path>`)
|
|
351
|
-
// unchanged; when absent, it reproduces `output()`'s OWN "text" branch
|
|
352
|
-
// verbatim (same shape/detail projection, same registered-formatter-or-
|
|
353
|
-
// generic-fallback, same `--output <path>` handling) without touching
|
|
354
|
-
// the shared dispatcher other commands rely on.
|
|
355
|
-
//
|
|
356
|
-
// "No format named anywhere" also has to check the RESOLVED mode, not
|
|
357
|
-
// just argv: `getOutputMode().format` already folds a persisted
|
|
358
|
-
// `output.format` config default in ahead of the hardcoded "json"
|
|
359
|
-
// fallback (`resolveOutputMode`, src/output/context.ts — argv ?? config
|
|
360
|
-
// default ?? "json"). A user who has configured e.g. `output.format:
|
|
361
|
-
// "yaml"` gets yaml from every other command with no `--format` on the
|
|
362
|
-
// line; `workflow plan` must honor that too instead of forcing its
|
|
363
|
-
// human-text branch over a real persisted default. A resolved format of
|
|
364
|
-
// exactly "json" is deliberately left on the text branch below: it's
|
|
365
|
-
// indistinguishable from "nothing configured" (DEFAULT_CONFIG.output.format
|
|
366
|
-
// is also "json" — OutputConfigSchema's `format` carries no independent
|
|
367
|
-
// zod default, so the merge is the only source), and collapsing that case
|
|
368
|
-
// onto the JSON envelope would erase the documented unmarked-default text
|
|
369
|
-
// summary for the overwhelmingly common "user configured nothing" case.
|
|
370
|
-
const mode = getOutputMode();
|
|
371
|
-
if (getParsedInvocation().getFlagValue("--format") === undefined && mode.format === "json") {
|
|
372
|
-
const shaped = shapeForCommand("workflow-plan", result, mode.detail, mode.shape);
|
|
373
|
-
const plain = formatPlain("workflow-plan", shaped, mode.detail);
|
|
374
|
-
deliverRendered(plain ?? renderGenericText("workflow-plan", shaped), mode.outputPath);
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
316
|
+
// json-by-default, like every other verb (#903). This used to default to
|
|
317
|
+
// the human summary, which cost ~60 lines of branch: `args.format` cannot
|
|
318
|
+
// detect "no format named" (citty parses per level, so a global
|
|
319
|
+
// pre-subcommand `--format json` is eaten by the ROOT command and the leaf
|
|
320
|
+
// reads undefined), so it had to route through `getParsedInvocation()`,
|
|
321
|
+
// then fold in a persisted `output.format`, and then still leave a
|
|
322
|
+
// resolved "json" on the text branch because that is indistinguishable
|
|
323
|
+
// from "nothing configured". The last compromise meant an explicit
|
|
324
|
+
// `--format json` silently did nothing for anyone whose config already
|
|
325
|
+
// resolved to json. `--format text` still renders the summary through the
|
|
326
|
+
// registered formatter; it is just no longer the unmarked default.
|
|
377
327
|
output("workflow-plan", result);
|
|
378
328
|
},
|
|
379
329
|
});
|
|
@@ -115,17 +115,30 @@ export function backupExistingConfig(configPath, now = new Date()) {
|
|
|
115
115
|
return { timestamped, latest };
|
|
116
116
|
}
|
|
117
117
|
function pruneOldBackups(backupDir) {
|
|
118
|
+
pruneToNewest(backupDir, MAX_CONFIG_BACKUPS, (entry) => entry.isFile() && isTimestampedConfigBackup(entry.name));
|
|
119
|
+
}
|
|
120
|
+
function isTimestampedConfigBackup(name) {
|
|
121
|
+
return name.startsWith("config-") && name.endsWith(".json") && name !== "config.latest.json";
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Keep the `keep` most-recently-modified entries of `dir` that `select`
|
|
125
|
+
* admits and remove the rest. Best-effort: an unreadable dir is a no-op, an
|
|
126
|
+
* unreadable entry sorts oldest (pruned first), and a failed removal is
|
|
127
|
+
* retried by the next call. Shared by the config backups above and the task
|
|
128
|
+
* migration snapshots (#897).
|
|
129
|
+
*/
|
|
130
|
+
export function pruneToNewest(dir, keep, select) {
|
|
118
131
|
let entries;
|
|
119
132
|
try {
|
|
120
|
-
entries = fs.readdirSync(
|
|
133
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
121
134
|
}
|
|
122
135
|
catch {
|
|
123
136
|
return;
|
|
124
137
|
}
|
|
125
|
-
const
|
|
126
|
-
.filter(
|
|
127
|
-
.map((
|
|
128
|
-
const full = path.join(
|
|
138
|
+
const candidates = entries
|
|
139
|
+
.filter(select)
|
|
140
|
+
.map((entry) => {
|
|
141
|
+
const full = path.join(dir, entry.name);
|
|
129
142
|
let mtime = 0;
|
|
130
143
|
try {
|
|
131
144
|
mtime = fs.statSync(full).mtimeMs;
|
|
@@ -136,12 +149,12 @@ function pruneOldBackups(backupDir) {
|
|
|
136
149
|
return { path: full, mtime };
|
|
137
150
|
})
|
|
138
151
|
.sort((a, b) => b.mtime - a.mtime);
|
|
139
|
-
for (const stale of
|
|
152
|
+
for (const stale of candidates.slice(keep)) {
|
|
140
153
|
try {
|
|
141
|
-
fs.
|
|
154
|
+
fs.rmSync(stale.path, { recursive: true, force: true });
|
|
142
155
|
}
|
|
143
156
|
catch {
|
|
144
|
-
// Best-effort prune; next
|
|
157
|
+
// Best-effort prune; the next call will retry.
|
|
145
158
|
}
|
|
146
159
|
}
|
|
147
160
|
}
|
|
@@ -36,6 +36,7 @@ export const STATE_MIGRATION_SAFETY_BY_ID = Object.freeze({
|
|
|
36
36
|
"023-child-workflow-runs": "additive",
|
|
37
37
|
"024-workflow-run-outputs": "additive",
|
|
38
38
|
"025-task-history-vocabulary-backfill": "data-preserving-rebuild",
|
|
39
|
+
"026-proposals-strip-legacy-fragment-refs": "data-preserving-rebuild",
|
|
39
40
|
});
|
|
40
41
|
export const STATE_MIGRATIONS = [
|
|
41
42
|
// ── Migration 001 — initial schema ──────────────────────────────────────────
|
|
@@ -1127,6 +1128,27 @@ export const STATE_MIGRATIONS = [
|
|
|
1127
1128
|
AND json_extract(metadata_json, '$.targetVocab') IS NULL;
|
|
1128
1129
|
`,
|
|
1129
1130
|
},
|
|
1131
|
+
// ── Migration 026 — strip the retired proposal-ref export fragment (#898) ──
|
|
1132
|
+
//
|
|
1133
|
+
// Older releases could write a proposal `ref` carrying the export-fragment
|
|
1134
|
+
// selector (`[bundle//]conceptId#fragment`, see src/core/asset/asset-ref.ts).
|
|
1135
|
+
// `currentProposalRef` (proposals-repository.ts) now rejects any fragment, so
|
|
1136
|
+
// those rows failed to parse on every read and could not be listed, repaired,
|
|
1137
|
+
// or deleted through the CLI. Every surface that still reads an archived row
|
|
1138
|
+
// is concept-scoped, so the fragment carries nothing they use.
|
|
1139
|
+
//
|
|
1140
|
+
// `#` is legal in a ref only as the fragment separator, so truncating at the
|
|
1141
|
+
// first `#` is exactly the split `parseBundleRef` performs. `ref` has no
|
|
1142
|
+
// UNIQUE constraint, and no writer ever produced an empty concept id, so
|
|
1143
|
+
// every legacy row normalizes without a drop path.
|
|
1144
|
+
{
|
|
1145
|
+
id: "026-proposals-strip-legacy-fragment-refs",
|
|
1146
|
+
up: `
|
|
1147
|
+
UPDATE proposals
|
|
1148
|
+
SET ref = substr(ref, 1, instr(ref, '#') - 1)
|
|
1149
|
+
WHERE ref LIKE '%#%';
|
|
1150
|
+
`,
|
|
1151
|
+
},
|
|
1130
1152
|
];
|
|
1131
1153
|
assertMigrationRegistry(STATE_MIGRATIONS);
|
|
1132
1154
|
function assertStateMigrationSafetyRegistry() {
|
package/dist/core/warn.js
CHANGED
|
@@ -122,6 +122,21 @@ export function warn(...args) {
|
|
|
122
122
|
console.warn(...args);
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
|
+
const warnedOnceKeys = new Set();
|
|
126
|
+
/**
|
|
127
|
+
* `warn()` at most once per process for a given `key`. For diagnostics that
|
|
128
|
+
* would otherwise repeat on every read of the same bad row or value.
|
|
129
|
+
*/
|
|
130
|
+
export function warnOnce(key, ...args) {
|
|
131
|
+
if (warnedOnceKeys.has(key))
|
|
132
|
+
return;
|
|
133
|
+
warnedOnceKeys.add(key);
|
|
134
|
+
warn(...args);
|
|
135
|
+
}
|
|
136
|
+
/** TEST-ONLY. Forget every `warnOnce` key so a test can re-trigger a warning. */
|
|
137
|
+
export function _resetWarnOnceForTests() {
|
|
138
|
+
warnedOnceKeys.clear();
|
|
139
|
+
}
|
|
125
140
|
/**
|
|
126
141
|
* Emit an error to stderr unless --quiet is active.
|
|
127
142
|
* Always written to the log file if one is active.
|