@gr8ful/spf 0.4.0 → 0.5.1
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/README.md +122 -4
- package/assets/defaults/spf.config.yaml +6 -0
- package/assets/prompts/reviewer/system.md +1 -1
- package/assets/skill/SKILL.md +1 -0
- package/assets/skill/cookbooks/authoring_chains.md +90 -7
- package/assets/skill/cookbooks/ocr_reviewer.md +196 -0
- package/assets/skill/cookbooks/roster.md +15 -4
- package/assets/skill/cookbooks/spf_overview.md +1 -0
- package/assets/skill/references/config.md +69 -4
- package/assets/skill/references/observability.md +11 -2
- package/assets/templates/ts-flue-ollama.spf.config.yaml +67 -0
- package/assets/templates/ts.spf.config.yaml +5 -0
- package/dist/chains/context.d.ts +30 -0
- package/dist/chains/index.d.ts +94 -10
- package/dist/chains/index.js +70 -5
- package/dist/chains/repo_chains.d.ts +139 -0
- package/dist/chains/repo_chains.js +428 -0
- package/dist/chains/simple_sdlc.d.ts +74 -1
- package/dist/chains/simple_sdlc.js +134 -4
- package/dist/chains/steps.d.ts +215 -20
- package/dist/chains/steps.js +429 -61
- package/dist/cli/ask.d.ts +14 -1
- package/dist/cli/ask.js +32 -2
- package/dist/cli/commands/doctor.d.ts +1 -1
- package/dist/cli/commands/doctor.js +319 -11
- package/dist/cli/commands/init.d.ts +12 -0
- package/dist/cli/commands/init.js +78 -1
- package/dist/cli/commands/list.js +42 -5
- package/dist/cli/commands/run.js +25 -2
- package/dist/cli/commands/watch.d.ts +18 -0
- package/dist/cli/commands/watch.js +158 -10
- package/dist/cli/index.js +60 -3
- package/dist/cli/interview.js +65 -10
- package/dist/core/agent_cc.d.ts +40 -1
- package/dist/core/agent_cc.js +51 -4
- package/dist/core/agent_flue.js +28 -4
- package/dist/core/agents.d.ts +8 -0
- package/dist/core/agents.js +43 -3
- package/dist/core/data_types.d.ts +104 -4
- package/dist/core/data_types.js +99 -2
- package/dist/core/git_helper.d.ts +29 -0
- package/dist/core/git_helper.js +41 -1
- package/dist/core/ollama_provider.d.ts +70 -0
- package/dist/core/ollama_provider.js +208 -0
- package/dist/core/otel.d.ts +352 -0
- package/dist/core/otel.js +793 -0
- package/dist/core/paths.d.ts +3 -0
- package/dist/core/paths.js +48 -1
- package/dist/core/providers.js +4 -0
- package/dist/core/refine.js +11 -3
- package/dist/core/session.js +39 -2
- package/dist/core/tracer.d.ts +31 -2
- package/dist/core/tracer.js +69 -11
- package/dist/core/watch.d.ts +11 -0
- package/dist/core/watch.js +17 -2
- package/dist/test/chains.test.js +8 -3
- package/dist/test/data_types.test.js +140 -2
- package/dist/test/git_helper.test.d.ts +1 -0
- package/dist/test/git_helper.test.js +59 -0
- package/dist/test/hermetic_git.d.ts +1 -0
- package/dist/test/hermetic_git.js +22 -0
- package/dist/test/init_command.test.d.ts +14 -1
- package/dist/test/init_command.test.js +54 -1
- package/dist/test/interview.test.d.ts +15 -1
- package/dist/test/interview.test.js +127 -0
- package/dist/test/ollama_provider.test.d.ts +1 -0
- package/dist/test/ollama_provider.test.js +103 -0
- package/dist/test/otel.test.d.ts +26 -0
- package/dist/test/otel.test.js +512 -0
- package/dist/test/paths.test.d.ts +1 -0
- package/dist/test/paths.test.js +68 -0
- package/dist/test/refine.test.js +64 -1
- package/dist/test/repo_chains.test.d.ts +21 -0
- package/dist/test/repo_chains.test.js +416 -0
- package/dist/test/signoff.test.d.ts +1 -0
- package/dist/test/signoff.test.js +329 -0
- package/dist/test/ui_server.test.d.ts +7 -1
- package/dist/test/ui_server.test.js +1 -0
- package/dist/test/watch.test.js +124 -1
- package/package.json +5 -5
package/dist/core/paths.d.ts
CHANGED
|
@@ -67,6 +67,9 @@ export interface DataPaths {
|
|
|
67
67
|
* alwaysWritable()` matches `cfg.defaults.data_dir` against repo-relative git
|
|
68
68
|
* output, so that field stays exactly as configured. This produces a
|
|
69
69
|
* parallel, absolute set of paths for filesystem use instead of mutating it.
|
|
70
|
+
*
|
|
71
|
+
* Not otherwise side-effect-free: see `healBrokenDataDir` above, which every
|
|
72
|
+
* caller needs run before its own first `mkdirSync` against `data_dir`.
|
|
70
73
|
*/
|
|
71
74
|
export declare function resolveDataPaths(anchor: RepoAnchor, rawDataDir: string, rawDbPath: string): DataPaths;
|
|
72
75
|
/**
|
package/dist/core/paths.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* levels below the package root, so the same relative path resolves either
|
|
17
17
|
* way), with `assets/` a direct child of it.
|
|
18
18
|
*/
|
|
19
|
-
import { existsSync, statSync } from "node:fs";
|
|
19
|
+
import { existsSync, lstatSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
20
20
|
import path from "node:path";
|
|
21
21
|
import { findRepoRoot } from "./git_helper.js";
|
|
22
22
|
export const PACKAGE_ROOT = path.resolve(import.meta.dirname, "..", "..");
|
|
@@ -67,6 +67,49 @@ export function resolveConfigPaths(anchor, explicit) {
|
|
|
67
67
|
}
|
|
68
68
|
return { paths: [BUILTIN_CONFIG_PATH], source: "built-in-only" };
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Self-heals one specific, previously-observed failure mode: `data_dir`
|
|
72
|
+
* existing as a broken symlink — self-referential (points at itself) or
|
|
73
|
+
* dangling (points at something that no longer exists) — instead of a real
|
|
74
|
+
* directory. `spf watch`'s `linkDataDir` guards against ever CREATING one of
|
|
75
|
+
* these now (see its comment in `cli/commands/watch.ts`), but a prior,
|
|
76
|
+
* unguarded build already left one behind on at least one real checkout, and
|
|
77
|
+
* that state persists across every later `spf` invocation until something
|
|
78
|
+
* removes it: `mkdirSync(data_dir, {recursive: true})` — the very first
|
|
79
|
+
* filesystem touch nearly every command makes, whether via `ensureDir`'s
|
|
80
|
+
* session dirs or `acquireLock`'s lockfile dir — throws `ELOOP` before
|
|
81
|
+
* anything else runs, with no recovery path of its own.
|
|
82
|
+
*
|
|
83
|
+
* This has to run HERE, in the one function every one of those call sites
|
|
84
|
+
* resolves `data_dir` through, rather than as a guard inside each of them
|
|
85
|
+
* individually — a fix duplicated at every call site is a fix that's one new
|
|
86
|
+
* call site away from being missed again.
|
|
87
|
+
*
|
|
88
|
+
* `lstatSync` (never follows the link itself) distinguishes "nothing here
|
|
89
|
+
* yet" (the ordinary first-run case — silently returns, nothing to heal)
|
|
90
|
+
* from "a symlink sits here." Only for the symlink case does `realpathSync`
|
|
91
|
+
* (which fully resolves it) get a chance to throw — `ELOOP` for a circular
|
|
92
|
+
* link, `ENOENT` for a dangling target — and only then is anything removed.
|
|
93
|
+
* A real directory never reaches the `realpathSync` call at all.
|
|
94
|
+
*/
|
|
95
|
+
function healBrokenDataDir(data_dir) {
|
|
96
|
+
let entry;
|
|
97
|
+
try {
|
|
98
|
+
entry = lstatSync(data_dir);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return; // nothing at this path at all — ordinary first run
|
|
102
|
+
}
|
|
103
|
+
if (!entry.isSymbolicLink())
|
|
104
|
+
return; // a real directory (or file) — not this bug
|
|
105
|
+
try {
|
|
106
|
+
realpathSync(data_dir);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
rmSync(data_dir);
|
|
110
|
+
console.error(`spf: removed a broken data_dir symlink at ${data_dir} (self-referential or dangling) — a fresh directory will be created in its place`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
70
113
|
/**
|
|
71
114
|
* Anchor a config's own `data_dir`/`observability.db` (as loaded — relative
|
|
72
115
|
* or absolute, whichever the YAML says) to `repo_root`.
|
|
@@ -75,9 +118,13 @@ export function resolveConfigPaths(anchor, explicit) {
|
|
|
75
118
|
* alwaysWritable()` matches `cfg.defaults.data_dir` against repo-relative git
|
|
76
119
|
* output, so that field stays exactly as configured. This produces a
|
|
77
120
|
* parallel, absolute set of paths for filesystem use instead of mutating it.
|
|
121
|
+
*
|
|
122
|
+
* Not otherwise side-effect-free: see `healBrokenDataDir` above, which every
|
|
123
|
+
* caller needs run before its own first `mkdirSync` against `data_dir`.
|
|
78
124
|
*/
|
|
79
125
|
export function resolveDataPaths(anchor, rawDataDir, rawDbPath) {
|
|
80
126
|
const data_dir = path.resolve(anchor.repo_root, rawDataDir);
|
|
127
|
+
healBrokenDataDir(data_dir);
|
|
81
128
|
const data_dir_rel = path.relative(anchor.repo_root, data_dir).split(path.sep).join("/");
|
|
82
129
|
const db_path = path.resolve(anchor.repo_root, rawDbPath);
|
|
83
130
|
const sessions_dir = path.resolve(path.dirname(db_path), "sessions");
|
package/dist/core/providers.js
CHANGED
|
@@ -21,4 +21,8 @@ export const PROVIDER_ENV_KEYS = {
|
|
|
21
21
|
deepseek: ["DEEPSEEK_API_KEY"],
|
|
22
22
|
together: ["TOGETHER_API_KEY"],
|
|
23
23
|
cerebras: ["CEREBRAS_API_KEY"],
|
|
24
|
+
// Keyless: a local server, not a hosted API — nothing to check for or
|
|
25
|
+
// prompt for. An empty array here means "known provider, needs no key",
|
|
26
|
+
// never "unknown provider" (that's a missing table entry, not `[]`).
|
|
27
|
+
ollama: [],
|
|
24
28
|
};
|
package/dist/core/refine.js
CHANGED
|
@@ -27,14 +27,22 @@ export function resolveAuthoringProvider(cfg) {
|
|
|
27
27
|
throw new Error(`watch.issue_provider ${JSON.stringify(cfg.watch.issue_provider)} does not support issue authoring — ` +
|
|
28
28
|
`the refine lane needs "github" (see jira_provider.ts's module comment on why Jira isn't wired up yet)`);
|
|
29
29
|
}
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
// Issue authoring always targets the ISSUE tracker's repo — `issue_repo`
|
|
31
|
+
// if set, falling back to plain `repo` (the common case: issue_provider
|
|
32
|
+
// and code_host are both github, so they're the same repo). Reading
|
|
33
|
+
// `repo` alone would be wrong for a github-issues + bitbucket-code setup,
|
|
34
|
+
// where `repo` names the BITBUCKET repo (see WatchConfigSchema's doc
|
|
35
|
+
// comment) — this would try to create GitHub issues against a Bitbucket
|
|
36
|
+
// identifier.
|
|
37
|
+
const repo = cfg.watch.issue_repo.trim() || cfg.watch.repo.trim();
|
|
38
|
+
if (!repo) {
|
|
39
|
+
throw new Error(`watch.repo (or watch.issue_repo, if code_host names a different repo) is not configured — add it to spf.config.yaml's watch: section, e.g. "owner/name"`);
|
|
32
40
|
}
|
|
33
41
|
const token = process.env["GITHUB_TOKEN"];
|
|
34
42
|
if (!token) {
|
|
35
43
|
throw new Error('GITHUB_TOKEN is not set — the refine lane needs a classic PAT with "repo" scope (or "public_repo" for a public-only repo)');
|
|
36
44
|
}
|
|
37
|
-
return new GitHubProvider(
|
|
45
|
+
return new GitHubProvider(repo, cfg.watch.label_prefix, token);
|
|
38
46
|
}
|
|
39
47
|
function typeLabel(labelPrefix, kind) {
|
|
40
48
|
return `${labelPrefix}:type:${kind}`;
|
package/dist/core/session.js
CHANGED
|
@@ -11,6 +11,15 @@ import { Run } from "./runner.js";
|
|
|
11
11
|
import { Tracer } from "./tracer.js";
|
|
12
12
|
import { engineerName, newId } from "./utils.js";
|
|
13
13
|
import { resolveNotifier } from "./notify/notifier.js";
|
|
14
|
+
import * as otel from "./otel.js";
|
|
15
|
+
/**
|
|
16
|
+
* How long a signalled run may spend pushing spans before it exits anyway.
|
|
17
|
+
* Short on purpose: someone who just pressed ^C is waiting, and an
|
|
18
|
+
* observability projection is never worth making a kill feel broken. The
|
|
19
|
+
* budget is enforced inside `otel.flushAll()` (a raced, unref'd deadline), so
|
|
20
|
+
* an unreachable collector costs exactly this and not one tick more.
|
|
21
|
+
*/
|
|
22
|
+
const SIGNAL_DRAIN_MS = 750;
|
|
14
23
|
/**
|
|
15
24
|
* A killed run still closes its own trace.
|
|
16
25
|
*
|
|
@@ -20,11 +29,31 @@ import { resolveNotifier } from "./notify/notifier.js";
|
|
|
20
29
|
* flight that is already dead. Handling the signal both finalizes here and
|
|
21
30
|
* lets the phase's try/catch record the phase as failed on the way out
|
|
22
31
|
* (best-effort: a signal can still land mid-write).
|
|
32
|
+
*
|
|
33
|
+
* SQLite is written FIRST and synchronously, exactly as before — the otel
|
|
34
|
+
* drain is appended after it and can only ever cost time, never correctness.
|
|
35
|
+
* (`notify` has no equivalent drain on this path: its in-flight webhooks are
|
|
36
|
+
* dropped on a signal today. Fixing that means touching the notifier's
|
|
37
|
+
* lifecycle, which is outside this change; only the otel path is drained here.)
|
|
38
|
+
* A second signal during the drain exits immediately — someone pressing ^C
|
|
39
|
+
* twice means "now", and a shutdown path that ignores that is a hang.
|
|
23
40
|
*/
|
|
24
41
|
function finalizeWhenKilled(run) {
|
|
42
|
+
let draining = false;
|
|
25
43
|
const handler = (signal) => {
|
|
44
|
+
const code = 128 + (signal === "SIGINT" ? 2 : 15);
|
|
45
|
+
if (draining)
|
|
46
|
+
process.exit(code);
|
|
47
|
+
draining = true;
|
|
26
48
|
run.tracer.sessionFinish(run.adw_id, false); // also closes process rows
|
|
27
|
-
|
|
49
|
+
// Unconfigured (the default) exits SYNCHRONOUSLY, exactly as it did before
|
|
50
|
+
// otel existed — no extra tick between the signal and the exit for the
|
|
51
|
+
// repos that never opted in.
|
|
52
|
+
if (!run.tracer.otel)
|
|
53
|
+
process.exit(code);
|
|
54
|
+
// Bounded and never-throwing: flushAll() swallows its own failures and
|
|
55
|
+
// resolves on its own deadline, so this always reaches process.exit().
|
|
56
|
+
void otel.flushAll(SIGNAL_DRAIN_MS).then(() => process.exit(code), () => process.exit(code));
|
|
28
57
|
};
|
|
29
58
|
process.on("SIGTERM", handler);
|
|
30
59
|
process.on("SIGINT", handler);
|
|
@@ -44,7 +73,15 @@ export function ensure(cfg, adwId, cwd, chainName) {
|
|
|
44
73
|
const id = adwId || newId(8);
|
|
45
74
|
const anchor = paths.resolveAnchor(cwd);
|
|
46
75
|
const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
47
|
-
|
|
76
|
+
// `null` unless `observability.otel` is configured — no environment variable
|
|
77
|
+
// can turn this on (see core/otel.ts's EXPLICIT CONFIG ONLY). Constructed
|
|
78
|
+
// BEFORE the Tracer because the Tracer's write methods are the fan-out
|
|
79
|
+
// seams: SQLite stays the source of truth, otel is a projection off it, and
|
|
80
|
+
// registering here (module-level LIVE, exactly like resolveNotifier) is what
|
|
81
|
+
// lets the CLI's finally block and the signal handler above drain it without
|
|
82
|
+
// threading a handle through every call site.
|
|
83
|
+
const otelExporter = otel.resolveOtelExporter(cfg, { adwId: id, chainName: chainName || "adw" });
|
|
84
|
+
const tracer = new Tracer(dataPaths.db_path, path.join(dataPaths.sessions_dir, id, "events.jsonl"), otelExporter);
|
|
48
85
|
const run = new Run({
|
|
49
86
|
cfg,
|
|
50
87
|
adwId: id,
|
package/dist/core/tracer.d.ts
CHANGED
|
@@ -2,18 +2,47 @@
|
|
|
2
2
|
* Tracer: every event lands in JSONL and SQLite AS IT HAPPENS.
|
|
3
3
|
*
|
|
4
4
|
* Files are the raw record; spf.db is the queryable mirror the UI polls.
|
|
5
|
-
* No push transport — the flow is always: agents -> sqlite -> web ui.
|
|
6
5
|
* WAL mode so the UI can read while ADW processes write.
|
|
6
|
+
*
|
|
7
|
+
* No push transport in the CONTROL flow — that is always, still, and only:
|
|
8
|
+
* agents -> sqlite -> web ui. SQLite is the source of truth; nothing
|
|
9
|
+
* downstream of it can affect a phase, a gate, or a run outcome.
|
|
10
|
+
*
|
|
11
|
+
* The one amendment: when (and only when) `observability.otel` is configured,
|
|
12
|
+
* each write method below ends with a single fan-out line to an optional
|
|
13
|
+
* OtelExporter — a lossy, allowlisted PROJECTION of what was just written,
|
|
14
|
+
* pushed to an OTLP endpoint fire-and-forget. It is deliberately NOT a second
|
|
15
|
+
* record: it never blocks, never throws into a caller (see `fanOut`), drops
|
|
16
|
+
* spans under backpressure, and carries only the allowlisted subset of fields
|
|
17
|
+
* (never `EventRecord.payload`, never the request text, never envelope
|
|
18
|
+
* contents — `core/otel.ts`'s header has the full list and the reasons).
|
|
19
|
+
* Methods whose data is entirely outside that allowlist —
|
|
20
|
+
* `sessionRequest` (the operator's prompt), `envelopeRow` (agent output),
|
|
21
|
+
* `processStart`/`processEnd` (pids) — have NO fan-out line on purpose. Do not
|
|
22
|
+
* add one.
|
|
7
23
|
*/
|
|
8
24
|
import { Database } from "./sqlite.ts";
|
|
9
25
|
import type { AgentConfig, EventRecord, GateReport, Phase } from "./data_types.ts";
|
|
26
|
+
import type { OtelExporter } from "./otel.ts";
|
|
10
27
|
export declare class Tracer {
|
|
11
28
|
db: Database;
|
|
12
29
|
dbPath: string;
|
|
13
30
|
eventsJsonl: string;
|
|
14
|
-
|
|
31
|
+
/** `null` unless `observability.otel` is configured — see the header. */
|
|
32
|
+
otel: OtelExporter | null;
|
|
33
|
+
constructor(dbPath: string, eventsJsonl: string, otel?: OtelExporter | null);
|
|
15
34
|
/** Additive column migrations, so a db from an older SPF still opens. */
|
|
16
35
|
private migrate;
|
|
36
|
+
/**
|
|
37
|
+
* The ONE door to the optional otel projection, and the only reason a fan-out
|
|
38
|
+
* line is safe to put at the end of a synchronous write method: it is a
|
|
39
|
+
* no-op when unconfigured, and it swallows everything. An exporter bug, a
|
|
40
|
+
* malformed span, an exhausted queue — none of it may ever surface as a
|
|
41
|
+
* failed phase, because export is not allowed to dispose of anything. The
|
|
42
|
+
* exporter's own methods are synchronous enqueues; the network happens later,
|
|
43
|
+
* on an unref'd timer.
|
|
44
|
+
*/
|
|
45
|
+
private fanOut;
|
|
17
46
|
event(record: EventRecord): string;
|
|
18
47
|
sessionStart(adwId: string, engineer: string, adwName?: string | null): void;
|
|
19
48
|
sessionRequest(adwId: string, request: string): void;
|
package/dist/core/tracer.js
CHANGED
|
@@ -2,8 +2,24 @@
|
|
|
2
2
|
* Tracer: every event lands in JSONL and SQLite AS IT HAPPENS.
|
|
3
3
|
*
|
|
4
4
|
* Files are the raw record; spf.db is the queryable mirror the UI polls.
|
|
5
|
-
* No push transport — the flow is always: agents -> sqlite -> web ui.
|
|
6
5
|
* WAL mode so the UI can read while ADW processes write.
|
|
6
|
+
*
|
|
7
|
+
* No push transport in the CONTROL flow — that is always, still, and only:
|
|
8
|
+
* agents -> sqlite -> web ui. SQLite is the source of truth; nothing
|
|
9
|
+
* downstream of it can affect a phase, a gate, or a run outcome.
|
|
10
|
+
*
|
|
11
|
+
* The one amendment: when (and only when) `observability.otel` is configured,
|
|
12
|
+
* each write method below ends with a single fan-out line to an optional
|
|
13
|
+
* OtelExporter — a lossy, allowlisted PROJECTION of what was just written,
|
|
14
|
+
* pushed to an OTLP endpoint fire-and-forget. It is deliberately NOT a second
|
|
15
|
+
* record: it never blocks, never throws into a caller (see `fanOut`), drops
|
|
16
|
+
* spans under backpressure, and carries only the allowlisted subset of fields
|
|
17
|
+
* (never `EventRecord.payload`, never the request text, never envelope
|
|
18
|
+
* contents — `core/otel.ts`'s header has the full list and the reasons).
|
|
19
|
+
* Methods whose data is entirely outside that allowlist —
|
|
20
|
+
* `sessionRequest` (the operator's prompt), `envelopeRow` (agent output),
|
|
21
|
+
* `processStart`/`processEnd` (pids) — have NO fan-out line on purpose. Do not
|
|
22
|
+
* add one.
|
|
7
23
|
*/
|
|
8
24
|
import { Database } from "./sqlite.js";
|
|
9
25
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
@@ -97,7 +113,10 @@ export class Tracer {
|
|
|
97
113
|
db;
|
|
98
114
|
dbPath;
|
|
99
115
|
eventsJsonl;
|
|
100
|
-
|
|
116
|
+
/** `null` unless `observability.otel` is configured — see the header. */
|
|
117
|
+
otel;
|
|
118
|
+
constructor(dbPath, eventsJsonl, otel) {
|
|
119
|
+
this.otel = otel ?? null;
|
|
101
120
|
mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
102
121
|
this.dbPath = dbPath;
|
|
103
122
|
this.eventsJsonl = eventsJsonl;
|
|
@@ -118,6 +137,27 @@ export class Tracer {
|
|
|
118
137
|
}
|
|
119
138
|
}
|
|
120
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* The ONE door to the optional otel projection, and the only reason a fan-out
|
|
142
|
+
* line is safe to put at the end of a synchronous write method: it is a
|
|
143
|
+
* no-op when unconfigured, and it swallows everything. An exporter bug, a
|
|
144
|
+
* malformed span, an exhausted queue — none of it may ever surface as a
|
|
145
|
+
* failed phase, because export is not allowed to dispose of anything. The
|
|
146
|
+
* exporter's own methods are synchronous enqueues; the network happens later,
|
|
147
|
+
* on an unref'd timer.
|
|
148
|
+
*/
|
|
149
|
+
fanOut(action) {
|
|
150
|
+
if (!this.otel)
|
|
151
|
+
return;
|
|
152
|
+
try {
|
|
153
|
+
action(this.otel);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// Deliberately silent: a logged line per event on a hot path would be
|
|
157
|
+
// its own failure mode, and otel.ts already logs its own send failures
|
|
158
|
+
// exactly once.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
121
161
|
// ── events ──────────────────────────────────────────────────────────────
|
|
122
162
|
event(record) {
|
|
123
163
|
const eventId = `evt_${newId(12)}`;
|
|
@@ -128,23 +168,28 @@ export class Tracer {
|
|
|
128
168
|
.query(`INSERT INTO events (event_id, adw_id, phase_id, parent_id, type, name,
|
|
129
169
|
payload_json, tokens, started_at, ended_at) VALUES (?,?,?,?,?,?,?,?,?,?)`)
|
|
130
170
|
.run(eventId, record.adw_id, record.phase_id, record.parent_id, record.type, record.name, JSON.stringify(record.payload), record.tokens ?? null, record.started_at || ts, record.ended_at ?? null);
|
|
171
|
+
this.fanOut((otel) => otel.recordEvent(record, eventId, ts)); // otel projection — see fanOut
|
|
131
172
|
return eventId;
|
|
132
173
|
}
|
|
133
174
|
// ── sessions ────────────────────────────────────────────────────────────
|
|
134
175
|
sessionStart(adwId, engineer, adwName) {
|
|
176
|
+
const startedAt = nowIso();
|
|
135
177
|
this.db
|
|
136
178
|
.query(`INSERT INTO sessions (adw_id, status, engineer, started_at) VALUES (?,?,?,?)
|
|
137
179
|
ON CONFLICT(adw_id) DO UPDATE SET status='running'`)
|
|
138
|
-
.run(adwId, "running", engineer,
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
180
|
+
.run(adwId, "running", engineer, startedAt);
|
|
181
|
+
if (adwName) {
|
|
182
|
+
// A joined session chains ADWs — record each distinct one, in run order.
|
|
183
|
+
const row = this.db.query("SELECT adw_name FROM sessions WHERE adw_id=?").get(adwId);
|
|
184
|
+
const names = row?.adw_name ? row.adw_name.split(" + ") : [];
|
|
185
|
+
if (!names.includes(adwName)) {
|
|
186
|
+
names.push(adwName);
|
|
187
|
+
this.db.query("UPDATE sessions SET adw_name=? WHERE adw_id=?").run(names.join(" + "), adwId);
|
|
188
|
+
}
|
|
147
189
|
}
|
|
190
|
+
// otel projection: the run's clock only. `engineer` is a person's name —
|
|
191
|
+
// outside the allowlist, and not a measure of anything.
|
|
192
|
+
this.fanOut((otel) => otel.recordSessionStart(startedAt));
|
|
148
193
|
}
|
|
149
194
|
sessionRequest(adwId, request) {
|
|
150
195
|
this.db.query("UPDATE sessions SET request=? WHERE adw_id=?").run(request.slice(0, 500), adwId);
|
|
@@ -154,6 +199,7 @@ export class Tracer {
|
|
|
154
199
|
.query("UPDATE sessions SET status=?, ended_at=? WHERE adw_id=?")
|
|
155
200
|
.run(ok ? "success" : "fail", nowIso(), adwId);
|
|
156
201
|
this.processesEndAll(adwId); // nothing of this run is alive any more
|
|
202
|
+
this.fanOut((otel) => otel.recordSessionFinish(ok)); // otel projection: emits the root run span, once
|
|
157
203
|
}
|
|
158
204
|
sessionAddUsage(adwId, tokens, cost) {
|
|
159
205
|
this.db
|
|
@@ -208,6 +254,9 @@ export class Tracer {
|
|
|
208
254
|
ON CONFLICT(phase_id) DO UPDATE SET status=excluded.status,
|
|
209
255
|
attempt=excluded.attempt, error=excluded.error, ended_at=excluded.ended_at`)
|
|
210
256
|
.run(phase.phase_id, phase.adw_id, phase.seq, p.name, p.kind, p.owner, p.description, phase.status, phase.attempt, p.retries, phase.error ?? null, phase.started_at ?? null, phase.ended_at ?? null);
|
|
257
|
+
// otel projection: a no-op on the start-of-phase upsert (no ended_at yet) —
|
|
258
|
+
// phase spans are emitted at phase END only. `phase.error` never crosses.
|
|
259
|
+
this.fanOut((otel) => otel.recordPhase(phase));
|
|
211
260
|
}
|
|
212
261
|
// ── envelopes / gates / agent sessions ──────────────────────────────────
|
|
213
262
|
envelopeRow(phase, agent, outputType, payloadJson, valid, attempt) {
|
|
@@ -222,6 +271,10 @@ export class Tracer {
|
|
|
222
271
|
.query(`INSERT INTO gate_results (adw_id, phase_id, attempt, gate, passed,
|
|
223
272
|
violations_json, checks_json, created_at) VALUES (?,?,?,?,?,?,?,?)`)
|
|
224
273
|
.run(phase.adw_id, phase.phase_id, attempt, gate, report.passed ? 1 : 0, JSON.stringify(report.violations), JSON.stringify(report.checks), nowIso());
|
|
274
|
+
// otel projection: gate name + verdict + violation COUNT as a span event on
|
|
275
|
+
// the phase span. The violation and check TEXT stays here in SQLite — it
|
|
276
|
+
// quotes the agent's claim and the repo's files.
|
|
277
|
+
this.fanOut((otel) => otel.recordGate(phase, gate, report, attempt));
|
|
225
278
|
}
|
|
226
279
|
/**
|
|
227
280
|
* The agent's config row is the source of truth for its label and color.
|
|
@@ -242,5 +295,10 @@ export class Tracer {
|
|
|
242
295
|
context_window=excluded.context_window,
|
|
243
296
|
last_used_at=excluded.last_used_at`)
|
|
244
297
|
.run(adwId, agent.name, agent.coding_agent, agent.model, agent.color, sessionId, contextTokens, contextWindow, ts, ts);
|
|
298
|
+
// otel projection: the TYPED source of an agent's model + backend for its
|
|
299
|
+
// span (agents.ts writes this row before the agent_end event, which is what
|
|
300
|
+
// lets otel.ts avoid reading the agent_start payload at all). `sessionId`
|
|
301
|
+
// is not exported — it is a coding-agent handle, not a measure.
|
|
302
|
+
this.fanOut((otel) => otel.recordAgentSession(agent));
|
|
245
303
|
}
|
|
246
304
|
}
|
package/dist/core/watch.d.ts
CHANGED
|
@@ -6,6 +6,17 @@ export interface ChainRunResult {
|
|
|
6
6
|
adwId: string;
|
|
7
7
|
/** Shown to the engineer via a `blocked` comment on a failed/no-op run. */
|
|
8
8
|
detail: string;
|
|
9
|
+
/**
|
|
10
|
+
* A short, already-sanitized/truncated digest of the reviewer's verdict
|
|
11
|
+
* (approved/blocking/findings), read back best-effort from the sessions DB
|
|
12
|
+
* — see `cli/commands/watch.ts`'s `runChain`. `undefined` when the chain
|
|
13
|
+
* that ran has no reviewer step, or the DB read/parse failed; `runIssue`
|
|
14
|
+
* below falls back to `reviewRequired` to tell those two apart in the PR
|
|
15
|
+
* body and `pr_opened` notification.
|
|
16
|
+
*/
|
|
17
|
+
reviewSummary?: string;
|
|
18
|
+
/** Whether the chain that ran declares a "reviewer" in its `requiredAgents` — distinguishes "reviewer approved" from "nothing reviewed this change" when `reviewSummary` is absent. */
|
|
19
|
+
reviewRequired?: boolean;
|
|
9
20
|
}
|
|
10
21
|
/** One issue the refine lane created — enough for `finishSpec`'s summary comment and the marker's idempotency record. */
|
|
11
22
|
export interface RefinedIssueRef {
|
package/dist/core/watch.js
CHANGED
|
@@ -284,6 +284,15 @@ async function runIssue(deps, issue) {
|
|
|
284
284
|
return;
|
|
285
285
|
}
|
|
286
286
|
wtGit.push("origin", branch);
|
|
287
|
+
// The human merging this PR sees whatever the reviewer found — or, if
|
|
288
|
+
// nothing reviewed this change at all, is told that plainly rather than
|
|
289
|
+
// left to assume a silent approval. `reviewSummary` is already
|
|
290
|
+
// sanitized/truncated by the caller (see runChain's own doc comment).
|
|
291
|
+
const reviewLine = result.reviewSummary
|
|
292
|
+
? result.reviewSummary
|
|
293
|
+
: result.reviewRequired
|
|
294
|
+
? "Reviewer ran, but no verdict could be read back from the session data."
|
|
295
|
+
: `Nothing reviewed this change — chain \`${deps.chain}\` has no reviewer step.`;
|
|
287
296
|
// No cross-linking magic keyword here on purpose (a code host paired
|
|
288
297
|
// with a different tracker has no "Closes #n" convention to hook into
|
|
289
298
|
// — see provider.ts) — the issue id in the title/body is plain text
|
|
@@ -292,7 +301,7 @@ async function runIssue(deps, issue) {
|
|
|
292
301
|
const pr = await deps.codeHost.openPr({
|
|
293
302
|
branch,
|
|
294
303
|
title: `${issue.title} (${issue.id})`,
|
|
295
|
-
body: `Automated by \`spf watch\` — chain \`${deps.chain}\`, adw_id \`${adwId}\`, issue ${issue.id}
|
|
304
|
+
body: `Automated by \`spf watch\` — chain \`${deps.chain}\`, adw_id \`${adwId}\`, issue ${issue.id}.\n\n${reviewLine}`,
|
|
296
305
|
base: deps.baseBranch,
|
|
297
306
|
});
|
|
298
307
|
await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, pr: pr.number, attempt: 0 });
|
|
@@ -302,7 +311,13 @@ async function runIssue(deps, issue) {
|
|
|
302
311
|
kind: "pr_opened",
|
|
303
312
|
level: "info",
|
|
304
313
|
title: `PR #${pr.number} opened`,
|
|
305
|
-
|
|
314
|
+
detail: reviewLine,
|
|
315
|
+
fields: [
|
|
316
|
+
["issue", issue.id],
|
|
317
|
+
["title", issue.title],
|
|
318
|
+
["chain", deps.chain],
|
|
319
|
+
["review", result.reviewSummary ? "reviewed" : result.reviewRequired ? "reviewer ran, no verdict" : "not reviewed"],
|
|
320
|
+
],
|
|
306
321
|
url: pr.url || undefined,
|
|
307
322
|
});
|
|
308
323
|
}
|
package/dist/test/chains.test.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { test } from "node:test";
|
|
13
13
|
import assert from "node:assert/strict";
|
|
14
|
-
import { CHAINS, findChain, resolveRequiredAgents } from "../chains/index.js";
|
|
14
|
+
import { CHAINS, findChain, resolveRequiredAgents, resolveRequiredSuites } from "../chains/index.js";
|
|
15
15
|
// name -> [phases, requiredAgents (with no options), requiredSuites]
|
|
16
16
|
const EXPECTED = {
|
|
17
17
|
prompt: { phases: "engineer(request) -> <agent>", agents: ["builder"], suites: [] },
|
|
@@ -44,7 +44,7 @@ const EXPECTED = {
|
|
|
44
44
|
refine: { phases: "engineer(request) -> refiner -> code(publish)", agents: ["refiner"], suites: [] },
|
|
45
45
|
"simple-sdlc": {
|
|
46
46
|
phases: "engineer(request) -> planner -> git(commit_plan) -> builder -> code(test) [-> builder(fix) -> code(test) ...] " +
|
|
47
|
-
"-> reviewer [-> builder(revise) -> reviewer ...] -> code(retest, if revised) -> git(commit_build) " +
|
|
47
|
+
"-> reviewer [-> builder(revise) -> reviewer ...] -> code(retest, if revised) -> engineer(signoff) -> git(commit_build) " +
|
|
48
48
|
"-> code(changes) -> documenter -> git(commit_docs)",
|
|
49
49
|
agents: ["planner", "builder", "reviewer", "documenter"],
|
|
50
50
|
suites: ["test"],
|
|
@@ -59,7 +59,7 @@ for (const chain of CHAINS) {
|
|
|
59
59
|
test(`${chain.name}: derived phases/requiredAgents/requiredSuites match what was hand-verified against \`spf list\``, () => {
|
|
60
60
|
assert.equal(chain.phases, expected.phases);
|
|
61
61
|
assert.deepEqual(resolveRequiredAgents(chain, {}), expected.agents);
|
|
62
|
-
assert.deepEqual(chain
|
|
62
|
+
assert.deepEqual(resolveRequiredSuites(chain, {}), expected.suites);
|
|
63
63
|
});
|
|
64
64
|
}
|
|
65
65
|
test("prompt: requiredAgents depends on --agent, not a fixed list — the one dynamic case", () => {
|
|
@@ -67,6 +67,11 @@ test("prompt: requiredAgents depends on --agent, not a fixed list — the one dy
|
|
|
67
67
|
assert.deepEqual(resolveRequiredAgents(chain, {}), ["builder"], "no --agent -> falls back to builder");
|
|
68
68
|
assert.deepEqual(resolveRequiredAgents(chain, { agent: "planner" }), ["planner"], "--agent overrides the default");
|
|
69
69
|
});
|
|
70
|
+
test("plan-build-test: requiredSuites depends on --suite, the same way prompt's agent does", () => {
|
|
71
|
+
const chain = findChain("plan-build-test");
|
|
72
|
+
assert.deepEqual(resolveRequiredSuites(chain, {}), ["test"], "no --suite -> falls back to the compiled-in default");
|
|
73
|
+
assert.deepEqual(resolveRequiredSuites(chain, { suite: "custom" }), ["custom"], "--suite overrides the default");
|
|
74
|
+
});
|
|
70
75
|
test("every chain but simple-sdlc is a steps list; simple-sdlc alone uses the imperative run() escape hatch", () => {
|
|
71
76
|
for (const chain of CHAINS) {
|
|
72
77
|
if (chain.name === "simple-sdlc") {
|
|
@@ -14,8 +14,8 @@ import { tmpdir } from "node:os";
|
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import * as v from "valibot";
|
|
16
16
|
import { toJsonSchema } from "@valibot/to-json-schema";
|
|
17
|
-
import { AgentConfigSchema, BuildOutput, ChangesOutput, DocumentOutput, GenericOutput, NotificationsConfigSchema, PhaseParamsSchema, PlanOutput, ReviewOutput, ScoutOutput, VerifyOutput, makePhaseParams, } from "../core/data_types.js";
|
|
18
|
-
import { loadConfig } from "../core/agents.js";
|
|
17
|
+
import { AgentConfigSchema, BuildOutput, ChangesOutput, DocumentOutput, EventRecordTypeSchema, GenericOutput, NotificationsConfigSchema, PhaseParamsSchema, PlanOutput, ReviewConfigSchema, ReviewOutput, ScoutOutput, VerifyOutput, makePhaseParams, } from "../core/data_types.js";
|
|
18
|
+
import { agentEnv, loadConfig } from "../core/agents.js";
|
|
19
19
|
test("writes: three-state semantics — absent, null, and [] all mean something different", () => {
|
|
20
20
|
const base = { name: "builder", prompt_engineering: { system: "s.md", user: "u.md" } };
|
|
21
21
|
const unrestricted = v.parse(AgentConfigSchema, base);
|
|
@@ -80,3 +80,141 @@ test("notifications survives loadConfig's merge — key-by-key like observabilit
|
|
|
80
80
|
rmSync(dir, { recursive: true, force: true });
|
|
81
81
|
}
|
|
82
82
|
});
|
|
83
|
+
test("ReviewConfigSchema: defaults to require_human_signoff=false, signoff_timeout_seconds=300", () => {
|
|
84
|
+
const parsed = v.parse(ReviewConfigSchema, {});
|
|
85
|
+
assert.equal(parsed.require_human_signoff, false, "this release fails OPEN by default — see the schema's own doc comment");
|
|
86
|
+
assert.equal(parsed.signoff_timeout_seconds, 300);
|
|
87
|
+
});
|
|
88
|
+
// The silent-drop trap: mergeRawConfig (core/agents.ts) is a FIXED-SHAPE
|
|
89
|
+
// object literal, so a `review:` key in a real config file that isn't named
|
|
90
|
+
// on both sides of that literal is dropped before SFConfigSchema ever sees
|
|
91
|
+
// it — parsing would then succeed anyway, quietly, on the schema default.
|
|
92
|
+
// This is adversarial history on this branch, not a hypothetical: it is
|
|
93
|
+
// exactly the bug `observability`/`notifications` already guard against, and
|
|
94
|
+
// `review` gets the same guard the day it is added.
|
|
95
|
+
test("review survives loadConfig's merge — the silent-drop trap mergeRawConfig's fixed-shape literal sets, for a single config file", () => {
|
|
96
|
+
const dir = mkdtempSync(join(tmpdir(), "spf-review-merge-test-"));
|
|
97
|
+
try {
|
|
98
|
+
const configPath = join(dir, "spf.config.yaml");
|
|
99
|
+
writeFileSync(configPath, "review:\n require_human_signoff: true\n signoff_timeout_seconds: 45\n");
|
|
100
|
+
const cfg = loadConfig([configPath]);
|
|
101
|
+
assert.equal(cfg.review.require_human_signoff, true, "a review: value from a real config file must reach SFConfig, not be dropped by mergeRawConfig's object literal");
|
|
102
|
+
assert.equal(cfg.review.signoff_timeout_seconds, 45);
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
rmSync(dir, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
test("review merges key-by-key across two layered config files, like observability/notifications", () => {
|
|
109
|
+
const dir = mkdtempSync(join(tmpdir(), "spf-review-merge-layered-test-"));
|
|
110
|
+
try {
|
|
111
|
+
const base = join(dir, "base.yaml");
|
|
112
|
+
const override = join(dir, "override.yaml");
|
|
113
|
+
writeFileSync(base, "review:\n require_human_signoff: false\n signoff_timeout_seconds: 120\n");
|
|
114
|
+
writeFileSync(override, "review:\n require_human_signoff: true\n");
|
|
115
|
+
const cfg = loadConfig([base, override]);
|
|
116
|
+
assert.equal(cfg.review.require_human_signoff, true, "override wins for the key it names");
|
|
117
|
+
assert.equal(cfg.review.signoff_timeout_seconds, 120, "unset in the override -> the base's value survives, key-by-key, not a whole-block replace");
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
rmSync(dir, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
// observability.otel is the one nested OBJECT under a top-level key that
|
|
124
|
+
// mergeRawConfig spreads field-by-field, so its merge semantics differ from its
|
|
125
|
+
// siblings' and are worth pinning: `otel:` is replaced as a WHOLE OBJECT by an
|
|
126
|
+
// override that names it (you never want a half-merged endpoint/headers pair —
|
|
127
|
+
// that is how an auth token gets POSTed to the wrong collector), while
|
|
128
|
+
// `observability`'s other keys still merge key-by-key around it. Also the
|
|
129
|
+
// activation contract: absent by default, so no repo starts exporting because
|
|
130
|
+
// it upgraded.
|
|
131
|
+
test("observability.otel survives loadConfig's merge — absent by default, whole-object replace on override", () => {
|
|
132
|
+
const dir = mkdtempSync(join(tmpdir(), "spf-otel-merge-test-"));
|
|
133
|
+
try {
|
|
134
|
+
const bare = join(dir, "bare.yaml");
|
|
135
|
+
writeFileSync(bare, "observability:\n poll_ms: 250\n");
|
|
136
|
+
assert.equal(loadConfig([bare]).observability.otel, undefined, "no otel: block -> export stays off, the default for every repo");
|
|
137
|
+
const base = join(dir, "base.yaml");
|
|
138
|
+
const override = join(dir, "override.yaml");
|
|
139
|
+
writeFileSync(base, "observability:\n poll_ms: 250\n otel:\n endpoint: http://base-collector:4318/v1/traces\n headers: {authorization: base-token}\n service_name: base\n");
|
|
140
|
+
writeFileSync(override, "observability:\n otel:\n endpoint: http://override-collector:4318/v1/traces\n");
|
|
141
|
+
const cfg = loadConfig([base, override]);
|
|
142
|
+
assert.equal(cfg.observability.poll_ms, 250, "observability's other keys still merge key-by-key around otel");
|
|
143
|
+
assert.equal(cfg.observability.otel?.endpoint, "http://override-collector:4318/v1/traces");
|
|
144
|
+
assert.equal(cfg.observability.otel?.headers, undefined, "whole-object replace: the base's auth header does NOT follow the override's endpoint");
|
|
145
|
+
assert.equal(cfg.observability.otel?.service_name, "spf", "and the base's service_name doesn't either — the schema default applies");
|
|
146
|
+
// A single config file must reach SFConfig at all — the silent-drop trap
|
|
147
|
+
// mergeRawConfig's fixed-shape object literal sets for any new key.
|
|
148
|
+
const single = join(dir, "single.yaml");
|
|
149
|
+
writeFileSync(single, "observability:\n otel:\n endpoint: https://collector.example.com/v1/traces\n");
|
|
150
|
+
assert.equal(loadConfig([single]).observability.otel?.endpoint, "https://collector.example.com/v1/traces");
|
|
151
|
+
// A typo fails at config load, not as a silent per-run export failure.
|
|
152
|
+
const bad = join(dir, "bad.yaml");
|
|
153
|
+
writeFileSync(bad, "observability:\n otel:\n endpoint: not-a-url\n");
|
|
154
|
+
assert.throws(() => loadConfig([bad]), /invalid config/, "endpoint is URL-validated");
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
rmSync(dir, { recursive: true, force: true });
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
test("EventRecordTypeSchema accepts every observed event type and rejects an unknown one", () => {
|
|
161
|
+
for (const type of ["phase_start", "agent_start", "tool_call", "handoff", "gate_pass", "gate_fail", "log", "agent_end", "phase_end", "error"]) {
|
|
162
|
+
assert.equal(v.parse(EventRecordTypeSchema, type), type);
|
|
163
|
+
}
|
|
164
|
+
assert.throws(() => v.parse(EventRecordTypeSchema, "bogus_type"), "an event type outside the observed set must be rejected, not silently stored");
|
|
165
|
+
});
|
|
166
|
+
test("AgentConfigSchema: env_allowlist is optional and defaults to undefined (byte-identical to before this field existed)", () => {
|
|
167
|
+
const base = { name: "builder", prompt_engineering: { system: "s.md", user: "u.md" } };
|
|
168
|
+
const unset = v.parse(AgentConfigSchema, base);
|
|
169
|
+
assert.equal(unset.env_allowlist, undefined);
|
|
170
|
+
const allowlisted = v.parse(AgentConfigSchema, { ...base, env_allowlist: ["MY_API_KEY"] });
|
|
171
|
+
assert.deepEqual(allowlisted.env_allowlist, ["MY_API_KEY"]);
|
|
172
|
+
const nulled = v.parse(AgentConfigSchema, { ...base, env_allowlist: null });
|
|
173
|
+
assert.equal(nulled.env_allowlist, null, "null must parse (the 'unrestricted' spelling config.md teaches for writes)");
|
|
174
|
+
});
|
|
175
|
+
// This is the load-bearing half of the field: the schema merely declares
|
|
176
|
+
// the shape, agentEnv() is what actually filters the operator's own
|
|
177
|
+
// environment down to the allowlist. A test that only exercised the schema
|
|
178
|
+
// (as the previous version of this test did) would never notice agentEnv
|
|
179
|
+
// silently failing to filter anything.
|
|
180
|
+
test("agentEnv: filters the operator environment down to the allowlist plus the baseline keys", () => {
|
|
181
|
+
const base = { name: "builder", prompt_engineering: { system: "s.md", user: "u.md" } };
|
|
182
|
+
const savedApiKey = process.env["MY_API_KEY"];
|
|
183
|
+
const savedSecret = process.env["SECRET_TOKEN"];
|
|
184
|
+
try {
|
|
185
|
+
process.env["MY_API_KEY"] = "abc123";
|
|
186
|
+
process.env["SECRET_TOKEN"] = "should-never-appear";
|
|
187
|
+
// unset -> undefined: both backends fall back to `request.env ?? operatorEnv()`,
|
|
188
|
+
// so this is byte-identical to the unfiltered behavior that predates the field.
|
|
189
|
+
const unset = v.parse(AgentConfigSchema, base);
|
|
190
|
+
assert.equal(agentEnv(unset), undefined);
|
|
191
|
+
// [] -> baseline-only: every ENV_BASELINE_KEYS entry actually present in
|
|
192
|
+
// process.env, and nothing else — SECRET_TOKEN must not leak through.
|
|
193
|
+
const empty = v.parse(AgentConfigSchema, { ...base, env_allowlist: [] });
|
|
194
|
+
const baselineOnly = agentEnv(empty);
|
|
195
|
+
const baselineKeys = ["PATH", "HOME", "USER", "LANG", "TERM", "TMPDIR"];
|
|
196
|
+
for (const key of Object.keys(baselineOnly)) {
|
|
197
|
+
assert.ok(baselineKeys.includes(key), `${key} is not a baseline key — [] must yield baseline-only`);
|
|
198
|
+
}
|
|
199
|
+
assert.equal(baselineOnly["SECRET_TOKEN"], undefined);
|
|
200
|
+
assert.equal(baselineOnly["MY_API_KEY"], undefined);
|
|
201
|
+
// ["MY_API_KEY"] -> baseline + MY_API_KEY, with SECRET_TOKEN provably absent.
|
|
202
|
+
const allowlisted = v.parse(AgentConfigSchema, { ...base, env_allowlist: ["MY_API_KEY"] });
|
|
203
|
+
const filtered = agentEnv(allowlisted);
|
|
204
|
+
assert.equal(filtered["MY_API_KEY"], "abc123");
|
|
205
|
+
assert.equal(filtered["SECRET_TOKEN"], undefined, "an unrelated secret must not survive the filter");
|
|
206
|
+
for (const key of Object.keys(filtered)) {
|
|
207
|
+
assert.ok(baselineKeys.includes(key) || key === "MY_API_KEY", `${key} leaked through the allowlist unexpectedly`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
if (savedApiKey === undefined)
|
|
212
|
+
delete process.env["MY_API_KEY"];
|
|
213
|
+
else
|
|
214
|
+
process.env["MY_API_KEY"] = savedApiKey;
|
|
215
|
+
if (savedSecret === undefined)
|
|
216
|
+
delete process.env["SECRET_TOKEN"];
|
|
217
|
+
else
|
|
218
|
+
process.env["SECRET_TOKEN"] = savedSecret;
|
|
219
|
+
}
|
|
220
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "./hermetic_git.ts";
|