akm-cli 0.9.0 → 0.9.1-beta.2
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 +724 -0
- package/README.md +28 -63
- package/STABILITY.md +4 -2
- package/dist/cli/parse-args.js +7 -1
- package/dist/commands/agent/contribute-cli.js +1 -1
- package/dist/commands/env/child-env.js +14 -0
- package/dist/commands/feedback-cli.js +7 -1
- package/dist/commands/health/llm-usage.js +2 -1
- package/dist/commands/health/surfaces.js +4 -77
- package/dist/commands/health.js +65 -11
- package/dist/commands/improve/distill/quality-gate.js +6 -1
- package/dist/commands/improve/eligibility.js +7 -1
- package/dist/commands/improve/eval-cases.js +2 -0
- package/dist/commands/improve/improve.js +126 -10
- package/dist/commands/improve/locks.js +7 -0
- package/dist/commands/improve/memory/memory-improve.js +9 -0
- package/dist/commands/improve/run-context.js +5 -0
- package/dist/commands/improve/session-asset.js +4 -0
- package/dist/commands/lint/base-linter.js +31 -7
- package/dist/commands/lint/index.js +205 -51
- package/dist/commands/lint/types.js +22 -1
- package/dist/commands/proposal/repository.js +17 -1
- package/dist/commands/sources/add-cli.js +8 -2
- package/dist/commands/sources/info.js +12 -2
- package/dist/commands/sources/installed-stashes.js +6 -1
- package/dist/commands/sources/migration-help.js +12 -3
- package/dist/commands/sources/self-update.js +9 -1
- package/dist/commands/tasks/tasks.js +8 -2
- package/dist/commands/workflow-cli.js +17 -11
- package/dist/core/abort-deadline.js +28 -0
- package/dist/core/adapter/adapters/agent-skills-adapter.js +83 -5
- package/dist/core/adapter/adapters/akm-adapter.js +13 -10
- package/dist/core/adapter/adapters/akm-lint.js +78 -22
- package/dist/core/adapter/adapters/akm-task-adapter.js +43 -20
- package/dist/core/adapter/adapters/dotenv-adapter.js +21 -0
- package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
- package/dist/core/asset/frontmatter.js +10 -1
- package/dist/core/common.js +147 -9
- package/dist/core/concurrent.js +32 -0
- package/dist/core/config/config-io.js +5 -45
- package/dist/core/config/schema/engines.js +14 -3
- package/dist/core/config/schema/workflow.js +11 -0
- package/dist/core/errors.js +25 -0
- package/dist/core/events.js +30 -24
- package/dist/core/extra-params.js +11 -0
- package/dist/core/file-lock.js +7 -1
- package/dist/core/fs-txn.js +15 -2
- package/dist/core/improve-result.js +5 -0
- package/dist/core/json-schema.js +344 -9
- package/dist/core/loopback.js +89 -0
- package/dist/core/migration-operation.js +17 -2
- package/dist/core/path-access.js +107 -0
- package/dist/core/paths.js +16 -2
- package/dist/core/redaction.js +86 -18
- package/dist/core/spawn-env.js +234 -0
- package/dist/core/state-db-scope.js +134 -0
- package/dist/core/state-db.js +1 -0
- package/dist/core/subprocess.js +181 -37
- package/dist/core/write-provenance.js +85 -0
- package/dist/core/write-source.js +33 -2
- package/dist/indexer/db/graph-db.js +17 -6
- package/dist/indexer/ensure-index.js +10 -3
- package/dist/indexer/index-written-assets.js +17 -2
- package/dist/indexer/indexer.js +86 -21
- package/dist/indexer/passes/memory-inference.js +4 -0
- package/dist/indexer/search/db-search.js +25 -17
- package/dist/indexer/walk/walker.js +6 -1
- package/dist/integrations/agent/detect.js +13 -1
- package/dist/integrations/agent/engine-resolution.js +24 -11
- package/dist/integrations/agent/model-aliases.js +1 -1
- package/dist/integrations/agent/profiles.js +9 -1
- package/dist/integrations/agent/spawn.js +15 -87
- package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +21 -0
- package/dist/integrations/lockfile.js +55 -2
- package/dist/llm/client.js +14 -19
- package/dist/llm/embedder.js +23 -3
- package/dist/llm/embedders/remote.js +27 -2
- package/dist/output/html-render.js +40 -1
- package/dist/output/text/lint-format.js +17 -4
- package/dist/runtime.js +23 -1
- package/dist/scripts/akm-migrate-node.js +1714 -836
- package/dist/scripts/akm-migrate.js +1682 -804
- package/dist/setup/setup.js +22 -7
- package/dist/sources/providers/git-install.js +25 -2
- package/dist/sources/providers/git-stash.js +19 -0
- package/dist/sources/providers/git.js +1 -1
- package/dist/sources/snapshot-fetchers/content-extract.js +63 -1
- package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
- package/dist/storage/database.js +71 -7
- package/dist/storage/engines/sqlite-migrations.js +61 -2
- package/dist/storage/managed-db.js +19 -0
- package/dist/storage/repositories/index-connection.js +39 -4
- package/dist/storage/repositories/index-entries-repository.js +6 -1
- package/dist/storage/repositories/index-meta-repository.js +11 -0
- package/dist/storage/repositories/index-schema.js +17 -2
- package/dist/storage/repositories/index-vec-repository.js +43 -5
- package/dist/storage/repositories/workflow-runs-repository.js +66 -13
- package/dist/storage/sqlite-pragmas.js +12 -1
- package/dist/tasks/log-redaction.js +156 -0
- package/dist/tasks/parser.js +82 -5
- package/dist/tasks/runner.js +222 -17
- package/dist/tasks/scheduler-invocation.js +19 -0
- package/dist/tasks/schema.js +86 -1
- package/dist/text-import-hook.mjs +1 -1
- package/dist/workflows/concurrency-policy.js +95 -1
- package/dist/workflows/exec/dispatch-redaction.js +114 -0
- package/dist/workflows/exec/exec-unit.js +542 -0
- package/dist/workflows/exec/frozen-judge.js +114 -42
- package/dist/workflows/exec/native-executor.js +465 -238
- package/dist/workflows/exec/param-secrets.js +4 -3
- package/dist/workflows/exec/run-workflow.js +424 -219
- package/dist/workflows/exec/step-work.js +506 -167
- package/dist/workflows/exec/unit-dispatch.js +31 -1
- package/dist/workflows/exec/unit-writer.js +53 -13
- package/dist/workflows/exec/worktree.js +454 -41
- package/dist/workflows/ir/compile.js +26 -2
- package/dist/workflows/ir/freeze.js +82 -15
- package/dist/workflows/ir/schema.js +105 -20
- package/dist/workflows/parser.js +242 -19
- package/dist/workflows/program/schema.js +24 -0
- package/dist/workflows/renderer.js +32 -4
- package/dist/workflows/resource-limits.js +182 -0
- package/dist/workflows/runtime/runs.js +146 -6
- package/dist/workflows/validate-summary.js +17 -2
- package/docs/README.md +74 -32
- package/docs/migration/release-notes/0.9.0.md +2 -1
- package/docs/migration/v0.7-to-v0.8.md +2 -1
- package/docs/migration/v0.8-to-v0.9.md +3 -1
- package/docs/reference/README.md +11 -4
- package/docs/reference/bundle-types.md +19 -0
- package/docs/reference/cli.md +105 -16
- package/docs/reference/configuration.md +15 -2
- package/docs/reference/data-and-telemetry.md +30 -10
- package/docs/reference/supported-formats.md +50 -0
- package/docs/reference/workflow-schema.md +1014 -0
- package/docs/reference/workflows.md +37 -633
- package/package.json +13 -6
- package/schemas/akm-config.json +18 -5
- package/schemas/akm-task.json +27 -5
- package/schemas/akm-workflow.json +92 -13
package/dist/core/common.js
CHANGED
|
@@ -83,6 +83,75 @@ export function readTextFileWithLimit(filePath, maxBytes, label = "File") {
|
|
|
83
83
|
* don't fail on those mounts. Windows does not support opening a
|
|
84
84
|
* directory for fsync, so the directory-sync step is skipped there.
|
|
85
85
|
*/
|
|
86
|
+
/**
|
|
87
|
+
* Strip JavaScript-style comments from a JSON string (JSONC support).
|
|
88
|
+
* Handles `//` line comments and `/* */` block comments while preserving
|
|
89
|
+
* comment-like sequences inside quoted strings.
|
|
90
|
+
*/
|
|
91
|
+
export function stripJsonComments(text) {
|
|
92
|
+
let result = "";
|
|
93
|
+
let i = 0;
|
|
94
|
+
let inString = false;
|
|
95
|
+
while (i < text.length) {
|
|
96
|
+
if (inString) {
|
|
97
|
+
if (text[i] === "\\") {
|
|
98
|
+
result += text[i] + (text[i + 1] ?? "");
|
|
99
|
+
i += 2;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (text[i] === '"') {
|
|
103
|
+
inString = false;
|
|
104
|
+
}
|
|
105
|
+
result += text[i];
|
|
106
|
+
i++;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (text[i] === '"') {
|
|
110
|
+
inString = true;
|
|
111
|
+
result += text[i];
|
|
112
|
+
i++;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (text[i] === "/" && text[i + 1] === "/") {
|
|
116
|
+
while (i < text.length && text[i] !== "\n")
|
|
117
|
+
i++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (text[i] === "/" && text[i + 1] === "*") {
|
|
121
|
+
i += 2;
|
|
122
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
|
|
123
|
+
i++;
|
|
124
|
+
i += 2;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
result += text[i];
|
|
128
|
+
i++;
|
|
129
|
+
}
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The mode to rewrite an existing USER-owned file with.
|
|
134
|
+
*
|
|
135
|
+
* {@link writeFileAtomic} creates its temp file with an explicit mode and
|
|
136
|
+
* chmods it, so it needs one — and its 0600 default is right for akm's own
|
|
137
|
+
* state but wrong for a user's asset, where rewriting must never change
|
|
138
|
+
* permissions. Returns the file's current mode, or the umask-derived default
|
|
139
|
+
* `fs.writeFileSync` would have produced for a new file.
|
|
140
|
+
*/
|
|
141
|
+
export function existingFileMode(filePath) {
|
|
142
|
+
try {
|
|
143
|
+
return fs.statSync(filePath).mode & 0o777;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// Absent (a new asset) or unreadable: fall back to the default create mode.
|
|
147
|
+
try {
|
|
148
|
+
return 0o666 & ~process.umask();
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return 0o644;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
86
155
|
export function writeFileAtomic(target, content, mode) {
|
|
87
156
|
const tmp = `${target}.tmp.${process.pid}.${crypto.randomBytes(8).toString("hex")}`;
|
|
88
157
|
const data = typeof content === "string" ? Buffer.from(content) : content;
|
|
@@ -226,7 +295,11 @@ function readStashDirFromConfig() {
|
|
|
226
295
|
try {
|
|
227
296
|
const configPath = getConfigPath();
|
|
228
297
|
const text = readTextFileWithLimit(configPath, MAX_CONFIG_FILE_BYTES, "Config file");
|
|
229
|
-
|
|
298
|
+
// The config loader accepts JSONC, so a commented config.json is valid and
|
|
299
|
+
// in use. Parsing it raw here threw, the catch swallowed it, and every
|
|
300
|
+
// caller silently fell back — operating on the wrong bundle or failing with
|
|
301
|
+
// STASH_DIR_NOT_FOUND despite a perfectly good config.
|
|
302
|
+
const raw = JSON.parse(stripJsonComments(text));
|
|
230
303
|
if (typeof raw !== "object" || raw === null)
|
|
231
304
|
return undefined;
|
|
232
305
|
// 0.9.0 config-shape cutover (spec §10.1): the primary stash is the
|
|
@@ -292,13 +365,49 @@ export function hasErrnoCode(error, code) {
|
|
|
292
365
|
return false;
|
|
293
366
|
return error.code === code;
|
|
294
367
|
}
|
|
368
|
+
/**
|
|
369
|
+
* True when `value` is a RELATIVE path that cannot leave its base directory:
|
|
370
|
+
* no absolute form (POSIX `/`, Windows `\` or a `C:` drive prefix), no `~`
|
|
371
|
+
* home expansion, and no `..` segment under either separator.
|
|
372
|
+
*
|
|
373
|
+
* This is the SYNTACTIC half of containment — cheap, string-only, usable at
|
|
374
|
+
* authoring time before any directory exists. It is deliberately paired with
|
|
375
|
+
* (never a substitute for) {@link isWithin}, which resolves symlinks against a
|
|
376
|
+
* real base at use time. Workflow `exec` units run both: the parser and the
|
|
377
|
+
* frozen-plan decoder reject uncontained spellings, and the executor re-checks
|
|
378
|
+
* the resolved path before spawning.
|
|
379
|
+
*/
|
|
380
|
+
export function isContainedRelativePath(value) {
|
|
381
|
+
if (value === "" || value.startsWith("/") || value.startsWith("\\") || value.startsWith("~"))
|
|
382
|
+
return false;
|
|
383
|
+
if (/^[A-Za-z]:/.test(value))
|
|
384
|
+
return false;
|
|
385
|
+
return !value.split(/[/\\]+/).includes("..");
|
|
386
|
+
}
|
|
295
387
|
export function isWithin(candidate, root) {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
388
|
+
return isContainedResolvedPath(safeRealpath(candidate), safeRealpath(root));
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* {@link isWithin} for callers that must not block the event loop (e.g. the
|
|
392
|
+
* workflow exec dispatch path, which runs once per fan-out unit). Same
|
|
393
|
+
* comparison, same normalization, same nearest-existing-ancestor fallback —
|
|
394
|
+
* only the realpath syscalls are awaited.
|
|
395
|
+
*/
|
|
396
|
+
export async function isWithinAsync(candidate, root) {
|
|
397
|
+
return isContainedResolvedPath(await safeRealpathAsync(candidate), await safeRealpathAsync(root));
|
|
398
|
+
}
|
|
399
|
+
/** The containment comparison shared by {@link isWithin} and {@link isWithinAsync}. */
|
|
400
|
+
function isContainedResolvedPath(resolvedCandidate, resolvedRoot) {
|
|
401
|
+
const rel = path.relative(normalizeFsPathForComparison(resolvedRoot), normalizeFsPathForComparison(resolvedCandidate));
|
|
402
|
+
if (rel === "")
|
|
403
|
+
return true;
|
|
404
|
+
if (path.isAbsolute(rel))
|
|
405
|
+
return false;
|
|
406
|
+
// Compare the first SEGMENT, not a string prefix: `..data` and `...v2` are
|
|
407
|
+
// legal directory names, and only a leading `..` segment means the candidate
|
|
408
|
+
// climbed out of the root. Both separators, because `path.relative` answers
|
|
409
|
+
// in the host's spelling while callers may hold either.
|
|
410
|
+
return rel.split(/[/\\]+/)[0] !== "..";
|
|
302
411
|
}
|
|
303
412
|
/**
|
|
304
413
|
* Resolve symlinks on `p`, walking up to the closest existing ancestor when
|
|
@@ -335,6 +444,30 @@ export function safeRealpath(p) {
|
|
|
335
444
|
}
|
|
336
445
|
}
|
|
337
446
|
}
|
|
447
|
+
/** {@link safeRealpath}'s async twin — awaited syscalls, identical walk-up. */
|
|
448
|
+
export async function safeRealpathAsync(p) {
|
|
449
|
+
const resolved = path.resolve(p);
|
|
450
|
+
try {
|
|
451
|
+
return await fs.promises.realpath(resolved);
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
const suffix = [];
|
|
455
|
+
let current = resolved;
|
|
456
|
+
for (;;) {
|
|
457
|
+
const parent = path.dirname(current);
|
|
458
|
+
if (parent === current)
|
|
459
|
+
return resolved;
|
|
460
|
+
suffix.unshift(path.basename(current));
|
|
461
|
+
current = parent;
|
|
462
|
+
try {
|
|
463
|
+
return path.join(await fs.promises.realpath(current), ...suffix);
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
// parent also doesn't exist; keep walking up
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
338
471
|
function normalizeFsPathForComparison(value) {
|
|
339
472
|
return process.platform === "win32" ? value.toLowerCase() : value;
|
|
340
473
|
}
|
|
@@ -718,14 +851,19 @@ export function stringArray(value) {
|
|
|
718
851
|
* Return true if a process with the given PID is currently alive.
|
|
719
852
|
* Uses `process.kill(pid, 0)` which does not deliver a signal but
|
|
720
853
|
* throws ESRCH when the process does not exist.
|
|
854
|
+
*
|
|
855
|
+
* EPERM means the process EXISTS but belongs to another uid, so it must be
|
|
856
|
+
* reported alive. Treating it as dead let a lock held by a live process in a
|
|
857
|
+
* shared data dir (agent sandboxes, containers, service accounts — a
|
|
858
|
+
* configuration managed-db.ts explicitly supports) be reclaimed as stale.
|
|
721
859
|
*/
|
|
722
860
|
export function isProcessAlive(pid) {
|
|
723
861
|
try {
|
|
724
862
|
process.kill(pid, 0);
|
|
725
863
|
return true;
|
|
726
864
|
}
|
|
727
|
-
catch {
|
|
728
|
-
return
|
|
865
|
+
catch (err) {
|
|
866
|
+
return err?.code === "EPERM";
|
|
729
867
|
}
|
|
730
868
|
}
|
|
731
869
|
/**
|
package/dist/core/concurrent.js
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
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
|
+
* Serialize `fn` behind every task previously enqueued under `key`: an
|
|
6
|
+
* in-process keyed promise chain (Bun is single-threaded, so this is a
|
|
7
|
+
* sufficient — and free — admission control for per-key mutual exclusion).
|
|
8
|
+
*
|
|
9
|
+
* `chains` is the caller's own module-state map, so independent subsystems
|
|
10
|
+
* (the unit-writer's per-database write queue, the worktree module's per-repo
|
|
11
|
+
* git lock) never share chains. A failed task rejects its OWN caller but
|
|
12
|
+
* never wedges the chain, and a drained tail deletes its map entry so a
|
|
13
|
+
* long-lived process does not retain one settled promise per key it ever
|
|
14
|
+
* touched.
|
|
15
|
+
*/
|
|
16
|
+
export function serializeByKey(chains, key, fn) {
|
|
17
|
+
const tail = chains.get(key) ?? Promise.resolve();
|
|
18
|
+
const run = tail.then(() => fn());
|
|
19
|
+
// Keep the chain alive regardless of individual outcomes.
|
|
20
|
+
const settled = run.then(() => undefined, () => undefined);
|
|
21
|
+
chains.set(key, settled);
|
|
22
|
+
// If another task was enqueued in the meantime the map now holds ITS tail,
|
|
23
|
+
// and this check leaves it alone.
|
|
24
|
+
void settled.then(() => {
|
|
25
|
+
if (chains.get(key) === settled)
|
|
26
|
+
chains.delete(key);
|
|
27
|
+
});
|
|
28
|
+
return run;
|
|
29
|
+
}
|
|
4
30
|
/**
|
|
5
31
|
* Maps over items concurrently with a pool size limit.
|
|
6
32
|
* Uses Promise.allSettled semantics — one failure does not cancel others.
|
|
@@ -11,6 +37,12 @@
|
|
|
11
37
|
* preempt those too). Unclaimed items stay `undefined` in the result,
|
|
12
38
|
* indistinguishable from individual failures by design: callers already
|
|
13
39
|
* treat `undefined` as "no result".
|
|
40
|
+
*
|
|
41
|
+
* A thrown `fn` is SWALLOWED (its slot stays `undefined`) — a caller that
|
|
42
|
+
* must report failure detail, or distinguish "threw" from "never claimed",
|
|
43
|
+
* catches inside `fn` and returns an explicit outcome value instead. This is
|
|
44
|
+
* the OPPOSITE of {@link serializeByKey} above, whose failures reject their
|
|
45
|
+
* own caller.
|
|
14
46
|
*/
|
|
15
47
|
export async function concurrentMap(items, fn, concurrency = 1, opts) {
|
|
16
48
|
const results = new Array(items.length).fill(undefined);
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import fs from "node:fs";
|
|
16
16
|
import path from "node:path";
|
|
17
17
|
import { sleepSync } from "../../runtime.js";
|
|
18
|
-
import { MAX_CONFIG_FILE_BYTES, readTextFileWithLimit, writeFileAtomic } from "../common.js";
|
|
18
|
+
import { MAX_CONFIG_FILE_BYTES, readTextFileWithLimit, stripJsonComments, writeFileAtomic } from "../common.js";
|
|
19
19
|
import { ConfigError } from "../errors.js";
|
|
20
20
|
import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync } from "../file-lock.js";
|
|
21
21
|
import { getCacheDir, getConfigDir } from "../paths.js";
|
|
@@ -233,48 +233,8 @@ export function withConfigLock(fn) {
|
|
|
233
233
|
}
|
|
234
234
|
}
|
|
235
235
|
/**
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
236
|
+
* Re-exported from core/common.ts, where it now lives so `resolveStashDir`
|
|
237
|
+
* can strip comments too without importing this module (common cannot depend
|
|
238
|
+
* on config-io — config-io already depends on common).
|
|
239
239
|
*/
|
|
240
|
-
export
|
|
241
|
-
let result = "";
|
|
242
|
-
let i = 0;
|
|
243
|
-
let inString = false;
|
|
244
|
-
while (i < text.length) {
|
|
245
|
-
if (inString) {
|
|
246
|
-
if (text[i] === "\\") {
|
|
247
|
-
result += text[i] + (text[i + 1] ?? "");
|
|
248
|
-
i += 2;
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
if (text[i] === '"') {
|
|
252
|
-
inString = false;
|
|
253
|
-
}
|
|
254
|
-
result += text[i];
|
|
255
|
-
i++;
|
|
256
|
-
continue;
|
|
257
|
-
}
|
|
258
|
-
if (text[i] === '"') {
|
|
259
|
-
inString = true;
|
|
260
|
-
result += text[i];
|
|
261
|
-
i++;
|
|
262
|
-
continue;
|
|
263
|
-
}
|
|
264
|
-
if (text[i] === "/" && text[i + 1] === "/") {
|
|
265
|
-
while (i < text.length && text[i] !== "\n")
|
|
266
|
-
i++;
|
|
267
|
-
continue;
|
|
268
|
-
}
|
|
269
|
-
if (text[i] === "/" && text[i + 1] === "*") {
|
|
270
|
-
i += 2;
|
|
271
|
-
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
|
|
272
|
-
i++;
|
|
273
|
-
i += 2;
|
|
274
|
-
continue;
|
|
275
|
-
}
|
|
276
|
-
result += text[i];
|
|
277
|
-
i++;
|
|
278
|
-
}
|
|
279
|
-
return result;
|
|
280
|
-
}
|
|
240
|
+
export { stripJsonComments } from "../common.js";
|
|
@@ -14,7 +14,18 @@ import { z } from "zod";
|
|
|
14
14
|
// `typeof import("./config-schema")` — routing through config-types would mint
|
|
15
15
|
// a config-schema ↔ config-types type cycle that collapses inference.
|
|
16
16
|
import { HARNESS_AGENT_DISPATCH_IDS, VALID_HARNESS_IDS } from "../../../integrations/harnesses/ids.js";
|
|
17
|
+
import { WORKFLOW_MAX_TIMEOUT_MS } from "../../../workflows/resource-limits.js";
|
|
17
18
|
import { chatCompletionsEndpoint, ENV_REFERENCE_PATTERN, ExtraParamsSchema, engineName, LlmCapabilitiesSchema, ModelAliasMapSchema, nonEmptyString, positiveInt, } from "./primitives.js";
|
|
19
|
+
/**
|
|
20
|
+
* Engine-config timeouts share the workflow ceiling.
|
|
21
|
+
*
|
|
22
|
+
* 0.9.1 bounded workflow-authored timeouts (parser) and frozen invocations
|
|
23
|
+
* (decoder) at 2^31-1, but the third source — `engines.<name>.timeoutMs` — was
|
|
24
|
+
* validated only as a positive integer. A larger value passed config validation
|
|
25
|
+
* and then failed EVERY run of that engine with an unlocated "Invalid frozen
|
|
26
|
+
* workflow plan: invocation is invalid". Reject it where the value is written.
|
|
27
|
+
*/
|
|
28
|
+
const timeoutMsField = z.union([positiveInt.max(WORKFLOW_MAX_TIMEOUT_MS), z.null()]).optional();
|
|
18
29
|
// ── Connection configs (LLM) ────────────────────────────────────────────────
|
|
19
30
|
/**
|
|
20
31
|
* OpenAI-compatible connection fields shared by named LLM engines and bounded
|
|
@@ -32,7 +43,7 @@ export const LlmConnectionConfigSchema = z
|
|
|
32
43
|
apiKey: z.string().optional(),
|
|
33
44
|
temperature: z.number().finite().optional(),
|
|
34
45
|
maxTokens: positiveInt.optional(),
|
|
35
|
-
timeoutMs:
|
|
46
|
+
timeoutMs: timeoutMsField,
|
|
36
47
|
concurrency: positiveInt.optional(),
|
|
37
48
|
capabilities: LlmCapabilitiesSchema.optional(),
|
|
38
49
|
extraParams: ExtraParamsSchema.optional(),
|
|
@@ -56,7 +67,7 @@ const LlmEngineSchema = z
|
|
|
56
67
|
apiKey: z.string().regex(ENV_REFERENCE_PATTERN, `apiKey must be $VAR or \${VAR}`).optional(),
|
|
57
68
|
temperature: z.number().finite().optional(),
|
|
58
69
|
maxTokens: positiveInt.optional(),
|
|
59
|
-
timeoutMs:
|
|
70
|
+
timeoutMs: timeoutMsField,
|
|
60
71
|
concurrency: positiveInt.optional(),
|
|
61
72
|
supportsJsonSchema: z.boolean().optional(),
|
|
62
73
|
extraParams: ExtraParamsSchema.optional(),
|
|
@@ -80,7 +91,7 @@ const AgentEngineSchema = z
|
|
|
80
91
|
args: z.array(z.string()).optional(),
|
|
81
92
|
workspace: nonEmptyString.optional(),
|
|
82
93
|
model: nonEmptyString.optional(),
|
|
83
|
-
timeoutMs:
|
|
94
|
+
timeoutMs: timeoutMsField,
|
|
84
95
|
modelAliases: ModelAliasMapSchema.optional(),
|
|
85
96
|
llmEngine: engineName.optional(),
|
|
86
97
|
})
|
|
@@ -19,10 +19,21 @@ import { engineName, positiveInt } from "./primitives.js";
|
|
|
19
19
|
* `[1, WORKFLOW_MAX_CONCURRENCY_CEILING]` (64). Values above the ceiling
|
|
20
20
|
* are clamped, not rejected, so a config shared across machines with wildly
|
|
21
21
|
* different core counts never hard-fails validation.
|
|
22
|
+
*
|
|
23
|
+
* `defaultMapConcurrency` is the width a `map` step freezes when it declares no
|
|
24
|
+
* `concurrency:` of its own:
|
|
25
|
+
* - UNSET → `DEFAULT_MAP_CONCURRENCY` (4) from
|
|
26
|
+
* `src/workflows/concurrency-policy.ts`.
|
|
27
|
+
* - SET → the explicit positive integer, CLAMPED to `[1, 64]`. Setting it
|
|
28
|
+
* to `1` restores the pre-0.9.1 serial-by-default fan-out for every
|
|
29
|
+
* workflow on this install. It is a floor for authoring only: it never
|
|
30
|
+
* raises a step above `maxConcurrency`, the engine's concurrency, or the
|
|
31
|
+
* host CPU cap, and it never overrides an authored `map.concurrency`.
|
|
22
32
|
*/
|
|
23
33
|
export const WorkflowConfigSchema = z
|
|
24
34
|
.object({
|
|
25
35
|
maxConcurrency: positiveInt.optional(),
|
|
36
|
+
defaultMapConcurrency: positiveInt.optional(),
|
|
26
37
|
/** Named LLM or agent engine frozen into every criteria-bearing gate. */
|
|
27
38
|
judgeEngine: engineName.optional(),
|
|
28
39
|
})
|
package/dist/core/errors.js
CHANGED
|
@@ -9,6 +9,7 @@ const CONFIG_HINTS = {
|
|
|
9
9
|
STASH_DIR_NOT_FOUND: "Run `akm setup` to create and configure your bundle, or configure a defaultBundle path.",
|
|
10
10
|
STASH_DIR_NOT_A_DIRECTORY: "The configured default bundle path exists but isn't a directory. Update it to point at a folder.",
|
|
11
11
|
STASH_DIR_UNREADABLE: "Check the path exists and your user has read permission, or update the default bundle path.",
|
|
12
|
+
DATA_DIR_UNREADABLE: "The data directory is not readable by the user running akm. Check its owner and mode, or point AKM_DATA_DIR / XDG_DATA_HOME somewhere this user owns.",
|
|
12
13
|
EMBEDDING_NOT_CONFIGURED: 'Run `akm config set embedding \'{"endpoint":"...","model":"..."}\'` to enable embeddings.',
|
|
13
14
|
LLM_NOT_CONFIGURED: 'Run `akm setup` or configure an `engines` entry with `kind: "llm"`, then select it with `defaults.llmEngine`.',
|
|
14
15
|
TEST_ISOLATION_MISSING: "Under bun test, when AKM_BUNDLE_DIR is set you MUST also set XDG_DATA_HOME (or AKM_DATA_DIR) and XDG_STATE_HOME (or AKM_STATE_DIR) to temp directories so the test does not touch the developer's real ~/.local/share/akm or ~/.local/state/akm.",
|
|
@@ -131,3 +132,27 @@ export function rethrowIfTestIsolationError(err) {
|
|
|
131
132
|
throw err;
|
|
132
133
|
}
|
|
133
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Unreadable-data-dir guard helper — the #791 sibling of the test-isolation
|
|
137
|
+
* pair above, and it exists for the same reason.
|
|
138
|
+
*
|
|
139
|
+
* `DATA_DIR_UNREADABLE` says "this path is there and I am not allowed to read
|
|
140
|
+
* it". It is raised by `assertIndexPathReadable` and friends precisely so a
|
|
141
|
+
* permission fault stops being indistinguishable from "nothing indexed yet".
|
|
142
|
+
* That distinction is destroyed again the moment a best-effort `catch` around
|
|
143
|
+
* the open collapses it into the same `null`/`[]`/`0` the absent case returns —
|
|
144
|
+
* which is how `akm search` came to answer "No search index available. Run
|
|
145
|
+
* 'akm index'" at exit 0 for a populated index sitting right there on disk.
|
|
146
|
+
*
|
|
147
|
+
* Call `rethrowIfDataDirUnreadable(err)` from any catch block that returns a
|
|
148
|
+
* fallback value after touching a data-dir path. Absent stays absent; a fault
|
|
149
|
+
* the operator has to fix keeps travelling.
|
|
150
|
+
*/
|
|
151
|
+
export function isDataDirUnreadableError(err) {
|
|
152
|
+
return err instanceof ConfigError && err.code === "DATA_DIR_UNREADABLE";
|
|
153
|
+
}
|
|
154
|
+
export function rethrowIfDataDirUnreadable(err) {
|
|
155
|
+
if (isDataDirUnreadableError(err)) {
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
}
|
package/dist/core/events.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { insertEvent, readStateEvents } from "../storage/repositories/events-repository.js";
|
|
5
5
|
import { rethrowIfTestIsolationError } from "./errors.js";
|
|
6
6
|
import { getStateDbPath, openStateDatabase, withStateDb } from "./state-db.js";
|
|
7
|
+
import { borrowScopedStateDb } from "./state-db-scope.js";
|
|
7
8
|
import { error } from "./warn.js";
|
|
8
9
|
/**
|
|
9
10
|
* Resolve the state.db path from context:
|
|
@@ -28,36 +29,41 @@ function resolveNow(ctx) {
|
|
|
28
29
|
* function writes directly to that handle without opening or closing the DB.
|
|
29
30
|
* This eliminates per-event open/migrate/close overhead for high-frequency
|
|
30
31
|
* callers such as `akmImprove`.
|
|
32
|
+
*
|
|
33
|
+
* The same fast path is taken IMPLICITLY inside a `withStateDbScope` /
|
|
34
|
+
* `withWorkflowRunsConnection` scope (`core/state-db-scope.ts`): the ambient
|
|
35
|
+
* scoped handle for this event's resolved `dbPath` is borrowed, so a workflow
|
|
36
|
+
* step's `workflow_unit_started` / `workflow_unit_finished` pair rides the same
|
|
37
|
+
* connection its journal rows do instead of opening state.db twice per unit.
|
|
38
|
+
* The scope owns that handle's lifetime; `appendEvent` never closes a borrowed
|
|
39
|
+
* connection.
|
|
31
40
|
*/
|
|
32
41
|
export function appendEvent(input, ctx) {
|
|
33
42
|
const now = resolveNow(ctx);
|
|
34
43
|
const ts = new Date(now()).toISOString();
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
const row = { eventType: input.eventType, ts, ref: input.ref, metadata: input.metadata };
|
|
45
|
+
// One try covers EVERY path — including resolving the state.db path, which
|
|
46
|
+
// reads the environment and throws where no data dir can be derived — so the
|
|
47
|
+
// best-effort contract ("a write failure never propagates") holds no matter
|
|
48
|
+
// which handle this event lands on.
|
|
49
|
+
try {
|
|
50
|
+
// Fast path: an explicitly supplied long-lived connection. Resolution is
|
|
51
|
+
// skipped outright, which is what "`dbPath` is ignored when `db` is
|
|
52
|
+
// provided" has to mean for a caller that already holds an open handle.
|
|
53
|
+
if (ctx?.db) {
|
|
54
|
+
insertEvent(ctx.db, row);
|
|
55
|
+
return;
|
|
44
56
|
}
|
|
45
|
-
|
|
46
|
-
|
|
57
|
+
const dbPath = resolveDbPath(ctx);
|
|
58
|
+
// The ambient scoped handle for this path, when a scope is open — borrowed
|
|
59
|
+
// exactly like the explicit one, and never closed here.
|
|
60
|
+
const borrowed = borrowScopedStateDb(dbPath);
|
|
61
|
+
if (borrowed) {
|
|
62
|
+
insertEvent(borrowed, row);
|
|
63
|
+
return;
|
|
47
64
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
// Default path: open, insert, close.
|
|
51
|
-
const dbPath = resolveDbPath(ctx);
|
|
52
|
-
try {
|
|
53
|
-
withStateDb((db) => {
|
|
54
|
-
insertEvent(db, {
|
|
55
|
-
eventType: input.eventType,
|
|
56
|
-
ts,
|
|
57
|
-
ref: input.ref,
|
|
58
|
-
metadata: input.metadata,
|
|
59
|
-
});
|
|
60
|
-
}, { path: dbPath });
|
|
65
|
+
// Default path: open, insert, close.
|
|
66
|
+
withStateDb((db) => insertEvent(db, row), { path: dbPath });
|
|
61
67
|
}
|
|
62
68
|
catch (err) {
|
|
63
69
|
// Never mask the bun-test isolation guard as a silent "events failed".
|
|
@@ -33,8 +33,16 @@ export function validateExtraParams(value) {
|
|
|
33
33
|
return [{ path: [], message: "must be an object" }];
|
|
34
34
|
}
|
|
35
35
|
const issues = [];
|
|
36
|
+
// A self-referential YAML anchor (`extraParams: &a { nested: *a }`) resolves
|
|
37
|
+
// to a genuinely cyclic object — the yaml package's alias-count guard does not
|
|
38
|
+
// catch cycles — so an unguarded walk overflowed the stack with a RangeError
|
|
39
|
+
// that escaped task parsing. Track visited containers and stop at a revisit.
|
|
40
|
+
const seen = new WeakSet();
|
|
36
41
|
const visit = (entry, path) => {
|
|
37
42
|
if (Array.isArray(entry)) {
|
|
43
|
+
if (seen.has(entry))
|
|
44
|
+
return;
|
|
45
|
+
seen.add(entry);
|
|
38
46
|
entry.forEach((child, index) => {
|
|
39
47
|
visit(child, [...path, index]);
|
|
40
48
|
});
|
|
@@ -42,6 +50,9 @@ export function validateExtraParams(value) {
|
|
|
42
50
|
}
|
|
43
51
|
if (!entry || typeof entry !== "object")
|
|
44
52
|
return;
|
|
53
|
+
if (seen.has(entry))
|
|
54
|
+
return;
|
|
55
|
+
seen.add(entry);
|
|
45
56
|
for (const [key, child] of Object.entries(entry)) {
|
|
46
57
|
const normalized = normalizeExtraParamKey(key);
|
|
47
58
|
if (path.length === 0 && PROTECTED_TOP_LEVEL_KEYS.has(normalized)) {
|
package/dist/core/file-lock.js
CHANGED
|
@@ -139,7 +139,13 @@ export function probeLock(lockPath, opts) {
|
|
|
139
139
|
try {
|
|
140
140
|
snapshot = readLockSnapshot(lockPath);
|
|
141
141
|
}
|
|
142
|
-
catch {
|
|
142
|
+
catch (error) {
|
|
143
|
+
// A permission error means the lock may be genuinely HELD by someone we
|
|
144
|
+
// cannot see. Reporting it as stale would invite a caller to steal a live
|
|
145
|
+
// lease (#791) — `unreadable` stays for the corrupt/undecodable case.
|
|
146
|
+
const code = error?.code;
|
|
147
|
+
if (code === "EACCES" || code === "EPERM")
|
|
148
|
+
return { state: "inaccessible", code };
|
|
143
149
|
return { state: "stale", reason: "unreadable" };
|
|
144
150
|
}
|
|
145
151
|
if (!snapshot)
|
package/dist/core/fs-txn.js
CHANGED
|
@@ -81,7 +81,11 @@ export function txnFileHash(filePath) {
|
|
|
81
81
|
return txnHash(fs.readFileSync(filePath));
|
|
82
82
|
}
|
|
83
83
|
export function fsyncTxnFile(filePath) {
|
|
84
|
-
|
|
84
|
+
// Open for WRITE. Windows implements fsync as FlushFileBuffers, which
|
|
85
|
+
// requires write access on the handle — a read-only descriptor fails with
|
|
86
|
+
// EACCES/EPERM, so every proposal accept and reject failed on that platform.
|
|
87
|
+
// POSIX accepts "r+" here just as readily as "r".
|
|
88
|
+
const fd = fs.openSync(filePath, "r+");
|
|
85
89
|
try {
|
|
86
90
|
fs.fsyncSync(fd);
|
|
87
91
|
}
|
|
@@ -91,7 +95,16 @@ export function fsyncTxnFile(filePath) {
|
|
|
91
95
|
}
|
|
92
96
|
export function fsyncTxnDir(dirPath) {
|
|
93
97
|
try {
|
|
94
|
-
fsyncTxnFile
|
|
98
|
+
// Read-only, unlike {@link fsyncTxnFile}: a directory cannot be opened for
|
|
99
|
+
// write on POSIX (EISDIR), and on Windows this whole operation is
|
|
100
|
+
// unsupported anyway and falls into the catch.
|
|
101
|
+
const fd = fs.openSync(dirPath, "r");
|
|
102
|
+
try {
|
|
103
|
+
fs.fsyncSync(fd);
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
fs.closeSync(fd);
|
|
107
|
+
}
|
|
95
108
|
}
|
|
96
109
|
catch {
|
|
97
110
|
// Directory fsync is unavailable on some platforms.
|
|
@@ -40,6 +40,7 @@ const COMMON_FIELDS = [
|
|
|
40
40
|
"cycleMetrics",
|
|
41
41
|
"runId",
|
|
42
42
|
"sync",
|
|
43
|
+
"writtenPaths",
|
|
43
44
|
"terminated",
|
|
44
45
|
];
|
|
45
46
|
const V2_FIELDS = new Set([...COMMON_FIELDS, "strategy", "strategyFilteredRefs"]);
|
|
@@ -83,10 +84,14 @@ function validateCommon(value) {
|
|
|
83
84
|
"extract",
|
|
84
85
|
"coverageGaps",
|
|
85
86
|
"deadUrls",
|
|
87
|
+
"writtenPaths",
|
|
86
88
|
]) {
|
|
87
89
|
if (value[field] !== undefined && !Array.isArray(value[field]))
|
|
88
90
|
fail(`${field} must be an array`);
|
|
89
91
|
}
|
|
92
|
+
if (Array.isArray(value.writtenPaths) && value.writtenPaths.some((entry) => typeof entry !== "string")) {
|
|
93
|
+
fail("writtenPaths must be an array of strings");
|
|
94
|
+
}
|
|
90
95
|
for (const field of [
|
|
91
96
|
"cyclesRun",
|
|
92
97
|
"evalCasesWritten",
|