@gr8ful/spf 0.3.0 → 0.5.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.
- package/README.md +177 -9
- package/assets/defaults/spf.config.yaml +22 -0
- package/assets/prompts/refiner/system.md +53 -0
- package/assets/prompts/refiner/user.md +70 -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 +104 -3
- package/assets/skill/references/observability.md +11 -2
- package/assets/templates/ts-cc.spf.config.yaml +3 -3
- package/assets/templates/ts-flue-ollama.spf.config.yaml +67 -0
- package/assets/templates/ts.spf.config.yaml +15 -2
- package/dist/chains/context.d.ts +39 -0
- package/dist/chains/index.d.ts +94 -10
- package/dist/chains/index.js +75 -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 +237 -18
- package/dist/chains/steps.js +477 -58
- 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 +324 -10
- package/dist/cli/commands/init.d.ts +12 -0
- package/dist/cli/commands/init.js +108 -4
- package/dist/cli/commands/install-skill.js +5 -2
- package/dist/cli/commands/list.js +43 -5
- package/dist/cli/commands/run.js +29 -2
- package/dist/cli/commands/watch.d.ts +18 -0
- package/dist/cli/commands/watch.js +214 -16
- package/dist/cli/index.js +63 -6
- package/dist/cli/interview.js +81 -9
- 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 +182 -4
- package/dist/core/data_types.js +141 -2
- package/dist/core/gates.d.ts +13 -0
- package/dist/core/gates.js +103 -0
- package/dist/core/git_helper.d.ts +29 -0
- package/dist/core/git_helper.js +41 -1
- package/dist/core/issues/github_provider.d.ts +35 -9
- package/dist/core/issues/github_provider.js +76 -28
- package/dist/core/issues/jira_provider.d.ts +14 -1
- package/dist/core/issues/jira_provider.js +9 -7
- package/dist/core/issues/provider.d.ts +77 -15
- package/dist/core/issues/provider.js +7 -4
- package/dist/core/notify/channel.d.ts +1 -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/providers.js +4 -0
- package/dist/core/refine.d.ts +39 -0
- package/dist/core/refine.js +152 -0
- 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 +67 -1
- package/dist/core/watch.js +217 -13
- package/dist/test/chains.test.js +9 -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 +71 -1
- package/dist/test/interview.test.d.ts +15 -1
- package/dist/test/interview.test.js +131 -3
- 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/refine.test.d.ts +1 -0
- package/dist/test/refine.test.js +189 -0
- 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 +297 -6
- package/package.json +5 -5
|
@@ -0,0 +1,512 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|