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/redaction.js
CHANGED
|
@@ -297,12 +297,32 @@ function addMappedMatches(coverageDelta, haystack, needle) {
|
|
|
297
297
|
offset = match + Math.max(needle.length, 1);
|
|
298
298
|
}
|
|
299
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* Mark every occurrence of `needle` in `text` — no encoding normalization, for
|
|
302
|
+
* text that contains neither `%` nor `+` and so cannot carry an encoded form.
|
|
303
|
+
*
|
|
304
|
+
* Matching against the ORIGINAL text (rather than an accumulator being rewritten
|
|
305
|
+
* in place) is the whole point: see {@link redactSensitiveText}.
|
|
306
|
+
*/
|
|
307
|
+
function addPlainMatches(coverageDelta, text, needle) {
|
|
308
|
+
if (!needle)
|
|
309
|
+
return;
|
|
310
|
+
let offset = 0;
|
|
311
|
+
while (offset <= text.length - needle.length) {
|
|
312
|
+
const match = text.indexOf(needle, offset);
|
|
313
|
+
if (match < 0)
|
|
314
|
+
break;
|
|
315
|
+
coverageDelta[match] = coverageDelta[match] + 1;
|
|
316
|
+
coverageDelta[match + needle.length] = coverageDelta[match + needle.length] - 1;
|
|
317
|
+
offset = match + needle.length;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
300
320
|
/**
|
|
301
321
|
* Redact credential-shaped substrings from arbitrary text by pattern alone —
|
|
302
322
|
* unlike {@link redactSensitiveText}, which requires the exact secret value
|
|
303
323
|
* up front, this catches credentials no caller ever knew to list. No
|
|
304
324
|
* truncation is applied; callers that need a length cap (e.g.
|
|
305
|
-
* {@link redactErrorBody}
|
|
325
|
+
* {@link redactErrorBody}) apply it themselves.
|
|
306
326
|
*
|
|
307
327
|
* Targets:
|
|
308
328
|
* - `Bearer <token>` headers echoed back by a provider
|
|
@@ -332,31 +352,66 @@ export function redactCredentialPatterns(input) {
|
|
|
332
352
|
.replace(/(hooks\.slack\.com\/services\/[A-Za-z0-9]+\/[A-Za-z0-9]+\/)[A-Za-z0-9]+/gi, "$1[REDACTED]"));
|
|
333
353
|
}
|
|
334
354
|
/**
|
|
335
|
-
* Replace exact sensitive values in text.
|
|
336
|
-
*
|
|
355
|
+
* Replace exact sensitive values in text.
|
|
356
|
+
*
|
|
357
|
+
* Every match is located against the ORIGINAL text and the result is emitted
|
|
358
|
+
* once, so overlapping matches merge into a single `[REDACTED]` and no needle
|
|
359
|
+
* can ever match inside a token an earlier needle produced.
|
|
360
|
+
*
|
|
361
|
+
* That last property is load-bearing. This function used to take a `replaceAll`
|
|
362
|
+
* fast path that chained over a *mutating* accumulator, which meant any needle
|
|
363
|
+
* drawn from the letters of `[REDACTED]` re-matched the tokens already injected
|
|
364
|
+
* and the output grew geometrically: `redactSensitiveText("a".repeat(50),
|
|
365
|
+
* ["a","E","D","T","C","R"])` returned 32,450 characters — 649x the input. On a
|
|
366
|
+
* path where the needle set is derived from configuration or the environment,
|
|
367
|
+
* that is a memory-exhaustion hazard reachable from ordinary command output.
|
|
368
|
+
* The encoded-form path never had the bug because it always worked this way.
|
|
369
|
+
*/
|
|
370
|
+
/** Max characters of a provider error body worth surfacing in a message. */
|
|
371
|
+
const ERROR_BODY_MAX_LEN = 200;
|
|
372
|
+
/**
|
|
373
|
+
* Make an HTTP error body safe to put in an error message: pattern-redact
|
|
374
|
+
* credential shapes, then clip. Provider bodies can echo the credential that
|
|
375
|
+
* was sent and can be megabytes of HTML, and these messages travel — into
|
|
376
|
+
* persisted status files, `--json` output, and agent transcripts.
|
|
377
|
+
*
|
|
378
|
+
* Lives here rather than beside one transport because every HTTP client in the
|
|
379
|
+
* codebase needs it; the embeddings transport originally lacked it and leaked
|
|
380
|
+
* raw 10 MB bodies into `semantic-status.json`.
|
|
337
381
|
*/
|
|
382
|
+
export function redactErrorBody(input) {
|
|
383
|
+
if (!input)
|
|
384
|
+
return "";
|
|
385
|
+
let out = redactCredentialPatterns(input);
|
|
386
|
+
if (out.length > ERROR_BODY_MAX_LEN) {
|
|
387
|
+
out = `${out.slice(0, ERROR_BODY_MAX_LEN)}…`;
|
|
388
|
+
}
|
|
389
|
+
return out;
|
|
390
|
+
}
|
|
338
391
|
export function redactSensitiveText(text, sensitiveValues) {
|
|
339
392
|
const values = [...new Set(sensitiveValues)]
|
|
340
393
|
.filter((value) => value.length > 0)
|
|
341
394
|
.sort((a, b) => b.length - a.length || a.localeCompare(b));
|
|
342
395
|
if (values.length === 0)
|
|
343
396
|
return text;
|
|
344
|
-
|
|
345
|
-
|
|
397
|
+
const coverageDelta = new Int32Array(text.length + 1);
|
|
398
|
+
if (text.includes("%") || text.includes("+")) {
|
|
399
|
+
// Percent-/plus-encoded text: match needle and haystack in their decoded
|
|
400
|
+
// forms, mapping hits back to source offsets.
|
|
401
|
+
const addMatchesForMode = (plusAsSpace) => {
|
|
402
|
+
const haystack = normalizeEncodedText(text, plusAsSpace);
|
|
403
|
+
for (const value of values) {
|
|
404
|
+
addMappedMatches(coverageDelta, haystack, normalizeEncodedText(value, plusAsSpace).text);
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
addMatchesForMode(false);
|
|
408
|
+
if (text.includes("+"))
|
|
409
|
+
addMatchesForMode(true);
|
|
410
|
+
}
|
|
411
|
+
else {
|
|
346
412
|
for (const value of values)
|
|
347
|
-
|
|
348
|
-
return redacted;
|
|
413
|
+
addPlainMatches(coverageDelta, text, value);
|
|
349
414
|
}
|
|
350
|
-
const coverageDelta = new Int32Array(text.length + 1);
|
|
351
|
-
const addMatchesForMode = (plusAsSpace) => {
|
|
352
|
-
const haystack = normalizeEncodedText(text, plusAsSpace);
|
|
353
|
-
for (const value of values) {
|
|
354
|
-
addMappedMatches(coverageDelta, haystack, normalizeEncodedText(value, plusAsSpace).text);
|
|
355
|
-
}
|
|
356
|
-
};
|
|
357
|
-
addMatchesForMode(false);
|
|
358
|
-
if (text.includes("+"))
|
|
359
|
-
addMatchesForMode(true);
|
|
360
415
|
let redacted = "";
|
|
361
416
|
let coverage = 0;
|
|
362
417
|
let offset = 0;
|
|
@@ -384,7 +439,20 @@ export function redactSensitiveValue(value, sensitiveValues) {
|
|
|
384
439
|
if (Array.isArray(entry))
|
|
385
440
|
return entry.map(redact);
|
|
386
441
|
if (entry && typeof entry === "object") {
|
|
387
|
-
|
|
442
|
+
const out = {};
|
|
443
|
+
for (const [key, child] of Object.entries(entry)) {
|
|
444
|
+
const redactedKey = redactSensitiveText(key, values);
|
|
445
|
+
// Two DISTINCT keys can redact to the same string (`{a, b, ab}` under
|
|
446
|
+
// needles `a`/`b` all collapse toward `[REDACTED]`). Building this with
|
|
447
|
+
// `Object.fromEntries` kept only the last of each colliding group, so a
|
|
448
|
+
// field was silently DROPPED rather than redacted — data loss disguised
|
|
449
|
+
// as redaction. Suffix instead: the value stays, the key stays hidden.
|
|
450
|
+
let finalKey = redactedKey;
|
|
451
|
+
for (let n = 2; Object.hasOwn(out, finalKey); n++)
|
|
452
|
+
finalKey = `${redactedKey} (${n})`;
|
|
453
|
+
out[finalKey] = redact(child);
|
|
454
|
+
}
|
|
455
|
+
return out;
|
|
388
456
|
}
|
|
389
457
|
return entry;
|
|
390
458
|
};
|
|
@@ -0,0 +1,234 @@
|
|
|
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
|
+
* The ONE allowlist-based child-environment primitive.
|
|
6
|
+
*
|
|
7
|
+
* Two akm code paths spawn a child from an explicit list of environment
|
|
8
|
+
* variable NAMES — the agent-CLI spawn wrapper
|
|
9
|
+
* (`integrations/agent/spawn.ts`, `profile.envPassthrough`) and the workflow
|
|
10
|
+
* `exec` unit runner (`workflows/exec/exec-unit.ts`) — and both start from an
|
|
11
|
+
* EMPTY environment and copy through named entries with
|
|
12
|
+
* {@link collectAllowlistedEnv}. Keeping that in one leaf module is what makes
|
|
13
|
+
* "allowlist" a single reviewable mechanism instead of two implementations
|
|
14
|
+
* that drift apart.
|
|
15
|
+
*
|
|
16
|
+
* NOT covered: the opencode-sdk server spawn
|
|
17
|
+
* (`integrations/harnesses/opencode-sdk/sdk-runner.ts`) keeps its own
|
|
18
|
+
* hard-coded name list and does not route through here, so it gets neither the
|
|
19
|
+
* platform floor below nor PATH supplementation.
|
|
20
|
+
*
|
|
21
|
+
* A LEAF: node built-ins only, so both the integrations layer and the workflow
|
|
22
|
+
* engine can import it without opening a cycle.
|
|
23
|
+
*
|
|
24
|
+
* @module core/spawn-env
|
|
25
|
+
*/
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import os from "node:os";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
/**
|
|
30
|
+
* The baseline env names every allowlisted akm child receives regardless of
|
|
31
|
+
* what it runs: process identity (`HOME`, `USER`), tool resolution (`PATH`),
|
|
32
|
+
* locale (`LANG`, `LC_ALL`), terminal (`TERM`), scratch space (`TMPDIR`), and
|
|
33
|
+
* akm's own event provenance (`AKM_EVENT_SOURCE` — machine traffic, never a
|
|
34
|
+
* secret). BOTH allowlists extend it — the agent-CLI profiles'
|
|
35
|
+
* `COMMON_PASSTHROUGH` (`integrations/agent/profiles.ts`) and the workflow
|
|
36
|
+
* exec unit's `EXEC_DEFAULT_ENV_PASSTHROUGH` (`workflows/exec/exec-unit.ts`)
|
|
37
|
+
* — so a baseline name cannot drift into one child-spawn path but not the
|
|
38
|
+
* other. NOTE: profile `envPassthrough` is frozen into workflow engine
|
|
39
|
+
* snapshots, so growing this list changes frozen-plan content — extend
|
|
40
|
+
* deliberately.
|
|
41
|
+
*/
|
|
42
|
+
export const COMMON_SPAWN_ENV_PASSTHROUGH = [
|
|
43
|
+
"HOME",
|
|
44
|
+
"PATH",
|
|
45
|
+
"USER",
|
|
46
|
+
"LANG",
|
|
47
|
+
"LC_ALL",
|
|
48
|
+
"TERM",
|
|
49
|
+
"TMPDIR",
|
|
50
|
+
"AKM_EVENT_SOURCE",
|
|
51
|
+
];
|
|
52
|
+
/**
|
|
53
|
+
* The names Windows itself requires of ANY child, whatever the caller's
|
|
54
|
+
* allowlist says. Applied at build time rather than added to
|
|
55
|
+
* {@link COMMON_SPAWN_ENV_PASSTHROUGH} because profile `envPassthrough` is
|
|
56
|
+
* frozen into workflow engine snapshots: growing the shared list would change
|
|
57
|
+
* the bytes — and so the hashes — of every plan already on disk, to express
|
|
58
|
+
* something that is not a policy choice at all.
|
|
59
|
+
*
|
|
60
|
+
* `SystemRoot`, `SystemDrive` and `WINDIR` are what PROCESS CREATION reads;
|
|
61
|
+
* without them the loader cannot find system DLLs and the spawn fails before
|
|
62
|
+
* the command runs. Without `PATHEXT` Windows never tries `bun.exe`/`bun.cmd`,
|
|
63
|
+
* so a `bin: "bun"` profile is unresolvable, and `COMSPEC` is how `.bat`/`.cmd`
|
|
64
|
+
* targets resolve at all. The rest are the win32 analogues of baseline names
|
|
65
|
+
* the POSIX side already grants — `HOME` (`USERPROFILE`, `HOMEDRIVE`,
|
|
66
|
+
* `HOMEPATH`) and `TMPDIR` (`TEMP`, `TMP`). None is a secret.
|
|
67
|
+
*
|
|
68
|
+
* Config and install roots (`APPDATA`, `LOCALAPPDATA`, `ProgramFiles`, …) are
|
|
69
|
+
* deliberately NOT here: a child can be created and can resolve its command
|
|
70
|
+
* without them, so which allowlist wants them stays a per-caller decision.
|
|
71
|
+
*
|
|
72
|
+
* THE definition of the floor. The workflow exec allowlist
|
|
73
|
+
* (`EXEC_DEFAULT_ENV_PASSTHROUGH`) spreads this constant rather than
|
|
74
|
+
* re-spelling it, because that list is also consumed on POSIX — where
|
|
75
|
+
* {@link spawnEnvNamesFor} appends nothing — and the names still have to be
|
|
76
|
+
* requestable there for a win32 run of the same workflow.
|
|
77
|
+
*/
|
|
78
|
+
export const WIN32_SPAWN_ENV_FLOOR = [
|
|
79
|
+
"SystemRoot",
|
|
80
|
+
"SystemDrive",
|
|
81
|
+
"WINDIR",
|
|
82
|
+
"COMSPEC",
|
|
83
|
+
"PATHEXT",
|
|
84
|
+
"USERPROFILE",
|
|
85
|
+
"HOMEDRIVE",
|
|
86
|
+
"HOMEPATH",
|
|
87
|
+
"TEMP",
|
|
88
|
+
"TMP",
|
|
89
|
+
];
|
|
90
|
+
/**
|
|
91
|
+
* The effective allowlist for `platform`: the caller's names, plus any floor
|
|
92
|
+
* the operating system requires of every child regardless of allowlist.
|
|
93
|
+
* Exported for tests, which must be able to ask for a platform they are not
|
|
94
|
+
* running on.
|
|
95
|
+
*/
|
|
96
|
+
export function spawnEnvNamesFor(names, platform = process.platform) {
|
|
97
|
+
const effective = [...names];
|
|
98
|
+
if (platform !== "win32")
|
|
99
|
+
return effective;
|
|
100
|
+
// Deduped by EXACT spelling, never case-folded: {@link collectAllowlistedEnv}
|
|
101
|
+
// looks each surviving name up with exactly this case, and a `source` that is
|
|
102
|
+
// a plain object — the agent spawn's `envSource` seam — does not case-fold.
|
|
103
|
+
// Suppressing `SystemRoot` because the caller happened to write `SYSTEMROOT`
|
|
104
|
+
// would therefore drop the loader-critical variable the floor exists to
|
|
105
|
+
// guarantee. Keeping both spellings is harmless: the win32 environment is
|
|
106
|
+
// itself case-insensitive, so they resolve to the same value.
|
|
107
|
+
const present = new Set(effective);
|
|
108
|
+
for (const name of WIN32_SPAWN_ENV_FLOOR) {
|
|
109
|
+
if (!present.has(name))
|
|
110
|
+
effective.push(name);
|
|
111
|
+
}
|
|
112
|
+
return effective;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Build a child environment from an allowlist: start EMPTY and copy through
|
|
116
|
+
* exactly the named variables that exist in `source`. Names absent from the
|
|
117
|
+
* source are simply absent from the child (never an empty string, which many
|
|
118
|
+
* tools treat as "set but blank").
|
|
119
|
+
*
|
|
120
|
+
* `PATH`, when it comes through, is supplemented for scheduler contexts — see
|
|
121
|
+
* {@link supplementPathForSchedulerContext}. That happens here rather than in
|
|
122
|
+
* each caller so a child spawned from cron/launchd/Task Scheduler can find the
|
|
123
|
+
* user's toolchain no matter which spawn path reached it. On win32 the names
|
|
124
|
+
* in {@link WIN32_SPAWN_ENV_FLOOR} come through too, for the same reason: the
|
|
125
|
+
* spawn cannot succeed without them, whichever caller built the list.
|
|
126
|
+
*/
|
|
127
|
+
export function collectAllowlistedEnv(names, source = process.env) {
|
|
128
|
+
const env = {};
|
|
129
|
+
for (const name of spawnEnvNamesFor(names)) {
|
|
130
|
+
const value = source[name];
|
|
131
|
+
if (value !== undefined)
|
|
132
|
+
env[name] = value;
|
|
133
|
+
}
|
|
134
|
+
if (env.PATH !== undefined) {
|
|
135
|
+
env.PATH = supplementPathForSchedulerContext(env.PATH);
|
|
136
|
+
}
|
|
137
|
+
return env;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Answers already computed by {@link supplementPathForSchedulerContext}, keyed
|
|
141
|
+
* by the input PATH and the home directory it was computed against.
|
|
142
|
+
*
|
|
143
|
+
* Correctness envelope: the answer is a pure function of those two, the
|
|
144
|
+
* platform, and which candidate directories exist on disk. The platform cannot
|
|
145
|
+
* change under a running process, and the other two are in the key — so the one
|
|
146
|
+
* thing the memo assumes is that a candidate directory does not APPEAR while
|
|
147
|
+
* akm runs. Answering the rest of a run the way its start was answered is what
|
|
148
|
+
* a shell's own command-path caching already does, and the cost of not
|
|
149
|
+
* memoizing is paid on every child-env build: two PATH splits and up to seven
|
|
150
|
+
* SYNCHRONOUS `existsSync` probes, on the event loop a 10 000-unit fan-out
|
|
151
|
+
* shares with the run's lease heartbeat.
|
|
152
|
+
*
|
|
153
|
+
* Bounded so a caller that somehow varies its PATH cannot grow it without
|
|
154
|
+
* limit; a spawn path only ever sees a handful of distinct PATH strings, so
|
|
155
|
+
* the reset is effectively unreachable in practice.
|
|
156
|
+
*/
|
|
157
|
+
const supplementedPaths = new Map();
|
|
158
|
+
const SUPPLEMENTED_PATH_MEMO_MAX = 64;
|
|
159
|
+
/**
|
|
160
|
+
* Supplement `existingPath` with well-known user binary directories when
|
|
161
|
+
* running in a scheduler context (cron/launchd) where PATH is stripped.
|
|
162
|
+
*
|
|
163
|
+
* Detection heuristic: if the current PATH does not contain the user's home
|
|
164
|
+
* directory, we are likely in a stripped scheduler env. In an interactive
|
|
165
|
+
* shell the user's home almost always appears (e.g. ~/.bun/bin, ~/.cargo/bin).
|
|
166
|
+
*
|
|
167
|
+
* Only directories that actually exist on disk are prepended, and only if
|
|
168
|
+
* they are not already present, so interactive-shell PATH ordering is never
|
|
169
|
+
* disturbed.
|
|
170
|
+
*
|
|
171
|
+
* Memoized per input PATH — see {@link supplementedPaths} for what that
|
|
172
|
+
* assumes.
|
|
173
|
+
*/
|
|
174
|
+
export function supplementPathForSchedulerContext(existingPath) {
|
|
175
|
+
const home = os.homedir();
|
|
176
|
+
// NUL-joined so no pair of (home, PATH) can collide onto one key: no path
|
|
177
|
+
// component can contain a NUL.
|
|
178
|
+
const key = `${home}\u0000${existingPath}`;
|
|
179
|
+
const memoized = supplementedPaths.get(key);
|
|
180
|
+
if (memoized !== undefined)
|
|
181
|
+
return memoized;
|
|
182
|
+
const supplemented = computeSupplementedPath(existingPath, home);
|
|
183
|
+
if (supplementedPaths.size >= SUPPLEMENTED_PATH_MEMO_MAX)
|
|
184
|
+
supplementedPaths.clear();
|
|
185
|
+
supplementedPaths.set(key, supplemented);
|
|
186
|
+
return supplemented;
|
|
187
|
+
}
|
|
188
|
+
function computeSupplementedPath(existingPath, home) {
|
|
189
|
+
// A home of `/` (system crontab, launchd, service accounts) or of `""`
|
|
190
|
+
// prefixes EVERY entry, so a prefix test would read the most stripped
|
|
191
|
+
// environments there are as interactive and skip the repair they exist for.
|
|
192
|
+
const comparableHome = home === "" || home === path.sep ? undefined : home;
|
|
193
|
+
// If PATH already contains the home directory, we are in an interactive
|
|
194
|
+
// shell — skip supplementation entirely. Compared on a path boundary: a
|
|
195
|
+
// sibling home (`/home/alice` next to `/home/al`) is not this user's.
|
|
196
|
+
const isUnderHome = (dir) => comparableHome !== undefined && (dir === comparableHome || dir.startsWith(comparableHome + path.sep));
|
|
197
|
+
if (existingPath.split(path.delimiter).some(isUnderHome)) {
|
|
198
|
+
return existingPath;
|
|
199
|
+
}
|
|
200
|
+
const candidates = pathCandidatesForCurrentPlatform(home);
|
|
201
|
+
const existing = new Set(existingPath.split(path.delimiter).filter(Boolean));
|
|
202
|
+
const toAdd = candidates.filter((d) => !existing.has(d) && fs.existsSync(d));
|
|
203
|
+
if (toAdd.length === 0)
|
|
204
|
+
return existingPath;
|
|
205
|
+
return [...toAdd, existingPath].filter(Boolean).join(path.delimiter);
|
|
206
|
+
}
|
|
207
|
+
function pathCandidatesForCurrentPlatform(home) {
|
|
208
|
+
if (process.platform === "win32") {
|
|
209
|
+
// Windows: Bun + Cargo + Scoop + Chocolatey + system tools. Order favors
|
|
210
|
+
// user-local installs over machine-global so the user's chosen toolchain
|
|
211
|
+
// wins. These paths are commonly stripped from Task Scheduler / service
|
|
212
|
+
// environments, mirroring the cron/launchd problem on POSIX.
|
|
213
|
+
const localAppData = process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local");
|
|
214
|
+
const userProfile = process.env.USERPROFILE ?? home;
|
|
215
|
+
const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
|
|
216
|
+
return [
|
|
217
|
+
path.join(userProfile, ".bun", "bin"),
|
|
218
|
+
path.join(localAppData, "Programs", "bun"),
|
|
219
|
+
path.join(userProfile, ".cargo", "bin"),
|
|
220
|
+
path.join(localAppData, "Programs", "Git", "cmd"),
|
|
221
|
+
path.join(userProfile, "scoop", "shims"),
|
|
222
|
+
path.join(programFiles, "Git", "cmd"),
|
|
223
|
+
"C:\\ProgramData\\chocolatey\\bin",
|
|
224
|
+
];
|
|
225
|
+
}
|
|
226
|
+
return [
|
|
227
|
+
path.join(home, ".bun", "bin"),
|
|
228
|
+
path.join(home, ".cargo", "bin"),
|
|
229
|
+
path.join(home, ".local", "bin"),
|
|
230
|
+
"/opt/homebrew/bin",
|
|
231
|
+
"/opt/homebrew/sbin",
|
|
232
|
+
"/usr/local/bin",
|
|
233
|
+
];
|
|
234
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
* Ambient, run-scoped state.db connection reuse.
|
|
6
|
+
*
|
|
7
|
+
* `openStateDatabase` is NOT cheap: every call registers a `state-db`
|
|
8
|
+
* maintenance activity (a lockfile create under the maintenance barrier), opens
|
|
9
|
+
* a throwaway read-only preflight handle to assert the migration ledger, then
|
|
10
|
+
* opens the real handle and applies pragmas. Paying that per repository call
|
|
11
|
+
* — twice per dispatched workflow unit (insert + finish), plus once per
|
|
12
|
+
* `appendEvent` (two events per unit) — is the dominant cost of a wide `map`
|
|
13
|
+
* fan-out.
|
|
14
|
+
*
|
|
15
|
+
* This module adds the ONE thing the codebase was missing: a way to say "for
|
|
16
|
+
* the duration of this async operation, everything that talks to state.db
|
|
17
|
+
* shares a single handle". It deliberately does NOT introduce a pool, a cache
|
|
18
|
+
* with an eviction policy, or a background keep-alive timer — those all leak
|
|
19
|
+
* handles across the test harness's per-test data-dir swaps. The scope owns
|
|
20
|
+
* exactly one connection and closes it in a `finally`.
|
|
21
|
+
*
|
|
22
|
+
* ## Why this is safe
|
|
23
|
+
*
|
|
24
|
+
* SQLite connections must not be shared across THREADS. This is a single
|
|
25
|
+
* process running a single-threaded JS event loop: `bun:sqlite` statement
|
|
26
|
+
* execution and `withImmediateTransaction`'s `BEGIN IMMEDIATE … COMMIT` bodies
|
|
27
|
+
* are fully synchronous, so two logically concurrent units can never interleave
|
|
28
|
+
* statements on the shared handle — the runtime cannot preempt a synchronous
|
|
29
|
+
* transaction body. Sharing one handle therefore REMOVES in-process writer
|
|
30
|
+
* contention (`SQLITE_BUSY` against ourselves) rather than creating it.
|
|
31
|
+
* Cross-process behaviour is untouched: WAL mode, the 30 s `busy_timeout` and
|
|
32
|
+
* the run-lease protocol all still arbitrate between processes exactly as
|
|
33
|
+
* before.
|
|
34
|
+
*
|
|
35
|
+
* The one hazard of an {@link AsyncLocalStorage}-carried handle is async work
|
|
36
|
+
* that ESCAPES the scope: the context propagates into a promise that settles
|
|
37
|
+
* after the scope's `finally` closed the handle. {@link borrowScopedStateDb}
|
|
38
|
+
* guards that with a `closed` flag — once a scope is torn down it stops
|
|
39
|
+
* lending, and escapee callers transparently fall back to opening their own
|
|
40
|
+
* connection (the pre-existing behaviour). A use-after-close is structurally
|
|
41
|
+
* impossible.
|
|
42
|
+
*/
|
|
43
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
44
|
+
import path from "node:path";
|
|
45
|
+
import { getStateDbPath, openStateDatabase } from "./state-db.js";
|
|
46
|
+
const scopeStorage = new AsyncLocalStorage();
|
|
47
|
+
/** Every scope with a live handle, so the exit backstop can close them all. */
|
|
48
|
+
const liveScopes = new Set();
|
|
49
|
+
let exitHookInstalled = false;
|
|
50
|
+
function installExitBackstop() {
|
|
51
|
+
if (exitHookInstalled)
|
|
52
|
+
return;
|
|
53
|
+
exitHookInstalled = true;
|
|
54
|
+
// Idempotent, synchronous backstop: a process that exits mid-scope (a
|
|
55
|
+
// `process.exit()` from a command, an uncaught fatal) still releases the
|
|
56
|
+
// handle and its maintenance-activity lockfile. Normal teardown happens in
|
|
57
|
+
// the scope's own `finally`, which removes the scope from `liveScopes`
|
|
58
|
+
// first, so this never double-closes.
|
|
59
|
+
process.on("exit", closeAllStateDbScopes);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Close every scope-owned handle. Idempotent and safe to call at any time: a
|
|
63
|
+
* closed scope stops lending, so in-flight borrowers fall back to their own
|
|
64
|
+
* connections instead of using a dead handle. Module-private: the per-scope
|
|
65
|
+
* `finally` plus the process-exit backstop above are what guarantee no handle
|
|
66
|
+
* leak — export this the day a caller actually wires deterministic teardown.
|
|
67
|
+
*/
|
|
68
|
+
function closeAllStateDbScopes() {
|
|
69
|
+
for (const scope of [...liveScopes])
|
|
70
|
+
closeScope(scope);
|
|
71
|
+
}
|
|
72
|
+
function closeScope(scope) {
|
|
73
|
+
scope.closed = true;
|
|
74
|
+
liveScopes.delete(scope);
|
|
75
|
+
const db = scope.db;
|
|
76
|
+
scope.db = undefined;
|
|
77
|
+
if (!db)
|
|
78
|
+
return;
|
|
79
|
+
try {
|
|
80
|
+
db.close();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// A close failure must never mask the caller's own error (or wedge exit).
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The ambient scoped handle for `dbPath` (default: the canonical state.db), or
|
|
88
|
+
* `undefined` when there is no live scope for that exact path.
|
|
89
|
+
*
|
|
90
|
+
* The path comparison matters: the test harness repoints `AKM_DATA_DIR` between
|
|
91
|
+
* tests, and a scope entered against one data dir must never lend its handle to
|
|
92
|
+
* a caller resolving a different one.
|
|
93
|
+
*/
|
|
94
|
+
export function borrowScopedStateDb(dbPath) {
|
|
95
|
+
const scope = scopeStorage.getStore();
|
|
96
|
+
if (!scope || scope.closed)
|
|
97
|
+
return undefined;
|
|
98
|
+
if (path.resolve(dbPath ?? getStateDbPath()) !== scope.dbPath)
|
|
99
|
+
return undefined;
|
|
100
|
+
scope.db ??= openStateDatabase(scope.dbPath);
|
|
101
|
+
liveScopes.add(scope);
|
|
102
|
+
return scope.db;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Run `fn` with one shared state.db connection ambient for its whole async
|
|
106
|
+
* extent. The handle is opened on FIRST borrow (never eagerly) and closed once
|
|
107
|
+
* `fn` settles.
|
|
108
|
+
*
|
|
109
|
+
* Nesting is a no-op join: an inner scope for the same path reuses the outer
|
|
110
|
+
* scope's handle and does not close it, so a caller can enter a scope without
|
|
111
|
+
* knowing whether an outer frame already did.
|
|
112
|
+
*/
|
|
113
|
+
export async function withStateDbScope(fn, opts) {
|
|
114
|
+
const dbPath = path.resolve(opts?.path ?? getStateDbPath());
|
|
115
|
+
const outer = scopeStorage.getStore();
|
|
116
|
+
if (outer && !outer.closed && outer.dbPath === dbPath)
|
|
117
|
+
return fn();
|
|
118
|
+
const scope = { dbPath, db: undefined, closed: false };
|
|
119
|
+
installExitBackstop();
|
|
120
|
+
try {
|
|
121
|
+
return await scopeStorage.run(scope, fn);
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
closeScope(scope);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Test seam: how many scope-owned handles are currently open. Must return to 0. */
|
|
128
|
+
export function openScopedStateDbCount() {
|
|
129
|
+
let count = 0;
|
|
130
|
+
for (const scope of liveScopes)
|
|
131
|
+
if (scope.db)
|
|
132
|
+
count++;
|
|
133
|
+
return count;
|
|
134
|
+
}
|
package/dist/core/state-db.js
CHANGED