akm-cli 0.9.10 → 0.9.11
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 +60 -0
- package/STABILITY.md +22 -14
- package/dist/commands/health/checks.js +23 -7
- package/dist/commands/health/improve-metrics.js +12 -0
- package/dist/commands/improve/distill/quality-gate.js +13 -5
- package/dist/commands/improve/eval-cases.js +9 -2
- package/dist/commands/improve/improve.js +19 -4
- package/dist/commands/improve/loop-stages.js +13 -3
- package/dist/commands/tasks/tasks-cli.js +32 -0
- package/dist/commands/tasks/validate.js +186 -0
- package/dist/commands/url-checker.js +75 -16
- package/dist/core/bundle-id.js +7 -1
- package/dist/core/config/schema/engines.js +17 -0
- package/dist/core/improve-result.js +8 -0
- package/dist/core/paths.js +112 -0
- package/dist/indexer/search/search-source.js +3 -2
- package/dist/integrations/agent/engine-resolution.js +92 -3
- package/dist/integrations/agent/execution-lowering.js +15 -2
- package/dist/integrations/agent/runner-dispatch.js +16 -3
- package/dist/integrations/agent/runner.js +2 -0
- package/dist/output/shapes/passthrough.js +1 -0
- package/dist/scripts/akm-migrate-node.js +1043 -822
- package/dist/scripts/akm-migrate.js +1043 -822
- package/dist/tasks/scheduler-sync.js +51 -25
- package/dist/workflows/exec/dispatch-redaction.js +21 -7
- package/docs/integration/bundling-akm.md +1 -1
- package/docs/migration/v0.8-to-v0.9.md +32 -0
- package/docs/reference/cli.md +31 -5
- package/docs/reference/configuration.md +12 -2
- package/docs/reference/data-and-telemetry.md +1 -1
- package/docs/reference/tasks.md +8 -0
- package/package.json +1 -1
- package/schemas/akm-config.json +8 -0
|
@@ -0,0 +1,186 @@
|
|
|
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
|
+
* `akm task validate <path>` (#907) — parse ONE task file by filesystem
|
|
6
|
+
* path, not a concept ref/id, and report the same diagnostic `akm task
|
|
7
|
+
* sync` would produce for it. The file need not live in any configured
|
|
8
|
+
* bundle: unlike every other `akm task` subcommand, this one never resolves
|
|
9
|
+
* a bundle/adapter/concept id at all — it reads exactly the path it was
|
|
10
|
+
* given and classifies it.
|
|
11
|
+
*
|
|
12
|
+
* Reuses the exact version-routing shim `parseTaskSource`
|
|
13
|
+
* (`src/tasks/source/parse-task-source.ts`) already applies for every other
|
|
14
|
+
* task-source reader (`akm task sync`'s `compileTaskSources` included) —
|
|
15
|
+
* this module never forks a second parser or a second v2/v3 migration
|
|
16
|
+
* planner. `readBoundedTaskSourceYaml` / `peekTaskSourceVersion` / `own` are
|
|
17
|
+
* the SAME front-end helpers that shim itself calls first; they are used
|
|
18
|
+
* here only to recover the file's ORIGINALLY DECLARED schema version for
|
|
19
|
+
* the report, because `parseTaskSource`'s own `ParsedTaskSource.version` is
|
|
20
|
+
* always `4` post-shim — it cannot answer "was this a v2/v3/v4 file?" on
|
|
21
|
+
* its own once a v2/v3 source has been converted in memory.
|
|
22
|
+
*
|
|
23
|
+
* Beyond parsing, this module also runs the SAME two per-source gates
|
|
24
|
+
* `akm task sync`'s `compileTaskSources` runs before it ever installs a
|
|
25
|
+
* schedule — `assertTaskScheduleInputsSatisfyContract` and
|
|
26
|
+
* `assertTaskScheduleCronValid` (both extracted from `scheduler-sync.ts` for
|
|
27
|
+
* exactly this reuse) — so a file `sync` would reject can never
|
|
28
|
+
* be reported `valid`/`converts` here. Cron dialect is checked against
|
|
29
|
+
* `backendNameForPlatform()`, the same platform default `sync` falls back
|
|
30
|
+
* to whenever it has no injected/native-inspected backend to hand (see
|
|
31
|
+
* `akmTasksAdd`, `src/commands/tasks/tasks.ts`); a bare file was never
|
|
32
|
+
* installed anywhere; there is no native scheduler state to inspect for it.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately DOES NOT call `prepareTaskV3Execution` (the function
|
|
35
|
+
* `compileTaskSources` calls between those two gates) or resolve an
|
|
36
|
+
* execution engine: that path resolves a composed command/persona ref
|
|
37
|
+
* against the local index and lowers the task's cascade-composed
|
|
38
|
+
* engine/model — both of which assume a real, indexed bundle and a
|
|
39
|
+
* configured engine. A bare file passed to `validate` has neither, so
|
|
40
|
+
* running that step would make an otherwise-valid command-kind task report
|
|
41
|
+
* `invalid` on any machine with no engine configured. `resolved`
|
|
42
|
+
* is therefore the compiled task shape `sync` itself would build a
|
|
43
|
+
* scheduler binding from — id, the compiled v4 `version`, `target`
|
|
44
|
+
* (`uses`/`run`), the declared `inputs` contract, and `schedule` bindings —
|
|
45
|
+
* never an execution-lowered plan.
|
|
46
|
+
*
|
|
47
|
+
* Outcome classification (mirrors `parse-task-source.ts`'s own routing
|
|
48
|
+
* table in its header, extended for the two gates above):
|
|
49
|
+
* - `valid` — parses as task source v4 directly (declared `version: 4`)
|
|
50
|
+
* and passes both sync gates.
|
|
51
|
+
* - `converts` — declared `version: 2` or `3`; the deterministic
|
|
52
|
+
* in-memory migrator produced a valid v4 document that
|
|
53
|
+
* passes both sync gates.
|
|
54
|
+
* - `blocked` — declared `version: 2` or `3`; the migrator itself
|
|
55
|
+
* could not convert it (an ambiguous/unmigratable
|
|
56
|
+
* shape) — the ONLY way `parseTaskSource` ever throws
|
|
57
|
+
* for those two version numbers, so no message-text
|
|
58
|
+
* sniffing is needed to tell this apart from `invalid`.
|
|
59
|
+
* - `invalid` — the document declares SOME version (`4`, or anything
|
|
60
|
+
* other than 2/3/4) but fails to parse/validate, OR it
|
|
61
|
+
* parsed (directly or via a SUCCESSFUL v2/v3
|
|
62
|
+
* conversion) but fails one of the two sync gates
|
|
63
|
+
* above, OR the YAML itself does not parse at all
|
|
64
|
+
* (a genuine syntax error, not merely a non-task
|
|
65
|
+
* shape) — reported with the parser's own reason.
|
|
66
|
+
* - `not-a-task` — the document parses as YAML but never declares a
|
|
67
|
+
* `version:` field at all (or isn't a YAML mapping) —
|
|
68
|
+
* the strongest signal available that the file was
|
|
69
|
+
* never intended as a task source in the first place.
|
|
70
|
+
*/
|
|
71
|
+
import fs from "node:fs";
|
|
72
|
+
import path from "node:path";
|
|
73
|
+
import { UsageError } from "../../core/errors.js";
|
|
74
|
+
import { backendNameForPlatform } from "../../tasks/backends/index.js";
|
|
75
|
+
import { assertTaskScheduleCronValid, assertTaskScheduleInputsSatisfyContract } from "../../tasks/scheduler-sync.js";
|
|
76
|
+
import { own, readBoundedTaskSourceYaml } from "../../tasks/source/bounded-document.js";
|
|
77
|
+
import { parseTaskSource, peekTaskSourceVersion } from "../../tasks/source/parse-task-source.js";
|
|
78
|
+
/** True when `root` is a YAML mapping that itself declares a `version:` key, regardless of that key's type/value. */
|
|
79
|
+
function declaresVersionKey(root) {
|
|
80
|
+
return root !== null && typeof root === "object" && !Array.isArray(root) && own(root, "version");
|
|
81
|
+
}
|
|
82
|
+
function buildResolved(id, v4) {
|
|
83
|
+
return {
|
|
84
|
+
id,
|
|
85
|
+
version: v4.version,
|
|
86
|
+
...(v4.name !== undefined ? { name: v4.name } : {}),
|
|
87
|
+
...(v4.description !== undefined ? { description: v4.description } : {}),
|
|
88
|
+
target: v4.target,
|
|
89
|
+
inputs: v4.inputs ?? {},
|
|
90
|
+
schedule: v4.schedule,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
export async function akmTaskValidate(filePath) {
|
|
94
|
+
const resolvedPath = path.resolve(filePath);
|
|
95
|
+
let stat;
|
|
96
|
+
try {
|
|
97
|
+
stat = fs.statSync(resolvedPath);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
throw new UsageError(`Task file not found: ${JSON.stringify(filePath)}.`, "INVALID_FLAG_VALUE");
|
|
101
|
+
}
|
|
102
|
+
if (!stat.isFile()) {
|
|
103
|
+
throw new UsageError(`${JSON.stringify(filePath)} is not a regular file.`, "INVALID_FLAG_VALUE");
|
|
104
|
+
}
|
|
105
|
+
let yaml;
|
|
106
|
+
try {
|
|
107
|
+
yaml = fs.readFileSync(resolvedPath, "utf8");
|
|
108
|
+
}
|
|
109
|
+
catch (cause) {
|
|
110
|
+
throw new UsageError(`Task file ${JSON.stringify(filePath)} could not be read: ${cause instanceof Error ? cause.message : String(cause)}`, "INVALID_FLAG_VALUE");
|
|
111
|
+
}
|
|
112
|
+
// Peek the declared version BEFORE the real parse, using the identical
|
|
113
|
+
// bounded YAML front end `parseTaskSource` calls internally — never a
|
|
114
|
+
// second/looser YAML reader. A front-end failure here (unparseable YAML,
|
|
115
|
+
// not a mapping, exceeds a resource bound) means the document itself does
|
|
116
|
+
// not parse at all — tracked as `peekFailed` so that case reports
|
|
117
|
+
// `invalid`, never `not-a-task` (`not-a-task` is reserved for
|
|
118
|
+
// YAML that DOES parse but never declared a task shape). The real parse
|
|
119
|
+
// below throws the identical error either way, so nothing is lost by
|
|
120
|
+
// swallowing it here.
|
|
121
|
+
let root;
|
|
122
|
+
let peekFailed = false;
|
|
123
|
+
try {
|
|
124
|
+
root = readBoundedTaskSourceYaml({ yaml, filePath: resolvedPath }, { sourceLabel: "task source" }).root;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
peekFailed = true;
|
|
128
|
+
}
|
|
129
|
+
const declaredVersion = peekFailed ? undefined : peekTaskSourceVersion(root);
|
|
130
|
+
const hasVersionKey = !peekFailed && declaresVersionKey(root);
|
|
131
|
+
const workspaceRoot = path.dirname(resolvedPath);
|
|
132
|
+
const backend = backendNameForPlatform();
|
|
133
|
+
let parsed;
|
|
134
|
+
try {
|
|
135
|
+
parsed = parseTaskSource({ yaml, filePath: resolvedPath, workspaceRoot });
|
|
136
|
+
}
|
|
137
|
+
catch (cause) {
|
|
138
|
+
if (!(cause instanceof UsageError))
|
|
139
|
+
throw cause;
|
|
140
|
+
const reason = cause.message;
|
|
141
|
+
// `parseTaskSource` only ever throws for a declared version 2/3 via the
|
|
142
|
+
// unmigratable-conversion branch (see this file's header) — no separate
|
|
143
|
+
// message check needed to recognize "blocked" here.
|
|
144
|
+
if (declaredVersion === 2 || declaredVersion === 3) {
|
|
145
|
+
return { ok: false, path: resolvedPath, sourceVersion: declaredVersion, outcome: "blocked", reason };
|
|
146
|
+
}
|
|
147
|
+
if (peekFailed) {
|
|
148
|
+
return { ok: false, path: resolvedPath, outcome: "invalid", reason };
|
|
149
|
+
}
|
|
150
|
+
if (!hasVersionKey) {
|
|
151
|
+
return { ok: false, path: resolvedPath, outcome: "not-a-task", reason };
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
path: resolvedPath,
|
|
156
|
+
...(declaredVersion !== undefined ? { sourceVersion: declaredVersion } : {}),
|
|
157
|
+
outcome: "invalid",
|
|
158
|
+
reason,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
// Success is unreachable from any path that leaves `declaredVersion`
|
|
162
|
+
// undefined — the router requires a numeric 2/3/4 version to reach here.
|
|
163
|
+
const sourceVersion = declaredVersion ?? 4;
|
|
164
|
+
// The document itself parsed (directly, or via a successful v2/v3
|
|
165
|
+
// conversion) — now the two gates `compileTaskSources` runs before
|
|
166
|
+
// accepting it. A violation here is `invalid`, never `blocked`: the
|
|
167
|
+
// migrator already succeeded, so this is the same kind of defect a
|
|
168
|
+
// native v4 document with the identical schedule would have.
|
|
169
|
+
try {
|
|
170
|
+
assertTaskScheduleInputsSatisfyContract(parsed.v4, resolvedPath);
|
|
171
|
+
assertTaskScheduleCronValid(parsed.v4, backend);
|
|
172
|
+
}
|
|
173
|
+
catch (cause) {
|
|
174
|
+
if (!(cause instanceof UsageError))
|
|
175
|
+
throw cause;
|
|
176
|
+
return { ok: false, path: resolvedPath, sourceVersion, outcome: "invalid", reason: cause.message };
|
|
177
|
+
}
|
|
178
|
+
const id = path.parse(resolvedPath).name;
|
|
179
|
+
return {
|
|
180
|
+
ok: true,
|
|
181
|
+
path: resolvedPath,
|
|
182
|
+
sourceVersion,
|
|
183
|
+
outcome: sourceVersion === 2 || sourceVersion === 3 ? "converts" : "valid",
|
|
184
|
+
resolved: buildResolved(id, parsed.v4),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -1,34 +1,93 @@
|
|
|
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 { concurrentMap } from "../core/concurrent.js";
|
|
5
|
+
import { systemErrorCode } from "../core/system-error.js";
|
|
4
6
|
const URL_RE = /https?:\/\/[^\s"'<>)\]]+/g;
|
|
7
|
+
/**
|
|
8
|
+
* URLs are checked `URL_CHECK_CONCURRENCY` at a time via {@link concurrentMap}
|
|
9
|
+
* rather than all at once: a knowledge bundle can hold thousands of links, and
|
|
10
|
+
* firing every HEAD request in one `Promise.allSettled` batch is a
|
|
11
|
+
* self-inflicted denial-of-service against whatever host happens to be linked
|
|
12
|
+
* most. Bounding concurrency changes only how fast the check runs, never how
|
|
13
|
+
* much of it happens — every URL still gets checked.
|
|
14
|
+
*/
|
|
15
|
+
const URL_CHECK_CONCURRENCY = 8;
|
|
16
|
+
/**
|
|
17
|
+
* Per-HEAD-request timeout, matching the old `TIMEOUT_MS` the caps this
|
|
18
|
+
* checker replaced used. A dead site rarely refuses cleanly — it hangs — so
|
|
19
|
+
* without this a handful of unresponsive hosts could stall the whole check
|
|
20
|
+
* indefinitely instead of the timed-out URLs simply showing up as dead.
|
|
21
|
+
*/
|
|
22
|
+
const DEAD_URL_TIMEOUT_MS = 5000;
|
|
23
|
+
/**
|
|
24
|
+
* DNS/connection-level codes meaning "this machine could not reach the host
|
|
25
|
+
* right now" — a corporate DNS block, an offline sandbox, a transient blip —
|
|
26
|
+
* as opposed to "the resource is gone". Same grouping `classifyVectorFailure`
|
|
27
|
+
* (indexer/search/db-search.ts) uses for its "connection failed" bucket.
|
|
28
|
+
* These are counted in `coverage.skipped`, never reported as a `DeadUrl`: an
|
|
29
|
+
* indeterminate result is not evidence a link is dead.
|
|
30
|
+
*/
|
|
31
|
+
const NETWORK_ERROR_CODES = new Set([
|
|
32
|
+
"ECONNREFUSED",
|
|
33
|
+
"ECONNRESET",
|
|
34
|
+
"ENETUNREACH",
|
|
35
|
+
"EHOSTUNREACH",
|
|
36
|
+
"ENOTFOUND",
|
|
37
|
+
"EAI_AGAIN",
|
|
38
|
+
]);
|
|
5
39
|
/**
|
|
6
40
|
* Check every URL in `entries` and report the ones that are dead.
|
|
7
41
|
*
|
|
8
42
|
* No cap, no per-entry slice, no ceiling option. There used to be a
|
|
9
43
|
* `MAX_URLS = 20` plus an undocumented `slice(0, 3)` per entry, so this
|
|
10
44
|
* examined at most twenty links in a bundle holding thousands and reported
|
|
11
|
-
* success.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
45
|
+
* success. Both are gone. It checks what you asked it to check, at a bounded
|
|
46
|
+
* concurrency (see {@link URL_CHECK_CONCURRENCY}) so a large bundle does not
|
|
47
|
+
* turn into a request flood, and each request is bounded by
|
|
48
|
+
* {@link DEAD_URL_TIMEOUT_MS} so one unresponsive host cannot stall the rest.
|
|
15
49
|
*
|
|
16
|
-
* A
|
|
17
|
-
* rather than being swallowed, so a network problem is
|
|
18
|
-
* looking like a clean bill of health.
|
|
50
|
+
* A `>=400` response or a timeout surfaces as a `DeadUrl` (a timeout as
|
|
51
|
+
* `status: "timeout"`) rather than being swallowed, so a network problem is
|
|
52
|
+
* visible instead of looking like a clean bill of health. A DNS/connection
|
|
53
|
+
* failure (see {@link NETWORK_ERROR_CODES}) is different: it says the check
|
|
54
|
+
* itself could not run, not that the URL is dead, so it is counted in
|
|
55
|
+
* `coverage.skipped` instead of either `deadUrls` or a silent success. Any
|
|
56
|
+
* other thrown error still reports as `status: "error"`. `coverage.checked`
|
|
57
|
+
* and `coverage.total` are equal only when nothing was skipped this way.
|
|
19
58
|
*/
|
|
20
59
|
export async function checkDeadUrls(_stashDir, entries) {
|
|
21
60
|
const urlsToCheck = entries.flatMap((entry) => (entry.body.match(URL_RE) ?? []).map((url) => ({ ref: entry.ref, url })));
|
|
22
|
-
const
|
|
23
|
-
await Promise.allSettled(urlsToCheck.map(async ({ ref, url }) => {
|
|
61
|
+
const outcomes = await concurrentMap(urlsToCheck, async ({ ref, url }) => {
|
|
24
62
|
try {
|
|
25
|
-
const res = await fetch(url, {
|
|
26
|
-
|
|
27
|
-
|
|
63
|
+
const res = await fetch(url, {
|
|
64
|
+
method: "HEAD",
|
|
65
|
+
redirect: "follow",
|
|
66
|
+
signal: AbortSignal.timeout(DEAD_URL_TIMEOUT_MS),
|
|
67
|
+
});
|
|
68
|
+
return res.status >= 400 ? { dead: { ref, url, status: res.status } } : {};
|
|
28
69
|
}
|
|
29
|
-
catch {
|
|
30
|
-
|
|
70
|
+
catch (err) {
|
|
71
|
+
if (err instanceof DOMException && err.name === "TimeoutError") {
|
|
72
|
+
return { dead: { ref, url, status: "timeout" } };
|
|
73
|
+
}
|
|
74
|
+
const code = systemErrorCode(err);
|
|
75
|
+
if (code && NETWORK_ERROR_CODES.has(code)) {
|
|
76
|
+
return { skipped: true };
|
|
77
|
+
}
|
|
78
|
+
return { dead: { ref, url, status: "error" } };
|
|
31
79
|
}
|
|
32
|
-
})
|
|
33
|
-
|
|
80
|
+
}, URL_CHECK_CONCURRENCY);
|
|
81
|
+
const deadUrls = [];
|
|
82
|
+
let skipped = 0;
|
|
83
|
+
for (const outcome of outcomes) {
|
|
84
|
+
if (outcome?.dead)
|
|
85
|
+
deadUrls.push(outcome.dead);
|
|
86
|
+
if (outcome?.skipped)
|
|
87
|
+
skipped += 1;
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
deadUrls,
|
|
91
|
+
coverage: { checked: urlsToCheck.length - skipped, total: urlsToCheck.length, skipped },
|
|
92
|
+
};
|
|
34
93
|
}
|
package/dist/core/bundle-id.js
CHANGED
|
@@ -46,6 +46,12 @@ function ensureUniqueId(preferred, sourcePath, used) {
|
|
|
46
46
|
n++;
|
|
47
47
|
return `${suffixed}-${n}`;
|
|
48
48
|
}
|
|
49
|
-
|
|
49
|
+
/**
|
|
50
|
+
* First 8 hex chars of `input`'s sha256 — deterministic and short enough to
|
|
51
|
+
* suffix a slug or a path key. Exported so callers needing the same
|
|
52
|
+
* short-hash-of-a-resolved-path primitive (e.g. `getStashStateKey` in
|
|
53
|
+
* `paths.ts`) don't grow their own duplicate.
|
|
54
|
+
*/
|
|
55
|
+
export function shortHash(input) {
|
|
50
56
|
return crypto.createHash("sha256").update(input).digest("hex").slice(0, 8);
|
|
51
57
|
}
|
|
@@ -68,6 +68,11 @@ const LlmEngineSchema = z
|
|
|
68
68
|
endpoint: chatCompletionsEndpoint,
|
|
69
69
|
model: nonEmptyString,
|
|
70
70
|
apiKey: z.string().regex(ENV_REFERENCE_PATTERN, `apiKey must be $VAR or \${VAR}`).optional(),
|
|
71
|
+
// #905: file-backed alternative to `apiKey` for hosts that refuse
|
|
72
|
+
// secrets in the process environment. A plain filesystem path (`~`
|
|
73
|
+
// expanded, read at dispatch) — see resolveLlmEngineUse/
|
|
74
|
+
// materializeLlmConnectionWithCredential in integrations/agent/engine-resolution.ts.
|
|
75
|
+
apiKeyFile: nonEmptyString.optional(),
|
|
71
76
|
temperature: z.number().finite().optional(),
|
|
72
77
|
maxTokens: positiveInt.optional(),
|
|
73
78
|
timeoutMs: timeoutMsField,
|
|
@@ -83,6 +88,17 @@ const LlmEngineSchema = z
|
|
|
83
88
|
if (key in value)
|
|
84
89
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [key], message: `${key} is not valid on an LLM engine` });
|
|
85
90
|
}
|
|
91
|
+
// #905: apiKey and apiKeyFile are two alternative ways to supply the same
|
|
92
|
+
// credential; a third (the implicit AKM_ENGINE_<NAME>_API_KEY env var) is
|
|
93
|
+
// still available when neither is set, so only the both-set case is
|
|
94
|
+
// rejected here.
|
|
95
|
+
if (value.apiKey !== undefined && value.apiKeyFile !== undefined) {
|
|
96
|
+
ctx.addIssue({
|
|
97
|
+
code: z.ZodIssueCode.custom,
|
|
98
|
+
path: ["apiKeyFile"],
|
|
99
|
+
message: "apiKey and apiKeyFile cannot both be set",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
86
102
|
});
|
|
87
103
|
const AgentEngineSchema = z
|
|
88
104
|
.object({
|
|
@@ -103,6 +119,7 @@ const AgentEngineSchema = z
|
|
|
103
119
|
"provider",
|
|
104
120
|
"endpoint",
|
|
105
121
|
"apiKey",
|
|
122
|
+
"apiKeyFile",
|
|
106
123
|
"temperature",
|
|
107
124
|
"maxTokens",
|
|
108
125
|
"concurrency",
|
|
@@ -27,6 +27,7 @@ const COMMON_FIELDS = [
|
|
|
27
27
|
"coverageGaps",
|
|
28
28
|
"evalCasesWritten",
|
|
29
29
|
"deadUrls",
|
|
30
|
+
"deadUrlCoverage",
|
|
30
31
|
"reflectsWithErrorContext",
|
|
31
32
|
"memoryInference",
|
|
32
33
|
"graphExtraction",
|
|
@@ -440,6 +441,7 @@ function validateCommon(value) {
|
|
|
440
441
|
"sync",
|
|
441
442
|
"terminated",
|
|
442
443
|
"plan",
|
|
444
|
+
"deadUrlCoverage",
|
|
443
445
|
]) {
|
|
444
446
|
if (value[field] !== undefined && !isRecord(value[field]))
|
|
445
447
|
fail(`${field} must be an object`);
|
|
@@ -453,6 +455,12 @@ function validateCommon(value) {
|
|
|
453
455
|
fail("terminated.errorMessage must be a string when present");
|
|
454
456
|
}
|
|
455
457
|
}
|
|
458
|
+
if (isRecord(value.deadUrlCoverage)) {
|
|
459
|
+
requireExactFields(value.deadUrlCoverage, new Set(["checked", "total", "skipped"]));
|
|
460
|
+
for (const field of ["checked", "total", "skipped"]) {
|
|
461
|
+
requireCount(value.deadUrlCoverage, field, "deadUrlCoverage");
|
|
462
|
+
}
|
|
463
|
+
}
|
|
456
464
|
}
|
|
457
465
|
/**
|
|
458
466
|
* Per-`schemaVersion` decoders for the persisted `improve_runs.result_json`
|
package/dist/core/paths.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import os from "node:os";
|
|
12
12
|
import path from "node:path";
|
|
13
|
+
import { shortHash } from "./bundle-id.js";
|
|
13
14
|
import { ConfigError } from "./errors.js";
|
|
14
15
|
import { IS_WINDOWS } from "./platform.js";
|
|
15
16
|
/**
|
|
@@ -257,6 +258,117 @@ export function getRegistryCacheDir() {
|
|
|
257
258
|
export function getRegistryIndexCacheDir() {
|
|
258
259
|
return path.join(getCacheDir(), "registry-index");
|
|
259
260
|
}
|
|
261
|
+
// ── State directory ──────────────────────────────────────────────────────────
|
|
262
|
+
/**
|
|
263
|
+
* Returns the XDG state directory for akm (`~/.local/state/akm` on
|
|
264
|
+
* Linux/macOS, `%LOCALAPPDATA%\akm\state` on Windows).
|
|
265
|
+
*
|
|
266
|
+
* Per-machine runtime state that is neither durable application data
|
|
267
|
+
* (`$DATA`), a purely regenerable cache (`$CACHE`), nor state that must
|
|
268
|
+
* travel with a bundle's own content (`$STASH/.akm`; see "What May Live in
|
|
269
|
+
* $STASH/.akm" in docs/architecture/internals/storage-locations.md).
|
|
270
|
+
*
|
|
271
|
+
* Env overrides (in priority order):
|
|
272
|
+
* AKM_STATE_DIR — point to any directory
|
|
273
|
+
* XDG_STATE_HOME — (Linux/macOS) override the XDG base; akm subdir is appended
|
|
274
|
+
*/
|
|
275
|
+
export function getStateDir(env = process.env, platform = process.platform) {
|
|
276
|
+
const override = env.AKM_STATE_DIR?.trim();
|
|
277
|
+
if (override)
|
|
278
|
+
return override;
|
|
279
|
+
if (platform === "win32") {
|
|
280
|
+
const localAppData = env.LOCALAPPDATA?.trim();
|
|
281
|
+
if (localAppData)
|
|
282
|
+
return path.join(localAppData, "akm", "state");
|
|
283
|
+
const userProfile = env.USERPROFILE?.trim();
|
|
284
|
+
if (userProfile)
|
|
285
|
+
return path.join(userProfile, "AppData", "Local", "akm", "state");
|
|
286
|
+
const appData = env.APPDATA?.trim();
|
|
287
|
+
if (!appData) {
|
|
288
|
+
throw new ConfigError("Unable to determine state directory. Set LOCALAPPDATA, USERPROFILE, or APPDATA.", "CONFIG_DIR_UNRESOLVABLE");
|
|
289
|
+
}
|
|
290
|
+
return path.join(appData, "..", "Local", "akm", "state");
|
|
291
|
+
}
|
|
292
|
+
const xdgStateHome = env.XDG_STATE_HOME?.trim();
|
|
293
|
+
if (xdgStateHome)
|
|
294
|
+
return path.join(xdgStateHome, "akm");
|
|
295
|
+
const home = env.HOME?.trim();
|
|
296
|
+
if (!home)
|
|
297
|
+
return homelessFallbackDir("akm-state");
|
|
298
|
+
return path.join(home, ".local", "state", "akm");
|
|
299
|
+
}
|
|
300
|
+
// ── Per-stash state under $STATE / $CACHE (itlackey/akm#890) ─────────────────
|
|
301
|
+
/**
|
|
302
|
+
* Deterministic, filesystem-safe key for machine-local state that belongs to
|
|
303
|
+
* one stash but must not live under `$STASH` itself. Namespaces `$STATE`/
|
|
304
|
+
* `$CACHE` writers by the resolved absolute stash directory — the filesystem
|
|
305
|
+
* counterpart of how `state.db`'s `proposals` table keys per-stash rows by
|
|
306
|
+
* its `stash_dir` column — so two stashes on one machine never collide.
|
|
307
|
+
*
|
|
308
|
+
* Reuses {@link shortHash} (bundle-id.ts) rather than growing a second
|
|
309
|
+
* sha256-truncated-hex helper. (The other existing option,
|
|
310
|
+
* `getCurrentWorkflowScopeKey`'s `dir:v1:<sha256>` in
|
|
311
|
+
* `src/workflows/authoring/scope-key.ts`, is a state.db column value for a
|
|
312
|
+
* different subsystem — full untruncated hex plus a `dir:v1:` prefix, which
|
|
313
|
+
* is neither filesystem-safe as a directory segment on Windows (`:`) nor
|
|
314
|
+
* short, so it is not a fit here.) `shortHash` trims to 8 hex chars (32
|
|
315
|
+
* bits) — fine at its original call site because a collision only ever
|
|
316
|
+
* shortens a batch-unique slug suffix that a retry loop immediately
|
|
317
|
+
* re-disambiguates. Here there is no such retry loop, but a machine
|
|
318
|
+
* realistically has, at most, a handful to a few dozen distinct stash
|
|
319
|
+
* directories ever registered — 32 bits of digest space makes an accidental
|
|
320
|
+
* collision between two of them astronomically unlikely.
|
|
321
|
+
*/
|
|
322
|
+
export function getStashStateKey(stashDir) {
|
|
323
|
+
const resolved = path.resolve(stashDir).replace(/\\/g, "/");
|
|
324
|
+
const normalized = IS_WINDOWS ? resolved.toLowerCase() : resolved;
|
|
325
|
+
return shortHash(normalized);
|
|
326
|
+
}
|
|
327
|
+
function stashScopedDir(base, stashDir) {
|
|
328
|
+
return path.join(base, getStashStateKey(stashDir));
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* `$STATE/improve/distill-rejected/<stash>/` — lessons that failed the
|
|
332
|
+
* distill quality gate. Moved out of `$STASH/.akm/distill-rejected/`
|
|
333
|
+
* (itlackey/akm#890): nothing reads it to resolve bundle content, so it does
|
|
334
|
+
* not meet the "must travel with the content" rule.
|
|
335
|
+
*/
|
|
336
|
+
export function getDistillRejectedDir(stashDir) {
|
|
337
|
+
return stashScopedDir(path.join(getStateDir(), "improve", "distill-rejected"), stashDir);
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* `$STATE/improve/eval-cases/<stash>/` — regression eval cases captured from
|
|
341
|
+
* rejected distill/proposal output. Moved out of `$STASH/.akm/eval-cases/`
|
|
342
|
+
* (itlackey/akm#890).
|
|
343
|
+
*/
|
|
344
|
+
export function getEvalCasesDir(stashDir) {
|
|
345
|
+
return stashScopedDir(path.join(getStateDir(), "improve", "eval-cases"), stashDir);
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* `$STATE/improve/measurement/verdicts/<stash>/` — `akm-eval-proactive-verdict`
|
|
349
|
+
* reports. Moved out of `$STASH/.akm/measurement/verdicts/` (itlackey/akm#890);
|
|
350
|
+
* the pilot treatment file at `$STASH/.akm/measurement/` is manually-authored
|
|
351
|
+
* measurement input and stays put.
|
|
352
|
+
*/
|
|
353
|
+
export function getMeasurementVerdictsDir(stashDir) {
|
|
354
|
+
return stashScopedDir(path.join(getStateDir(), "improve", "measurement", "verdicts"), stashDir);
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* `$CACHE/index/unresolved-sources/<stash>/` — synthetic placeholder path for
|
|
358
|
+
* a configured source whose content root did not resolve this run. Never
|
|
359
|
+
* written to disk; only used as a stable, reportable `SearchSource.path`.
|
|
360
|
+
* Moved out of `$STASH/.akm/unresolved-sources/` (itlackey/akm#890).
|
|
361
|
+
*/
|
|
362
|
+
export function getUnresolvedSourcesDir(stashDir) {
|
|
363
|
+
return stashScopedDir(path.join(getCacheDir(), "index", "unresolved-sources"), stashDir);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* `$STATE/locks/<stash>/` — per-stash operational lock files for the improve
|
|
367
|
+
* pipeline (e.g. `improve.lock`). Moved out of `$STASH/.akm/` (itlackey/akm#890).
|
|
368
|
+
*/
|
|
369
|
+
export function getStashLocksDir(stashDir) {
|
|
370
|
+
return stashScopedDir(path.join(getStateDir(), "locks"), stashDir);
|
|
371
|
+
}
|
|
260
372
|
// ── Scheduled-task runtime directories (logs + history) ──────────────────────
|
|
261
373
|
export function getTaskLogDir() {
|
|
262
374
|
return path.join(getCacheDir(), "tasks", "logs");
|
|
@@ -5,6 +5,7 @@ import fs from "node:fs";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { isWithin, resolveStashDir } from "../../core/common.js";
|
|
7
7
|
import { bundleComponentConfig, bundlesToSourceEntries, getSources, loadConfig } from "../../core/config/config.js";
|
|
8
|
+
import { getUnresolvedSourcesDir } from "../../core/paths.js";
|
|
8
9
|
import { resolveGitContentRoot, resolveWritable } from "../../core/write-source.js";
|
|
9
10
|
import { lockContentRootFor } from "../../integrations/lockfile.js";
|
|
10
11
|
import { resolveSourceProviderFactory } from "../../sources/provider-factory.js";
|
|
@@ -86,14 +87,14 @@ export function resolveSourceEntries(overrideStashDir, existingConfig) {
|
|
|
86
87
|
const component = bundleComponentConfig(config.bundles?.[entry.name ?? ""]);
|
|
87
88
|
const contentRoot = resolveEntryContentDir(entry);
|
|
88
89
|
if (contentRoot == null) {
|
|
89
|
-
const unresolvedPath = path.join(implicitStashDir ?? process.cwd(),
|
|
90
|
+
const unresolvedPath = path.join(getUnresolvedSourcesDir(implicitStashDir ?? process.cwd()), entry.name ?? entry.type);
|
|
90
91
|
addSource(unresolvedPath, entry.name, component?.writable ?? resolveWritable(entry), entry.type, component?.adapter, true);
|
|
91
92
|
continue;
|
|
92
93
|
}
|
|
93
94
|
const dir = path.resolve(contentRoot, component?.root ?? ".");
|
|
94
95
|
if (!isWithin(dir, contentRoot)) {
|
|
95
96
|
warn(`Warning: component root "${component?.root}" escapes bundle "${entry.name}"; skipping source.`);
|
|
96
|
-
const unresolvedPath = path.join(contentRoot,
|
|
97
|
+
const unresolvedPath = path.join(getUnresolvedSourcesDir(contentRoot), entry.name ?? entry.type);
|
|
97
98
|
addSource(unresolvedPath, entry.name, component?.writable ?? resolveWritable(entry), entry.type, component?.adapter, true);
|
|
98
99
|
continue;
|
|
99
100
|
}
|