@gr8ful/spf 0.5.1 → 0.6.0

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.
Files changed (95) hide show
  1. package/README.md +61 -17
  2. package/assets/defaults/spf.config.yaml +68 -0
  3. package/assets/prompts/refiner/system.md +32 -6
  4. package/assets/prompts/refiner/user.md +37 -5
  5. package/assets/skill/SKILL.md +1 -0
  6. package/assets/skill/references/config.md +153 -4
  7. package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
  8. package/assets/templates/ts.spf.config.yaml +6 -0
  9. package/dist/chains/index.d.ts +11 -0
  10. package/dist/chains/index.js +38 -3
  11. package/dist/chains/repo_chains.js +1 -0
  12. package/dist/chains/simple_sdlc.js +1 -1
  13. package/dist/chains/steps.d.ts +27 -9
  14. package/dist/chains/steps.js +82 -13
  15. package/dist/cli/commands/doctor.js +108 -1
  16. package/dist/cli/commands/estimate.d.ts +82 -0
  17. package/dist/cli/commands/estimate.js +317 -0
  18. package/dist/cli/commands/fanout.d.ts +40 -0
  19. package/dist/cli/commands/fanout.js +401 -0
  20. package/dist/cli/commands/init.js +19 -0
  21. package/dist/cli/commands/trace.d.ts +18 -0
  22. package/dist/cli/commands/trace.js +22 -3
  23. package/dist/cli/commands/watch.js +40 -26
  24. package/dist/cli/index.js +12 -0
  25. package/dist/core/agents.d.ts +56 -0
  26. package/dist/core/agents.js +152 -1
  27. package/dist/core/data_types.d.ts +232 -0
  28. package/dist/core/data_types.js +135 -0
  29. package/dist/core/fanout.d.ts +229 -0
  30. package/dist/core/fanout.js +313 -0
  31. package/dist/core/gates.d.ts +8 -0
  32. package/dist/core/gates.js +24 -1
  33. package/dist/core/git_helper.d.ts +98 -0
  34. package/dist/core/git_helper.js +127 -0
  35. package/dist/core/issues/github_provider.d.ts +7 -1
  36. package/dist/core/issues/github_provider.js +33 -2
  37. package/dist/core/issues/jira_provider.d.ts +5 -1
  38. package/dist/core/issues/jira_provider.js +26 -3
  39. package/dist/core/issues/provider.d.ts +53 -2
  40. package/dist/core/notify/channel.d.ts +1 -1
  41. package/dist/core/notify/notifier.d.ts +16 -0
  42. package/dist/core/notify/notifier.js +36 -0
  43. package/dist/core/otel.d.ts +64 -9
  44. package/dist/core/otel.js +78 -14
  45. package/dist/core/runner.d.ts +8 -0
  46. package/dist/core/runner.js +7 -0
  47. package/dist/core/session.d.ts +25 -0
  48. package/dist/core/session.js +97 -28
  49. package/dist/core/tiering.d.ts +145 -0
  50. package/dist/core/tiering.js +235 -0
  51. package/dist/core/tracer.d.ts +10 -0
  52. package/dist/core/tracer.js +12 -0
  53. package/dist/core/watch.d.ts +89 -9
  54. package/dist/core/watch.js +193 -19
  55. package/dist/ui/server/db.d.ts +39 -0
  56. package/dist/ui/server/db.js +61 -0
  57. package/package.json +2 -1
  58. package/dist/test/agent_cc.test.d.ts +0 -1
  59. package/dist/test/agent_cc.test.js +0 -95
  60. package/dist/test/agent_flue.test.d.ts +0 -1
  61. package/dist/test/agent_flue.test.js +0 -83
  62. package/dist/test/chains.test.d.ts +0 -12
  63. package/dist/test/chains.test.js +0 -92
  64. package/dist/test/data_types.test.d.ts +0 -10
  65. package/dist/test/data_types.test.js +0 -220
  66. package/dist/test/env_file.test.d.ts +0 -1
  67. package/dist/test/env_file.test.js +0 -74
  68. package/dist/test/fake_asker.d.ts +0 -23
  69. package/dist/test/fake_asker.js +0 -30
  70. package/dist/test/git_helper.test.d.ts +0 -1
  71. package/dist/test/git_helper.test.js +0 -59
  72. package/dist/test/hermetic_git.d.ts +0 -1
  73. package/dist/test/hermetic_git.js +0 -22
  74. package/dist/test/init_command.test.d.ts +0 -14
  75. package/dist/test/init_command.test.js +0 -136
  76. package/dist/test/interview.test.d.ts +0 -15
  77. package/dist/test/interview.test.js +0 -425
  78. package/dist/test/notify.test.d.ts +0 -1
  79. package/dist/test/notify.test.js +0 -174
  80. package/dist/test/ollama_provider.test.d.ts +0 -1
  81. package/dist/test/ollama_provider.test.js +0 -103
  82. package/dist/test/otel.test.d.ts +0 -26
  83. package/dist/test/otel.test.js +0 -512
  84. package/dist/test/paths.test.d.ts +0 -1
  85. package/dist/test/paths.test.js +0 -68
  86. package/dist/test/refine.test.d.ts +0 -1
  87. package/dist/test/refine.test.js +0 -189
  88. package/dist/test/repo_chains.test.d.ts +0 -21
  89. package/dist/test/repo_chains.test.js +0 -416
  90. package/dist/test/signoff.test.d.ts +0 -1
  91. package/dist/test/signoff.test.js +0 -329
  92. package/dist/test/ui_server.test.d.ts +0 -7
  93. package/dist/test/ui_server.test.js +0 -120
  94. package/dist/test/watch.test.d.ts +0 -1
  95. package/dist/test/watch.test.js +0 -687
@@ -1,103 +0,0 @@
1
- /**
2
- * Hermetic — never touches a live Ollama server. These exercise Flue's own
3
- * in-process provider registry (via `@flue/runtime/internal`) plus this
4
- * module's accumulation/idempotence logic, using synthetic model ids.
5
- */
6
- import { test, beforeEach, afterEach } from "node:test";
7
- import assert from "node:assert/strict";
8
- import { hasProvider, resetModelsForTests, resolveModel as flueResolveModel } from "@flue/runtime/internal";
9
- import { providerForTest, registerOllamaModel, resetOllamaRegistrationForTest, } from "../core/ollama_provider.js";
10
- // A minimal stand-in for pi-ai's `AuthContext` — our resolver ignores it
11
- // entirely (it has no ambient env/file lookups to do), but the `resolve()`
12
- // call site still needs something shaped right to pass.
13
- const fakeAuthContext = { env: async () => undefined, fileExists: async () => false };
14
- const ORIGINAL_BASE_URL = process.env.OLLAMA_BASE_URL;
15
- beforeEach(() => {
16
- resetOllamaRegistrationForTest();
17
- resetModelsForTests();
18
- delete process.env.OLLAMA_BASE_URL;
19
- });
20
- afterEach(() => {
21
- if (ORIGINAL_BASE_URL === undefined)
22
- delete process.env.OLLAMA_BASE_URL;
23
- else
24
- process.env.OLLAMA_BASE_URL = ORIGINAL_BASE_URL;
25
- });
26
- test("registerOllamaModel: union accumulation — registering a second id doesn't orphan the first", async () => {
27
- await registerOllamaModel("model-a");
28
- await registerOllamaModel("model-b");
29
- assert.equal(hasProvider("ollama"), true);
30
- // `setProvider()` REPLACES the whole provider (models list included) on
31
- // every call. If this module regressed from "re-register the full union"
32
- // to "register just the newest id", model-a would throw "Unknown model
33
- // ID … for provider \"ollama\"" here — exactly the failure the spike hit
34
- // and the `registeredIds` Set exists to prevent.
35
- const a = flueResolveModel("ollama/model-a");
36
- const b = flueResolveModel("ollama/model-b");
37
- assert.equal(a.id, "model-a");
38
- assert.equal(b.id, "model-b");
39
- const idsOnProvider = providerForTest()
40
- .getModels()
41
- .map((m) => m.id)
42
- .sort();
43
- assert.deepEqual(idsOnProvider, ["model-a", "model-b"], "the provider's own model list carries both ids, not just the latest");
44
- });
45
- test("registerOllamaModel: flue's registry resolves the SAME model object our provider registered — pins the pi-ai single-copy dedupe invariant", async () => {
46
- // package.json pins @earendil-works/pi-ai to the exact version
47
- // @flue/runtime depends on so npm dedupes to one physical copy — see that
48
- // pin's own comment for why. If a future dependency bump ever re-splits
49
- // that into two copies, `setProvider()`'s `Model`/`Provider` values would
50
- // stop being instances flue's OWN copy of pi-ai recognizes, and
51
- // `resolveModel()` would resolve to a DIFFERENT object than the one on
52
- // our provider — same string id, wrong identity. Reference equality here
53
- // is the one assertion that would actually catch that regression; a
54
- // structural `deepEqual` would keep passing right through it.
55
- await registerOllamaModel("model-a");
56
- const resolved = flueResolveModel("ollama/model-a");
57
- const onProvider = providerForTest().getModels().find((m) => m.id === "model-a");
58
- assert.equal(resolved, onProvider, "flue's registry and our provider must hand back the identical object, not merely an equal one");
59
- });
60
- test("registerOllamaModel: idempotent for an already-registered id", async () => {
61
- await registerOllamaModel("model-a");
62
- const providerAfterFirst = providerForTest();
63
- await registerOllamaModel("model-a");
64
- assert.equal(providerForTest(), providerAfterFirst, "a repeat registration never calls setProvider again — same object identity");
65
- });
66
- test("registerOllamaModel: concurrent calls for the same new id both resolve to one registration, not a second no-op", async () => {
67
- // Regression guard for committing to `registeredIds` before `setProvider`
68
- // finishes: if the second call saw the id "already registered" before
69
- // the first call's registration actually completed, it would resolve
70
- // immediately with nothing registered yet, and a caller awaiting it could
71
- // start dispatching before `setProvider()` ran.
72
- const [a, b] = await Promise.all([registerOllamaModel("model-c"), registerOllamaModel("model-c")]);
73
- assert.equal(a, undefined);
74
- assert.equal(b, undefined);
75
- const model = flueResolveModel("ollama/model-c");
76
- assert.equal(model.id, "model-c");
77
- });
78
- test("registerOllamaModel: defaults to http://localhost:11434/v1 when OLLAMA_BASE_URL is unset", async () => {
79
- await registerOllamaModel("model-a");
80
- const model = flueResolveModel("ollama/model-a");
81
- assert.equal(model.baseUrl, "http://localhost:11434/v1");
82
- assert.equal(providerForTest().baseUrl, "http://localhost:11434/v1");
83
- });
84
- test("registerOllamaModel: OLLAMA_BASE_URL override is respected", async () => {
85
- process.env.OLLAMA_BASE_URL = "http://example-ollama-host:9999/v1";
86
- await registerOllamaModel("model-a");
87
- const model = flueResolveModel("ollama/model-a");
88
- assert.equal(model.baseUrl, "http://example-ollama-host:9999/v1");
89
- assert.equal(providerForTest().baseUrl, "http://example-ollama-host:9999/v1");
90
- });
91
- test("registerOllamaModel: registered models carry contextWindow 0 (disables threshold compaction)", async () => {
92
- await registerOllamaModel("model-a");
93
- const model = flueResolveModel("ollama/model-a");
94
- assert.equal(model.contextWindow, 0);
95
- });
96
- test("registerOllamaModel: auth.apiKey resolves a truthy dummy key, never the upstream 'No API key' failure", async () => {
97
- await registerOllamaModel("model-a");
98
- const apiKeyAuth = providerForTest().auth.apiKey;
99
- assert.ok(apiKeyAuth, "auth.apiKey must be present — pi-ai requires at least one of apiKey/oauth even for a keyless provider");
100
- const resolved = await apiKeyAuth.resolve({ ctx: fakeAuthContext, credential: undefined });
101
- assert.ok(resolved, "resolve() must report the provider as configured, not 'unconfigured'");
102
- assert.ok(typeof resolved.auth.apiKey === "string" && resolved.auth.apiKey.length > 0, "the resolved apiKey must be a non-empty string — a falsy one is exactly what pi-ai's getClientApiKey() throws on at dispatch");
103
- });
@@ -1,26 +0,0 @@
1
- /**
2
- * The OTLP span exporter. Six things are pinned here, and every one of them is
3
- * a thing a "simplification" would quietly break:
4
- *
5
- * 1. ID derivation — deterministic, right length, lowercase hex, never zero.
6
- * 2. `traceparent` parsing — strict W3C, and GARBAGE IS SILENT (a malformed CI
7
- * variable must degrade to "own root", never throw, never half-parse).
8
- * 3. THE ALLOWLIST — a synthetic EventRecord whose payload carries a marker
9
- * string, asserted absent from the literal JSON body the exporter would
10
- * POST. This is the exfiltration test: `EventRecord.payload` holds tool
11
- * args, result snippets, and the repo's own source. If someone adds a
12
- * `stringValue` read from a payload, this test is what says no.
13
- * 4. The queue bound — 3000 spans pushed synchronously stay <= the bound, with
14
- * the overflow counted (drop-OLDEST, so the newest spans survive).
15
- * 5. The wire shape — against an in-process `node:http` receiver, not a fetch
16
- * mock, so the assertions are on real bytes: resourceSpans -> scopeSpans ->
17
- * spans nesting, hex ids, AnyValue-wrapped attributes, and nanosecond
18
- * timestamps AS STRINGS (a JSON number loses precision past 2^53).
19
- * 6. Failure isolation — an endpoint that 500s or does not exist must resolve
20
- * quietly, because export is never allowed to fail a run.
21
- *
22
- * Hermetic: every exporter is constructed with an explicit `env` (so the
23
- * developer's own TRACEPARENT cannot leak in) and every network case talks to a
24
- * loopback server on an ephemeral port.
25
- */
26
- export {};
@@ -1,512 +0,0 @@
1
- /**
2
- * The OTLP span exporter. Six things are pinned here, and every one of them is
3
- * a thing a "simplification" would quietly break:
4
- *
5
- * 1. ID derivation — deterministic, right length, lowercase hex, never zero.
6
- * 2. `traceparent` parsing — strict W3C, and GARBAGE IS SILENT (a malformed CI
7
- * variable must degrade to "own root", never throw, never half-parse).
8
- * 3. THE ALLOWLIST — a synthetic EventRecord whose payload carries a marker
9
- * string, asserted absent from the literal JSON body the exporter would
10
- * POST. This is the exfiltration test: `EventRecord.payload` holds tool
11
- * args, result snippets, and the repo's own source. If someone adds a
12
- * `stringValue` read from a payload, this test is what says no.
13
- * 4. The queue bound — 3000 spans pushed synchronously stay <= the bound, with
14
- * the overflow counted (drop-OLDEST, so the newest spans survive).
15
- * 5. The wire shape — against an in-process `node:http` receiver, not a fetch
16
- * mock, so the assertions are on real bytes: resourceSpans -> scopeSpans ->
17
- * spans nesting, hex ids, AnyValue-wrapped attributes, and nanosecond
18
- * timestamps AS STRINGS (a JSON number loses precision past 2^53).
19
- * 6. Failure isolation — an endpoint that 500s or does not exist must resolve
20
- * quietly, because export is never allowed to fail a run.
21
- *
22
- * Hermetic: every exporter is constructed with an explicit `env` (so the
23
- * developer's own TRACEPARENT cannot leak in) and every network case talks to a
24
- * loopback server on an ephemeral port.
25
- */
26
- import { test } from "node:test";
27
- import assert from "node:assert/strict";
28
- import { createServer } from "node:http";
29
- import { OtelExporter, endpointLabel, inboundTraceparent, nanosFromIso, parseTraceparent, redact, resolveTracesUrl, spanIdFor, toolSpanName, traceIdFor, } from "../core/otel.js";
30
- import * as v from "valibot";
31
- import { AgentConfigSchema, GateReport, makeEventRecord, makePhaseParams } from "../core/data_types.js";
32
- const HEX32 = /^[0-9a-f]{32}$/;
33
- const HEX16 = /^[0-9a-f]{16}$/;
34
- function exporter(endpoint, log = () => { }) {
35
- return new OtelExporter({
36
- cfg: { endpoint, service_name: "spf", headers: undefined },
37
- adwId: "adw_test",
38
- chainName: "plan-build-test",
39
- log,
40
- env: {}, // hermetic: never inherit the developer's own TRACEPARENT
41
- });
42
- }
43
- function phase(overrides = {}) {
44
- return {
45
- phase_id: "adw_test_01_build",
46
- adw_id: "adw_test",
47
- seq: 1,
48
- params: makePhaseParams({ name: "build", kind: "agent", owner: "builder", description: "write the code the plan asked for" }),
49
- status: "success",
50
- attempt: 0,
51
- error: null,
52
- started_at: "2026-01-01T00:00:00.000Z",
53
- ended_at: "2026-01-01T00:00:10.500Z",
54
- ...overrides,
55
- };
56
- }
57
- /** A one-request receiver. Resolves with the parsed body of the first POST. */
58
- async function receiver() {
59
- let resolveBody;
60
- const body = new Promise((resolve) => {
61
- resolveBody = resolve;
62
- });
63
- const server = createServer((req, res) => {
64
- const chunks = [];
65
- req.on("data", (c) => chunks.push(c));
66
- req.on("end", () => {
67
- res.writeHead(200, { "content-type": "application/json" });
68
- res.end("{}");
69
- resolveBody({
70
- method: req.method,
71
- contentType: req.headers["content-type"],
72
- raw: Buffer.concat(chunks).toString("utf-8"),
73
- });
74
- });
75
- });
76
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
77
- const port = server.address().port;
78
- return {
79
- url: `http://127.0.0.1:${port}/v1/traces`,
80
- body,
81
- close: () => new Promise((resolve) => server.close(() => resolve())),
82
- };
83
- }
84
- // ── 1. ids ──────────────────────────────────────────────────────────────────
85
- test("trace/span ids: deterministic, hex, exact length — the bespoke sha256 convention", () => {
86
- const traceId = traceIdFor("adw_abc123");
87
- assert.match(traceId, HEX32, "trace-id must be 32 lowercase hex chars");
88
- assert.equal(traceId, traceIdFor("adw_abc123"), "same adw_id -> same trace-id, so a re-export lands on the same trace");
89
- assert.notEqual(traceId, traceIdFor("adw_abc124"), "a different run must not collide");
90
- const spanId = spanIdFor("adw_abc123_01_plan");
91
- assert.match(spanId, HEX16, "span-id must be 16 lowercase hex chars");
92
- assert.equal(spanId, spanIdFor("adw_abc123_01_plan"), "same key -> same span-id: a child can name its parent before the parent is emitted");
93
- assert.notEqual(spanId, spanIdFor("adw_abc123_02_build"));
94
- assert.notEqual(spanId.slice(0, 16), traceId.slice(0, 16), "span ids are NOT a prefix of the trace id");
95
- });
96
- // ── 2. traceparent ──────────────────────────────────────────────────────────
97
- test("parseTraceparent: accepts a valid W3C header and reads the sampled flag", () => {
98
- const parsed = parseTraceparent("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01");
99
- assert.deepEqual(parsed, {
100
- traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
101
- spanId: "00f067aa0ba902b7",
102
- sampled: true,
103
- });
104
- assert.equal(parseTraceparent("00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00")?.sampled, false);
105
- });
106
- test("parseTraceparent: every malformed shape returns null, silently", () => {
107
- for (const bad of [
108
- undefined,
109
- null,
110
- "",
111
- " ",
112
- "not-a-traceparent",
113
- "01-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", // unknown version: never guess
114
- "00-4bf92f3577b34da6a3ce929d0e0e473-00f067aa0ba902b7-01", // trace-id 31 chars
115
- "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b-01", // span-id 15 chars
116
- "00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01", // uppercase
117
- "00-00000000000000000000000000000000-00f067aa0ba902b7-01", // all-zero trace-id
118
- "00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01", // all-zero span-id
119
- "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7", // three fields
120
- "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-zz", // non-hex flags
121
- "<script>alert(1)</script>",
122
- ]) {
123
- assert.equal(parseTraceparent(bad), null, `must reject: ${JSON.stringify(bad)}`);
124
- }
125
- });
126
- test("inboundTraceparent: reads TRACEPARENT, falls back to OTEL_TRACEPARENT, ignores everything else", () => {
127
- const valid = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01";
128
- assert.equal(inboundTraceparent({ TRACEPARENT: valid })?.traceId, "4bf92f3577b34da6a3ce929d0e0e4736");
129
- assert.equal(inboundTraceparent({ OTEL_TRACEPARENT: valid })?.spanId, "00f067aa0ba902b7");
130
- assert.equal(inboundTraceparent({}), null);
131
- // The whole point of EXPLICIT CONFIG ONLY: an exporter endpoint in the
132
- // environment is not a switch, and it is not a parent either.
133
- assert.equal(inboundTraceparent({ OTEL_EXPORTER_OTLP_ENDPOINT: "http://evil:4318" }), null);
134
- });
135
- test("an inbound traceparent adopts its trace-id and parents the run's root span under it", async () => {
136
- const sink = await receiver();
137
- try {
138
- const exp = new OtelExporter({
139
- cfg: { endpoint: sink.url, service_name: "spf", headers: undefined },
140
- adwId: "adw_test",
141
- chainName: "plan",
142
- log: () => { },
143
- env: { TRACEPARENT: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" },
144
- });
145
- exp.recordSessionFinish(true);
146
- await exp.flush(true);
147
- const span = JSON.parse((await sink.body).raw).resourceSpans[0].scopeSpans[0].spans[0];
148
- assert.equal(span.traceId, "4bf92f3577b34da6a3ce929d0e0e4736", "the CI trace, not our own sha256(adw_id)");
149
- assert.equal(span.parentSpanId, "00f067aa0ba902b7", "the run joins the CI trace instead of orphaning a root");
150
- }
151
- finally {
152
- await sink.close();
153
- }
154
- });
155
- // ── small pure helpers ──────────────────────────────────────────────────────
156
- test("resolveTracesUrl: a bare origin gets /v1/traces; an explicit path is left alone", () => {
157
- assert.equal(resolveTracesUrl("http://localhost:4318"), "http://localhost:4318/v1/traces");
158
- assert.equal(resolveTracesUrl("http://localhost:4318/"), "http://localhost:4318/v1/traces");
159
- assert.equal(resolveTracesUrl("https://collector.example.com/v1/traces"), "https://collector.example.com/v1/traces");
160
- assert.equal(resolveTracesUrl("https://collector.example.com/otlp/v1/traces"), "https://collector.example.com/otlp/v1/traces");
161
- });
162
- test("endpointLabel: drops userinfo and query — doctor output gets pasted into issues", () => {
163
- assert.equal(endpointLabel("https://user:s3cr3t@collector.example.com/v1/traces?token=abc"), "https://collector.example.com/v1/traces");
164
- assert.equal(endpointLabel("garbage"), "(unparseable endpoint)");
165
- });
166
- test("nanosFromIso: uint64 nanoseconds as a STRING, with a fallback for junk", () => {
167
- assert.equal(nanosFromIso("2026-01-01T00:00:00.000Z"), "1767225600000000000");
168
- assert.equal(typeof nanosFromIso("2026-01-01T00:00:00.000Z"), "string", "a JSON number would lose precision past 2^53");
169
- assert.match(nanosFromIso("2026-01-01T00:00:00.500Z"), /^\d+000000$/, "millisecond input -> six trailing zeros");
170
- assert.equal(nanosFromIso("not a date", 1000), "1000000000", "unparseable falls back rather than emitting NaN");
171
- assert.equal(nanosFromIso(null, 1000), "1000000000");
172
- });
173
- test("toolSpanName: the tool's identity, never the argument-bearing label", () => {
174
- // agent_flue.ts's labelFor() builds "bash: <command>" from real arguments.
175
- assert.equal(toolSpanName("bash: cat src/core/secrets.ts"), "bash");
176
- assert.equal(toolSpanName("quality:typecheck"), "quality");
177
- assert.equal(toolSpanName("Read"), "Read");
178
- assert.equal(toolSpanName(""), "tool_call");
179
- assert.equal(toolSpanName(undefined), "tool_call");
180
- assert.equal(toolSpanName("weird name with spaces"), "weirdnamewithspaces", "character-restricted, so an event name can't smuggle a payload");
181
- });
182
- test("redact: the endpoint and every header VALUE are unprintable", () => {
183
- const line = redact("connect ECONNREFUSED https://collector.example.com/v1/traces (Bearer sk-abc123456)", [
184
- "https://collector.example.com/v1/traces",
185
- "Bearer sk-abc123456",
186
- ]);
187
- assert.ok(!line.includes("sk-abc123456"), "a header value must never reach a log");
188
- assert.ok(!line.includes("collector.example.com"), "nor the endpoint");
189
- assert.ok(line.includes("ECONNREFUSED"), "the useful part of the message survives");
190
- });
191
- // ── 3. THE ALLOWLIST ────────────────────────────────────────────────────────
192
- test("allowlist: nothing from EventRecord.payload reaches the wire", () => {
193
- const MARKER = "ZZ_TOP_SECRET_REPO_SOURCE_ZZ";
194
- const exp = exporter("http://127.0.0.1:1/v1/traces");
195
- const phaseId = "adw_test_01_build";
196
- const events = [
197
- makeEventRecord({
198
- adw_id: "adw_test",
199
- phase_id: phaseId,
200
- type: "agent_start",
201
- name: "builder",
202
- // model/coding_agent are allowlisted, but they must come from the TYPED
203
- // AgentConfig, not from here — a payload read of any string is the bug.
204
- payload: { model: MARKER, coding_agent: MARKER, session_id: MARKER, purpose: MARKER },
205
- }),
206
- makeEventRecord({
207
- adw_id: "adw_test",
208
- phase_id: phaseId,
209
- type: "tool_call",
210
- name: `bash: cat ${MARKER}`, // the human label is built FROM tool args
211
- started_at: "2026-01-01T00:00:01.000Z",
212
- ended_at: "2026-01-01T00:00:02.000Z",
213
- payload: { tool: "bash", agent: "builder", args: { command: `cat ${MARKER}` }, result_snippet: MARKER, ok: true },
214
- }),
215
- makeEventRecord({
216
- adw_id: "adw_test",
217
- phase_id: phaseId,
218
- type: "log",
219
- name: "paths_touched",
220
- payload: { agent: "builder", paths: [`src/${MARKER}.ts`] },
221
- }),
222
- makeEventRecord({
223
- adw_id: "adw_test",
224
- phase_id: phaseId,
225
- type: "handoff",
226
- name: "builder",
227
- payload: { summary: MARKER, artifacts: [MARKER] },
228
- }),
229
- makeEventRecord({
230
- adw_id: "adw_test",
231
- phase_id: phaseId,
232
- type: "error",
233
- name: "permission_breach",
234
- payload: { error: MARKER, writes: [MARKER] },
235
- }),
236
- makeEventRecord({
237
- adw_id: "adw_test",
238
- phase_id: phaseId,
239
- type: "agent_end",
240
- name: "builder",
241
- tokens: 1234,
242
- // Numbers ride; the string-valued siblings must not. `total_tokens` here
243
- // is a STRING on purpose: numOrNull must drop it, not coerce it.
244
- payload: { cost: 0.42, usage: { input_tokens: 100, output_tokens: 24, total_tokens: MARKER }, note: MARKER },
245
- }),
246
- ];
247
- events.forEach((record, i) => exp.recordEvent(record, `evt_${i}`, "2026-01-01T00:00:05.000Z"));
248
- const gate = new GateReport();
249
- gate.check(`the agent claimed ${MARKER}`, false, MARKER);
250
- exp.recordGate(phase(), "no_placeholders", gate, 1);
251
- // A failed phase: `phase.error` is repo/agent text and must not ride along.
252
- exp.recordPhase(phase({ status: "fail", error: `TypeError in ${MARKER}` }));
253
- exp.recordSessionFinish(false);
254
- const json = exp.pendingJson();
255
- assert.ok(!json.includes(MARKER), `the marker leaked into the OTLP body:\n${json}`);
256
- // Positive controls: the allowlisted fields ARE there, so this test can't
257
- // pass by exporting nothing at all.
258
- assert.ok(json.includes("spf.phase.name"), "phase attributes are still exported");
259
- assert.ok(json.includes("no_placeholders"), "the gate NAME is allowlisted");
260
- assert.ok(json.includes('"spf.gate.violation_count"'), "the violation COUNT is what crosses, not the text");
261
- assert.ok(json.includes('"doubleValue":0.42'), "cost still rides as an attribute");
262
- // `record.tokens` (a real number) exports; `payload.usage.total_tokens` was
263
- // the MARKER string and must have been dropped by numOrNull rather than
264
- // stringified into an attribute — so exactly one total-tokens attribute
265
- // exists, and it is the numeric one.
266
- const agentSpan = JSON.parse(json).resourceSpans[0].scopeSpans[0].spans.find((s) => s.name === "agent builder");
267
- const totals = agentSpan.attributes.filter((a) => a.key === "spf.tokens.total");
268
- assert.equal(totals.length, 1);
269
- assert.deepEqual(totals[0].value, { intValue: "1234" }, "the number rode; the string did not");
270
- });
271
- test("allowlist: agent model/coding_agent come from the typed AgentConfig, never the payload", () => {
272
- const exp = exporter("http://127.0.0.1:1/v1/traces");
273
- exp.recordAgentSession(v.parse(AgentConfigSchema, {
274
- name: "builder",
275
- model: "google/gemini-3.6-flash",
276
- prompt_engineering: { system: "s.md", user: "u.md" },
277
- }));
278
- exp.recordEvent(makeEventRecord({ adw_id: "adw_test", phase_id: "adw_test_01_build", type: "agent_start", name: "builder", payload: {} }), "evt_a", "2026-01-01T00:00:00.000Z");
279
- exp.recordEvent(makeEventRecord({ adw_id: "adw_test", phase_id: "adw_test_01_build", type: "agent_end", name: "builder", tokens: 10, payload: {} }), "evt_b", "2026-01-01T00:00:09.000Z");
280
- const json = exp.pendingJson();
281
- assert.ok(json.includes("google/gemini-3.6-flash"), "the model is allowlisted and comes from config");
282
- assert.ok(json.includes('"spf.agent.coding_agent"'), "so is the backend");
283
- assert.ok(json.includes('"gen_ai.request.model"'), "and it is also emitted under the GenAI semantic convention");
284
- });
285
- // ── 4. the queue bound ──────────────────────────────────────────────────────
286
- test("queue bound: a synchronous burst of 3000 spans stays bounded, and the overflow is counted", () => {
287
- const exp = exporter("http://127.0.0.1:1/v1/traces");
288
- for (let i = 0; i < 3000; i++) {
289
- exp.recordEvent(makeEventRecord({
290
- adw_id: "adw_test",
291
- phase_id: "adw_test_01_build",
292
- type: "tool_call",
293
- name: "bash: echo hi",
294
- started_at: "2026-01-01T00:00:01.000Z",
295
- ended_at: "2026-01-01T00:00:01.100Z",
296
- payload: { tool: "bash", agent: "builder" },
297
- }), `evt_${i}`, "2026-01-01T00:00:01.000Z");
298
- }
299
- const stats = exp.stats();
300
- assert.ok(stats.queued <= 2048, `queue must stay bounded, saw ${stats.queued}`);
301
- assert.equal(stats.queued, 2048, "the bound is 2048 spans");
302
- assert.equal(stats.dropped, 3000 - 2048, "every span over the bound is counted, not silently forgotten");
303
- // Drop-OLDEST: the surviving spans are the newest ones, which are the ones
304
- // still explaining what the run was doing when it filled up.
305
- const spans = JSON.parse(exp.pendingJson()).resourceSpans[0].scopeSpans[0].spans;
306
- assert.equal(spans[spans.length - 1].spanId, spanIdFor("tool:adw_test_01_build:evt_2999"));
307
- });
308
- test("span events per phase are bounded too — a runaway phase can't grow the buffer forever", () => {
309
- const exp = exporter("http://127.0.0.1:1/v1/traces");
310
- for (let i = 0; i < 500; i++) {
311
- exp.recordEvent(makeEventRecord({ adw_id: "adw_test", phase_id: "adw_test_01_build", type: "handoff", name: "note", payload: {} }), `evt_${i}`, "2026-01-01T00:00:01.000Z");
312
- }
313
- exp.recordPhase(phase());
314
- const span = JSON.parse(exp.pendingJson()).resourceSpans[0].scopeSpans[0].spans[0];
315
- assert.equal(span.events.length, 64, "capped at MAX_EVENTS_PER_SPAN");
316
- assert.equal(exp.stats().droppedEvents, 500 - 64);
317
- });
318
- test("log events are dropped outright, not buffered — they can't crowd handoff/error out of the per-phase cap", () => {
319
- const exp = exporter("http://127.0.0.1:1/v1/traces");
320
- for (let i = 0; i < 70; i++) {
321
- exp.recordEvent(makeEventRecord({ adw_id: "adw_test", phase_id: "adw_test_01_build", type: "log", name: "console", payload: {} }), `log_${i}`, "2026-01-01T00:00:01.000Z");
322
- }
323
- exp.recordEvent(makeEventRecord({ adw_id: "adw_test", phase_id: "adw_test_01_build", type: "handoff", name: "handoff", payload: {} }), "evt_handoff", "2026-01-01T00:00:01.500Z");
324
- exp.recordPhase(phase());
325
- const span = JSON.parse(exp.pendingJson()).resourceSpans[0].scopeSpans[0].spans[0];
326
- assert.equal(span.events.length, 1, "the 70 log events were dropped, not buffered");
327
- assert.equal(span.events[0].name, "handoff");
328
- assert.equal(exp.stats().droppedEvents, 0, "log events are dropped before touching the buffer, so they don't count as droppedEvents either");
329
- });
330
- test("recordEvent is fail-closed: an unrecognized EventRecord.type is dropped, not exported by default", () => {
331
- const exp = exporter("http://127.0.0.1:1/v1/traces");
332
- exp.recordEvent({ adw_id: "adw_test", phase_id: "adw_test_01_build", type: "future_type", name: "agent-output-derived-name", payload: {}, parent_id: "" }, "evt_future", "2026-01-01T00:00:01.000Z");
333
- exp.recordPhase(phase());
334
- const span = JSON.parse(exp.pendingJson()).resourceSpans[0].scopeSpans[0].spans[0];
335
- assert.equal(span.events.length, 0, "an unknown event type must not fall through to export");
336
- });
337
- // ── 5. the wire shape ───────────────────────────────────────────────────────
338
- test("OTLP/HTTP-JSON shape: resourceSpans -> scopeSpans -> spans, hex ids, stringified nanos", async () => {
339
- const sink = await receiver();
340
- try {
341
- const exp = exporter(sink.url);
342
- exp.recordSessionStart("2026-01-01T00:00:00.000Z");
343
- exp.recordEvent(makeEventRecord({ adw_id: "adw_test", phase_id: "adw_test_01_build", type: "agent_start", name: "builder", payload: {} }), "evt_1", "2026-01-01T00:00:01.000Z");
344
- exp.recordEvent(makeEventRecord({
345
- adw_id: "adw_test",
346
- phase_id: "adw_test_01_build",
347
- type: "tool_call",
348
- name: "bash: ls",
349
- started_at: "2026-01-01T00:00:02.000Z",
350
- ended_at: "2026-01-01T00:00:03.000Z",
351
- payload: { tool: "bash", agent: "builder" },
352
- }), "evt_2", "2026-01-01T00:00:02.000Z");
353
- exp.recordEvent(makeEventRecord({
354
- adw_id: "adw_test",
355
- phase_id: "adw_test_01_build",
356
- type: "agent_end",
357
- name: "builder",
358
- tokens: 999,
359
- payload: { cost: 1.5, usage: { input_tokens: 700, output_tokens: 299, total_tokens: 999 } },
360
- }), "evt_3", "2026-01-01T00:00:04.000Z");
361
- exp.recordPhase(phase());
362
- exp.recordSessionFinish(true);
363
- await exp.flush(true);
364
- const request = await sink.body;
365
- assert.equal(request.method, "POST");
366
- assert.equal(request.contentType, "application/json");
367
- const payload = JSON.parse(request.raw);
368
- assert.equal(payload.resourceSpans.length, 1, "one resourceSpans entry");
369
- const resource = payload.resourceSpans[0].resource;
370
- assert.deepEqual(resource.attributes.find((a) => a.key === "service.name"), { key: "service.name", value: { stringValue: "spf" } }, "resource attributes are AnyValue-wrapped KeyValue pairs");
371
- assert.equal(payload.resourceSpans[0].scopeSpans.length, 1, "scopeSpans nests INSIDE resourceSpans");
372
- const scopeSpans = payload.resourceSpans[0].scopeSpans[0];
373
- assert.equal(scopeSpans.scope.name, "spf");
374
- const spans = scopeSpans.spans;
375
- assert.equal(spans.length, 4, "root + phase + agent call + tool call");
376
- for (const span of spans) {
377
- assert.match(span.traceId, HEX32, "trace ids are lowercase hex strings, not base64");
378
- assert.match(span.spanId, HEX16);
379
- assert.equal(typeof span.startTimeUnixNano, "string", "nanosecond timestamps MUST be strings");
380
- assert.equal(typeof span.endTimeUnixNano, "string");
381
- assert.match(span.startTimeUnixNano, /^\d+$/);
382
- assert.equal(span.kind, 1, "INTERNAL");
383
- assert.equal(span.traceId, traceIdFor("adw_test"), "one run = one trace");
384
- }
385
- const root = spans.find((s) => s.name === "spf run plan-build-test");
386
- const phaseSpan = spans.find((s) => s.name === "phase build");
387
- const agentSpan = spans.find((s) => s.name === "agent builder");
388
- const toolSpan = spans.find((s) => s.name === "bash");
389
- assert.ok(root && phaseSpan && agentSpan && toolSpan, "every level of the span model is present");
390
- assert.equal(root.parentSpanId, "", "no inbound traceparent -> the run is the root");
391
- assert.equal(root.spanId, spanIdFor("run:adw_test"));
392
- assert.equal(phaseSpan.parentSpanId, root.spanId, "phases hang off the run");
393
- assert.equal(phaseSpan.spanId, spanIdFor("adw_test_01_build"), "a phase span's id is sha256(phase_id)");
394
- assert.equal(agentSpan.parentSpanId, phaseSpan.spanId, "agent calls are CHILDREN of their phase");
395
- assert.equal(toolSpan.parentSpanId, agentSpan.spanId, "tool calls hang off the agent call that made them");
396
- assert.equal(phaseSpan.status.code, 1, "a successful phase reports OK");
397
- assert.equal(phaseSpan.startTimeUnixNano, nanosFromIso("2026-01-01T00:00:00.000Z"), "real phase clock, not send time");
398
- assert.equal(phaseSpan.endTimeUnixNano, nanosFromIso("2026-01-01T00:00:10.500Z"));
399
- assert.equal(toolSpan.startTimeUnixNano, nanosFromIso("2026-01-01T00:00:02.000Z"), "tool spans carry real elapsed time");
400
- const tokens = agentSpan.attributes.find((a) => a.key === "spf.tokens.total");
401
- assert.deepEqual(tokens.value, { intValue: "999" }, "token counts are intValue STRINGS");
402
- const cost = agentSpan.attributes.find((a) => a.key === "spf.cost.total");
403
- assert.deepEqual(cost.value, { doubleValue: 1.5 }, "cost is a double");
404
- }
405
- finally {
406
- await sink.close();
407
- }
408
- });
409
- test("a failed phase exports ERROR status and NO error message", async () => {
410
- const sink = await receiver();
411
- try {
412
- const exp = exporter(sink.url);
413
- exp.recordPhase(phase({ status: "fail", error: "TypeError: cannot read property 'x' of undefined in src/app.ts" }));
414
- await exp.flush(true);
415
- const span = JSON.parse((await sink.body).raw).resourceSpans[0].scopeSpans[0].spans[0];
416
- assert.equal(span.status.code, 2, "ERROR");
417
- assert.equal(span.status.message, undefined, "the message is agent/repo text — the status code is the whole signal");
418
- assert.ok(!(await sink.body).raw.includes("src/app.ts"));
419
- }
420
- finally {
421
- await sink.close();
422
- }
423
- });
424
- test("gate events land on the phase span as span EVENTS, with the count and not the text", async () => {
425
- const sink = await receiver();
426
- try {
427
- const exp = exporter(sink.url);
428
- const report = new GateReport();
429
- report.check("plan.md must exist", false, "not found");
430
- report.check("no TODO markers", false, "3 found in src/app.ts");
431
- exp.recordGate(phase(), "plan_gate", report, 2);
432
- exp.recordPhase(phase());
433
- await exp.flush(true);
434
- const span = JSON.parse((await sink.body).raw).resourceSpans[0].scopeSpans[0].spans[0];
435
- assert.equal(span.events.length, 1);
436
- assert.equal(span.events[0].name, "gate_fail");
437
- assert.equal(typeof span.events[0].timeUnixNano, "string");
438
- const byKey = Object.fromEntries(span.events[0].attributes.map((a) => [a.key, a.value]));
439
- assert.deepEqual(byKey["spf.gate.name"], { stringValue: "plan_gate" });
440
- assert.deepEqual(byKey["spf.gate.passed"], { boolValue: false });
441
- assert.deepEqual(byKey["spf.gate.violation_count"], { intValue: "2" });
442
- assert.deepEqual(byKey["spf.gate.attempt"], { intValue: "2" });
443
- assert.ok(!(await sink.body).raw.includes("src/app.ts"), "violation text stays in SQLite");
444
- }
445
- finally {
446
- await sink.close();
447
- }
448
- });
449
- test("the final flush carries the drop counter as a resource attribute", async () => {
450
- const sink = await receiver();
451
- const lines = [];
452
- try {
453
- const exp = exporter(sink.url, (m) => lines.push(m));
454
- for (let i = 0; i < 2100; i++) {
455
- exp.recordEvent(makeEventRecord({ adw_id: "adw_test", phase_id: "p", type: "tool_call", name: "bash: x", payload: {} }), `evt_${i}`, "2026-01-01T00:00:01.000Z");
456
- }
457
- await exp.flush(true);
458
- const resource = JSON.parse((await sink.body).raw).resourceSpans[0].resource;
459
- assert.deepEqual(resource.attributes.find((a) => a.key === "spf.otel.dropped_spans"), { key: "spf.otel.dropped_spans", value: { intValue: "52" } }, "the gap is visible in the backend, not only in a log nobody kept");
460
- assert.equal(lines.length, 1, "exactly one warn line, not one per drop");
461
- assert.match(lines[0], /dropped 52 span\(s\)/);
462
- }
463
- finally {
464
- await sink.close();
465
- }
466
- });
467
- // ── 6. failure isolation ────────────────────────────────────────────────────
468
- test("a rejecting collector logs once, redacted, and never throws", async () => {
469
- const lines = [];
470
- const server = createServer((_req, res) => {
471
- res.writeHead(500);
472
- res.end("nope");
473
- });
474
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
475
- const port = server.address().port;
476
- try {
477
- const exp = new OtelExporter({
478
- cfg: { endpoint: `http://127.0.0.1:${port}/v1/traces`, service_name: "spf", headers: { authorization: "Bearer sk-topsecret" } },
479
- adwId: "adw_test",
480
- chainName: "plan",
481
- log: (m) => lines.push(m),
482
- env: {},
483
- });
484
- exp.recordPhase(phase());
485
- await exp.flush(true); // must resolve, not reject
486
- exp.recordPhase(phase({ phase_id: "adw_test_02_test", seq: 2 }));
487
- await exp.flush(true);
488
- assert.equal(lines.length, 1, "one line for the life of the exporter, not one per batch");
489
- assert.ok(!lines[0].includes("sk-topsecret"), "header values are never logged");
490
- assert.ok(!lines[0].includes(String(port)), "nor the endpoint");
491
- assert.match(lines[0], /the run is unaffected/);
492
- }
493
- finally {
494
- await new Promise((resolve) => server.close(() => resolve()));
495
- }
496
- });
497
- test("drain(): resolves on its own budget against a dead endpoint, and never throws", async () => {
498
- // 127.0.0.1:1 is closed on every platform CI runs on — a connection refusal,
499
- // which is the fast path; the budget covers the hang case.
500
- const exp = exporter("http://127.0.0.1:1/v1/traces");
501
- exp.recordPhase(phase());
502
- const started = Date.now();
503
- await exp.drain(200);
504
- assert.ok(Date.now() - started < 3_000, "a dead collector cannot stall shutdown");
505
- assert.equal(exp.stats().queued, 0, "the queue is drained either way");
506
- });
507
- test("drain() with no queued spans and no config activity is a silent no-op", async () => {
508
- const exp = exporter("http://127.0.0.1:1/v1/traces");
509
- await exp.drain(50);
510
- await exp.drain(50); // idempotent: the root span is emitted at most once
511
- assert.equal(exp.stats().queued, 0);
512
- });
@@ -1 +0,0 @@
1
- export {};