@gr8ful/spf 0.1.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/LICENSE +21 -0
- package/README.md +321 -0
- package/assets/defaults/spf.config.yaml +141 -0
- package/assets/prompts/builder/system.md +13 -0
- package/assets/prompts/builder/user.md +34 -0
- package/assets/prompts/documenter/system.md +17 -0
- package/assets/prompts/documenter/user.md +48 -0
- package/assets/prompts/planner/system.md +21 -0
- package/assets/prompts/planner/user.md +45 -0
- package/assets/prompts/reviewer/system.md +16 -0
- package/assets/prompts/reviewer/user.md +44 -0
- package/assets/prompts/scout/system.md +20 -0
- package/assets/prompts/scout/user.md +34 -0
- package/assets/skill/SKILL.md +80 -0
- package/assets/skill/cookbooks/authoring_chains.md +193 -0
- package/assets/skill/cookbooks/how_to_prompt_for_the_eng.md +109 -0
- package/assets/skill/cookbooks/roster.md +197 -0
- package/assets/skill/cookbooks/run_adw.md +92 -0
- package/assets/skill/cookbooks/spf_overview.md +111 -0
- package/assets/skill/references/config.md +188 -0
- package/assets/skill/references/handoff.md +162 -0
- package/assets/skill/references/observability.md +184 -0
- package/dist/chains/adw_build.d.ts +12 -0
- package/dist/chains/adw_build.js +27 -0
- package/dist/chains/adw_build_review.d.ts +21 -0
- package/dist/chains/adw_build_review.js +55 -0
- package/dist/chains/adw_build_test.d.ts +21 -0
- package/dist/chains/adw_build_test.js +67 -0
- package/dist/chains/adw_document.d.ts +23 -0
- package/dist/chains/adw_document.js +59 -0
- package/dist/chains/adw_plan.d.ts +12 -0
- package/dist/chains/adw_plan.js +27 -0
- package/dist/chains/adw_plan_build.d.ts +12 -0
- package/dist/chains/adw_plan_build.js +30 -0
- package/dist/chains/adw_plan_build_test.d.ts +16 -0
- package/dist/chains/adw_plan_build_test.js +65 -0
- package/dist/chains/adw_plan_build_test_quality.d.ts +18 -0
- package/dist/chains/adw_plan_build_test_quality.js +66 -0
- package/dist/chains/adw_prompt.d.ts +12 -0
- package/dist/chains/adw_prompt.js +25 -0
- package/dist/chains/adw_quality.d.ts +12 -0
- package/dist/chains/adw_quality.js +32 -0
- package/dist/chains/adw_scout.d.ts +12 -0
- package/dist/chains/adw_scout.js +27 -0
- package/dist/chains/adw_simple_sdlc.d.ts +43 -0
- package/dist/chains/adw_simple_sdlc.js +147 -0
- package/dist/chains/context.d.ts +19 -0
- package/dist/chains/context.js +1 -0
- package/dist/chains/index.d.ts +19 -0
- package/dist/chains/index.js +116 -0
- package/dist/cli/bin.d.ts +15 -0
- package/dist/cli/bin.js +24 -0
- package/dist/cli/commands/abort.d.ts +1 -0
- package/dist/cli/commands/abort.js +42 -0
- package/dist/cli/commands/doctor.d.ts +1 -0
- package/dist/cli/commands/doctor.js +165 -0
- package/dist/cli/commands/eject.d.ts +1 -0
- package/dist/cli/commands/eject.js +55 -0
- package/dist/cli/commands/events.d.ts +1 -0
- package/dist/cli/commands/events.js +49 -0
- package/dist/cli/commands/init.d.ts +1 -0
- package/dist/cli/commands/init.js +62 -0
- package/dist/cli/commands/install-skill.d.ts +1 -0
- package/dist/cli/commands/install-skill.js +122 -0
- package/dist/cli/commands/list.d.ts +1 -0
- package/dist/cli/commands/list.js +13 -0
- package/dist/cli/commands/migrate.d.ts +1 -0
- package/dist/cli/commands/migrate.js +167 -0
- package/dist/cli/commands/phases.d.ts +1 -0
- package/dist/cli/commands/phases.js +25 -0
- package/dist/cli/commands/run.d.ts +3 -0
- package/dist/cli/commands/run.js +27 -0
- package/dist/cli/commands/sessions.d.ts +1 -0
- package/dist/cli/commands/sessions.js +20 -0
- package/dist/cli/commands/trace.d.ts +8 -0
- package/dist/cli/commands/trace.js +10 -0
- package/dist/cli/commands/ui.d.ts +1 -0
- package/dist/cli/commands/ui.js +35 -0
- package/dist/cli/commands/version.d.ts +1 -0
- package/dist/cli/commands/version.js +8 -0
- package/dist/cli/commands/watch.d.ts +8 -0
- package/dist/cli/commands/watch.js +210 -0
- package/dist/cli/gitignore.d.ts +1 -0
- package/dist/cli/gitignore.js +14 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +156 -0
- package/dist/core/agent_cc.d.ts +75 -0
- package/dist/core/agent_cc.js +322 -0
- package/dist/core/agent_flue.d.ts +66 -0
- package/dist/core/agent_flue.js +321 -0
- package/dist/core/agents.d.ts +64 -0
- package/dist/core/agents.js +456 -0
- package/dist/core/changes.d.ts +35 -0
- package/dist/core/changes.js +98 -0
- package/dist/core/console.d.ts +36 -0
- package/dist/core/console.js +156 -0
- package/dist/core/data_types.d.ts +562 -0
- package/dist/core/data_types.js +382 -0
- package/dist/core/gates.d.ts +33 -0
- package/dist/core/gates.js +144 -0
- package/dist/core/git_helper.d.ts +59 -0
- package/dist/core/git_helper.js +115 -0
- package/dist/core/issues/github_provider.d.ts +52 -0
- package/dist/core/issues/github_provider.js +211 -0
- package/dist/core/issues/provider.d.ts +90 -0
- package/dist/core/issues/provider.js +14 -0
- package/dist/core/paths.d.ts +78 -0
- package/dist/core/paths.js +108 -0
- package/dist/core/permissions.d.ts +78 -0
- package/dist/core/permissions.js +187 -0
- package/dist/core/prompts.d.ts +4 -0
- package/dist/core/prompts.js +17 -0
- package/dist/core/quality.d.ts +65 -0
- package/dist/core/quality.js +194 -0
- package/dist/core/runner.d.ts +72 -0
- package/dist/core/runner.js +168 -0
- package/dist/core/session.d.ts +16 -0
- package/dist/core/session.js +60 -0
- package/dist/core/sqlite.d.ts +55 -0
- package/dist/core/sqlite.js +106 -0
- package/dist/core/tracer.d.ts +56 -0
- package/dist/core/tracer.js +246 -0
- package/dist/core/utils.d.ts +40 -0
- package/dist/core/utils.js +108 -0
- package/dist/core/watch.d.ts +44 -0
- package/dist/core/watch.js +194 -0
- package/dist/test/agent_cc.test.d.ts +1 -0
- package/dist/test/agent_cc.test.js +95 -0
- package/dist/test/agent_flue.test.d.ts +1 -0
- package/dist/test/agent_flue.test.js +83 -0
- package/dist/test/data_types.test.d.ts +10 -0
- package/dist/test/data_types.test.js +49 -0
- package/dist/test/ui_server.test.d.ts +1 -0
- package/dist/test/ui_server.test.js +119 -0
- package/dist/test/watch.test.d.ts +1 -0
- package/dist/test/watch.test.js +227 -0
- package/dist/ui/server/app.d.ts +3 -0
- package/dist/ui/server/app.js +98 -0
- package/dist/ui/server/db.d.ts +82 -0
- package/dist/ui/server/db.js +333 -0
- package/dist/ui/server/serve.d.ts +13 -0
- package/dist/ui/server/serve.js +80 -0
- package/dist/ui/server/static.d.ts +5 -0
- package/dist/ui/server/static.js +55 -0
- package/dist/ui/shared/types.d.ts +263 -0
- package/dist/ui/shared/types.js +8 -0
- package/package.json +52 -0
- package/web/assets/index-C7nF068F.css +1 -0
- package/web/assets/index-mzSArcnQ.js +11 -0
- package/web/assets/play-latin-400-normal-GKW-4YV7.woff2 +0 -0
- package/web/assets/play-latin-700-normal-DyPlLDbb.woff2 +0 -0
- package/web/index.html +14 -0
- package/web/logo.svg +6 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Run object: config + adw_id + agent_map + tracer + console, bound once.
|
|
3
|
+
*
|
|
4
|
+
* `run.phase(params, fn)` is the ONE phase primitive — an async scope for all
|
|
5
|
+
* three kinds (engineer, agent, code), replacing Python's `with run.phase(...)
|
|
6
|
+
* as ph:` context manager (JS has no direct equivalent, so the callback form
|
|
7
|
+
* is the idiomatic stand-in). Success must be earned: every phase defaults to
|
|
8
|
+
* fail; only a clean exit flips it (agent phases additionally require a
|
|
9
|
+
* parsed envelope + green gates, enforced inside ph.call).
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import * as agents from "./agents.js";
|
|
14
|
+
import { makeGit } from "./git_helper.js";
|
|
15
|
+
import { Console } from "./console.js";
|
|
16
|
+
import { Tracer } from "./tracer.js";
|
|
17
|
+
import { makeEventRecord } from "./data_types.js";
|
|
18
|
+
import { ensureDir, nowIso } from "./utils.js";
|
|
19
|
+
class PhaseHandleImpl {
|
|
20
|
+
run;
|
|
21
|
+
phase;
|
|
22
|
+
constructor(run, phase) {
|
|
23
|
+
this.run = run;
|
|
24
|
+
this.phase = phase;
|
|
25
|
+
}
|
|
26
|
+
log(payload) {
|
|
27
|
+
this.run.tracer.event(makeEventRecord({
|
|
28
|
+
adw_id: this.run.adw_id,
|
|
29
|
+
phase_id: this.phase.phase_id,
|
|
30
|
+
type: "log",
|
|
31
|
+
name: this.phase.params.name,
|
|
32
|
+
payload,
|
|
33
|
+
}));
|
|
34
|
+
this.run.console.note(Object.entries(payload).map(([k, v]) => `${k}: ${v}`).join(", "));
|
|
35
|
+
if (this.phase.params.kind === "engineer" && "input" in payload) {
|
|
36
|
+
this.run.tracer.sessionRequest(this.run.adw_id, String(payload.input));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async call(call) {
|
|
40
|
+
if (this.phase.params.kind !== "agent") {
|
|
41
|
+
throw new Error("ph.call() is only valid inside an agent phase");
|
|
42
|
+
}
|
|
43
|
+
return (await agents.execute(this.run, this.phase, call));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export class Run {
|
|
47
|
+
cfg;
|
|
48
|
+
adw_id;
|
|
49
|
+
tracer;
|
|
50
|
+
console;
|
|
51
|
+
engineer;
|
|
52
|
+
phases = [];
|
|
53
|
+
tokens = 0;
|
|
54
|
+
cost = 0;
|
|
55
|
+
repo_root; // where every agent is spawned to work — always absolute
|
|
56
|
+
/** Every git operation for this run, bound to repo_root. Never call git_helper directly. */
|
|
57
|
+
git;
|
|
58
|
+
/** From the same anchor as repo_root — null if no .spf/ dir exists. */
|
|
59
|
+
spf_dir;
|
|
60
|
+
/** Absolute. Sibling of it: flue.db (Flue's own conversation store), sessions/. */
|
|
61
|
+
data_dir;
|
|
62
|
+
session_dir;
|
|
63
|
+
context_handoff_dir;
|
|
64
|
+
agent_map;
|
|
65
|
+
seq; // a joined run continues the sequence
|
|
66
|
+
agentMapPath;
|
|
67
|
+
constructor(init) {
|
|
68
|
+
this.cfg = init.cfg;
|
|
69
|
+
this.adw_id = init.adwId;
|
|
70
|
+
this.tracer = init.tracer;
|
|
71
|
+
this.console = new Console(init.tracer, init.adwId);
|
|
72
|
+
this.engineer = init.engineer;
|
|
73
|
+
this.seq = init.tracer.maxPhaseSeq(init.adwId);
|
|
74
|
+
this.repo_root = init.repoRoot;
|
|
75
|
+
this.spf_dir = init.sfDir;
|
|
76
|
+
this.git = makeGit(init.repoRoot);
|
|
77
|
+
this.data_dir = init.dataDir;
|
|
78
|
+
this.session_dir = ensureDir(path.join(init.dataDir, "sessions", init.adwId));
|
|
79
|
+
this.context_handoff_dir = ensureDir(path.join(this.session_dir, "context_handoff"));
|
|
80
|
+
this.agentMapPath = path.join(this.session_dir, "agent_map.json");
|
|
81
|
+
this.agent_map = existsSync(this.agentMapPath) ? JSON.parse(readFileSync(this.agentMapPath, "utf-8")) : {};
|
|
82
|
+
}
|
|
83
|
+
// ── agent map (adw_id -> per-agent coding-agent session ids) ────────────
|
|
84
|
+
saveAgentMap(agent, entry) {
|
|
85
|
+
this.agent_map[agent] = entry;
|
|
86
|
+
writeFileSync(this.agentMapPath, JSON.stringify(this.agent_map, null, 2));
|
|
87
|
+
}
|
|
88
|
+
// ── usage (run totals mirror what the tracer accumulates in sqlite) ─────
|
|
89
|
+
addUsage(tokens, cost) {
|
|
90
|
+
this.tokens += tokens;
|
|
91
|
+
this.cost += cost;
|
|
92
|
+
this.tracer.sessionAddUsage(this.adw_id, tokens, cost);
|
|
93
|
+
}
|
|
94
|
+
// ── the phase primitive ─────────────────────────────────────────────────
|
|
95
|
+
async phase(params, fn) {
|
|
96
|
+
this.seq += 1;
|
|
97
|
+
const phase = {
|
|
98
|
+
phase_id: `${this.adw_id}_${String(this.seq).padStart(2, "0")}_${params.name}`,
|
|
99
|
+
adw_id: this.adw_id,
|
|
100
|
+
seq: this.seq,
|
|
101
|
+
params,
|
|
102
|
+
status: "running",
|
|
103
|
+
attempt: 0,
|
|
104
|
+
error: null,
|
|
105
|
+
started_at: nowIso(),
|
|
106
|
+
ended_at: null,
|
|
107
|
+
};
|
|
108
|
+
this.phases.push(phase);
|
|
109
|
+
this.tracer.phaseUpsert(phase);
|
|
110
|
+
this.tracer.event(makeEventRecord({
|
|
111
|
+
adw_id: this.adw_id,
|
|
112
|
+
phase_id: phase.phase_id,
|
|
113
|
+
type: "phase_start",
|
|
114
|
+
name: params.name,
|
|
115
|
+
payload: { kind: params.kind, owner: params.owner, description: params.description },
|
|
116
|
+
}));
|
|
117
|
+
this.console.phaseStarted(phase);
|
|
118
|
+
const clock = performance.now();
|
|
119
|
+
try {
|
|
120
|
+
const result = await fn(new PhaseHandleImpl(this, phase));
|
|
121
|
+
phase.status = "success";
|
|
122
|
+
phase.ended_at = nowIso();
|
|
123
|
+
this.tracer.event(makeEventRecord({ adw_id: this.adw_id, phase_id: phase.phase_id, type: "phase_end", name: params.name, payload: { status: "success" } }));
|
|
124
|
+
this.tracer.phaseUpsert(phase);
|
|
125
|
+
this.console.phaseEnded(phase, (performance.now() - clock) / 1000);
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
phase.status = "fail"; // success must be earned
|
|
130
|
+
phase.error = String(error?.message ?? error).slice(0, 1000);
|
|
131
|
+
phase.ended_at = nowIso();
|
|
132
|
+
this.tracer.event(makeEventRecord({ adw_id: this.adw_id, phase_id: phase.phase_id, type: "error", name: params.name, payload: { error: phase.error } }));
|
|
133
|
+
this.tracer.event(makeEventRecord({ adw_id: this.adw_id, phase_id: phase.phase_id, type: "phase_end", name: params.name, payload: { status: "fail" } }));
|
|
134
|
+
this.tracer.phaseUpsert(phase);
|
|
135
|
+
this.tracer.sessionFinish(this.adw_id, false);
|
|
136
|
+
this.console.phaseEnded(phase, (performance.now() - clock) / 1000);
|
|
137
|
+
this.console.sessionFinished(false, this.tokens, this.cost, this.cfg.observability.db);
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Finalize the run and return its exit code. Call this exactly once.
|
|
143
|
+
*
|
|
144
|
+
* Two criteria, not one. Every phase must have passed, AND the ADW's own
|
|
145
|
+
* acceptance test must hold. They are different questions on purpose: a
|
|
146
|
+
* test phase that ran a red suite succeeded at its job. Pass `accepted=`
|
|
147
|
+
* so the exit code, the session status, and the banner are decided
|
|
148
|
+
* together and cannot disagree.
|
|
149
|
+
*/
|
|
150
|
+
finish(accepted = true, reason = "") {
|
|
151
|
+
const phasesOk = this.phases.length > 0 && this.phases.every((p) => p.status === "success");
|
|
152
|
+
const ok = phasesOk && accepted;
|
|
153
|
+
if (phasesOk && !accepted) {
|
|
154
|
+
const note = reason || "the run's acceptance criterion was not met";
|
|
155
|
+
this.tracer.event(makeEventRecord({
|
|
156
|
+
adw_id: this.adw_id,
|
|
157
|
+
phase_id: this.phases.length > 0 ? this.phases[this.phases.length - 1].phase_id : "",
|
|
158
|
+
type: "error",
|
|
159
|
+
name: "not_accepted",
|
|
160
|
+
payload: { reason: note },
|
|
161
|
+
}));
|
|
162
|
+
this.console.note(`not accepted: ${note}`);
|
|
163
|
+
}
|
|
164
|
+
this.tracer.sessionFinish(this.adw_id, ok);
|
|
165
|
+
this.console.sessionFinished(ok, this.tokens, this.cost, this.cfg.observability.db);
|
|
166
|
+
return ok ? 0 : 1;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session lifecycle: pin-or-create an adw_id, build the Run object.
|
|
3
|
+
*
|
|
4
|
+
* `ensure(cfg, adwId)` joins the session if it exists or creates it under
|
|
5
|
+
* exactly that id (pinned ids for repeatable runs); omitted, a fresh id is
|
|
6
|
+
* minted and printed so the next ADW can pick it up.
|
|
7
|
+
*/
|
|
8
|
+
import { Run } from "./runner.ts";
|
|
9
|
+
import type { SFConfig } from "./data_types.ts";
|
|
10
|
+
/**
|
|
11
|
+
* `cwd` anchors this run's repo_root and data_dir — it is NOT where the
|
|
12
|
+
* process happened to start; it is an explicit decision, threaded down from
|
|
13
|
+
* the CLI/chain context. Defaults to `process.cwd()` only for direct callers
|
|
14
|
+
* (tests, scratch scripts) that have no anchor of their own to pass.
|
|
15
|
+
*/
|
|
16
|
+
export declare function ensure(cfg: SFConfig, adwId?: string | null, cwd?: string): Run;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session lifecycle: pin-or-create an adw_id, build the Run object.
|
|
3
|
+
*
|
|
4
|
+
* `ensure(cfg, adwId)` joins the session if it exists or creates it under
|
|
5
|
+
* exactly that id (pinned ids for repeatable runs); omitted, a fresh id is
|
|
6
|
+
* minted and printed so the next ADW can pick it up.
|
|
7
|
+
*/
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import * as paths from "./paths.js";
|
|
10
|
+
import { Run } from "./runner.js";
|
|
11
|
+
import { Tracer } from "./tracer.js";
|
|
12
|
+
import { engineerName, newId } from "./utils.js";
|
|
13
|
+
/**
|
|
14
|
+
* A killed run still closes its own trace.
|
|
15
|
+
*
|
|
16
|
+
* Node's default SIGTERM/SIGINT handling exits without unwinding, so `just
|
|
17
|
+
* kill` (or any `kill <pid>`) would leave the session reading `running`
|
|
18
|
+
* forever and its process rows open — the trace would claim work is in
|
|
19
|
+
* flight that is already dead. Handling the signal both finalizes here and
|
|
20
|
+
* lets the phase's try/catch record the phase as failed on the way out
|
|
21
|
+
* (best-effort: a signal can still land mid-write).
|
|
22
|
+
*/
|
|
23
|
+
function finalizeWhenKilled(run) {
|
|
24
|
+
const handler = (signal) => {
|
|
25
|
+
run.tracer.sessionFinish(run.adw_id, false); // also closes process rows
|
|
26
|
+
process.exit(128 + (signal === "SIGINT" ? 2 : 15));
|
|
27
|
+
};
|
|
28
|
+
process.on("SIGTERM", handler);
|
|
29
|
+
process.on("SIGINT", handler);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* `cwd` anchors this run's repo_root and data_dir — it is NOT where the
|
|
33
|
+
* process happened to start; it is an explicit decision, threaded down from
|
|
34
|
+
* the CLI/chain context. Defaults to `process.cwd()` only for direct callers
|
|
35
|
+
* (tests, scratch scripts) that have no anchor of their own to pass.
|
|
36
|
+
*/
|
|
37
|
+
export function ensure(cfg, adwId, cwd) {
|
|
38
|
+
const id = adwId || newId(8);
|
|
39
|
+
const anchor = paths.resolveAnchor(cwd);
|
|
40
|
+
const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
41
|
+
const tracer = new Tracer(dataPaths.db_path, path.join(dataPaths.sessions_dir, id, "events.jsonl"));
|
|
42
|
+
const run = new Run({
|
|
43
|
+
cfg,
|
|
44
|
+
adwId: id,
|
|
45
|
+
tracer,
|
|
46
|
+
engineer: engineerName(),
|
|
47
|
+
repoRoot: anchor.repo_root,
|
|
48
|
+
sfDir: anchor.spf_dir,
|
|
49
|
+
dataDir: dataPaths.data_dir,
|
|
50
|
+
});
|
|
51
|
+
const scriptPath = process.argv[1] || "adw";
|
|
52
|
+
const adwName = path.basename(scriptPath, path.extname(scriptPath));
|
|
53
|
+
tracer.sessionStart(id, run.engineer, adwName);
|
|
54
|
+
// This process is the run. Record it before any phase opens, so a run that
|
|
55
|
+
// hangs in its first agent call is still killable by adw_id.
|
|
56
|
+
tracer.processStart(id, "adw", "", process.pid ?? -1, [path.basename(scriptPath), ...process.argv.slice(2)].join(" "));
|
|
57
|
+
finalizeWhenKilled(run);
|
|
58
|
+
run.console.sessionStarted(id, run.engineer);
|
|
59
|
+
return run;
|
|
60
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bun:sqlite's surface, backed by node:sqlite, so tracer.ts and the visualizer's
|
|
3
|
+
* db.ts change only their import line.
|
|
4
|
+
*
|
|
5
|
+
* This is deliberately NOT a full bun:sqlite polyfill — it's the ~10-behavior
|
|
6
|
+
* subset SPF actually calls (query/exec/get/all/run, readonly, PRAGMAs, WAL).
|
|
7
|
+
* Every mapping below was verified live against node:sqlite, not assumed:
|
|
8
|
+
*
|
|
9
|
+
* - bun:sqlite's `{ readonly: true }` maps to node:sqlite's `{ readOnly: true }`
|
|
10
|
+
* (capital O). Passing the lowercase bun spelling to node:sqlite is NOT an
|
|
11
|
+
* error — it is silently ignored and the connection opens read-write. That
|
|
12
|
+
* would quietly void every "this connection never writes" guarantee in the
|
|
13
|
+
* codebase, so the translation below is deliberate, not cosmetic.
|
|
14
|
+
* - PRAGMAs (journal_mode, table_info) return rows through the normal
|
|
15
|
+
* prepare()/get()/all() path on both drivers, and multi-statement `exec()`
|
|
16
|
+
* works on both — so the tracer's schema creation and additive ALTER
|
|
17
|
+
* migrations need no changes at all.
|
|
18
|
+
* - `.query()` on bun:sqlite is a MEMOIZED prepare — the same SQL text
|
|
19
|
+
* returns the same cached statement. node:sqlite's `prepare()` is not
|
|
20
|
+
* memoized, and db.ts rebuilds its SQL per-request through an
|
|
21
|
+
* `optionalColumn()` probe, so this shim caches by exact SQL text. That is
|
|
22
|
+
* also *correct*: when a tracer ALTER lands mid-serve and the probed SQL
|
|
23
|
+
* text changes, a fresh statement is prepared automatically.
|
|
24
|
+
* - node:sqlite's `get()` returns `undefined` for no-row, not `null`; rows
|
|
25
|
+
* come back with a null prototype. Both are normalized below so existing
|
|
26
|
+
* `?? null` / `Object.assign(row, ...)` call sites stay honest.
|
|
27
|
+
* - node:sqlite defaults `enableForeignKeyConstraints: true` — unlike plain
|
|
28
|
+
* SQLite and bun:sqlite, which both default FK enforcement OFF. The
|
|
29
|
+
* tracer's schema uses `REFERENCES` purely as documentation (rows are not
|
|
30
|
+
* always inserted parent-before-child — e.g. the very first `events` row
|
|
31
|
+
* for a session lands before that session's own `sessions` row commits),
|
|
32
|
+
* so this is disabled explicitly to restore the behavior the rest of the
|
|
33
|
+
* codebase was written against. Confirmed by reproduction: enabling it
|
|
34
|
+
* throws `FOREIGN KEY constraint failed` on session start.
|
|
35
|
+
*/
|
|
36
|
+
export interface DatabaseOptions {
|
|
37
|
+
readonly?: boolean;
|
|
38
|
+
}
|
|
39
|
+
export interface Statement<Row, Params extends unknown[]> {
|
|
40
|
+
get(...params: Params): Row | null;
|
|
41
|
+
all(...params: Params): Row[];
|
|
42
|
+
run(...params: Params): {
|
|
43
|
+
changes: number;
|
|
44
|
+
lastInsertRowid: number | bigint;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export declare class Database {
|
|
48
|
+
private readonly db;
|
|
49
|
+
private readonly statementCache;
|
|
50
|
+
constructor(path: string, options?: DatabaseOptions);
|
|
51
|
+
/** Cached prepare, keyed by exact SQL text — see the module doc comment. */
|
|
52
|
+
query<Row = unknown, Params extends unknown[] = unknown[]>(sql: string): Statement<Row, Params>;
|
|
53
|
+
exec(sql: string): void;
|
|
54
|
+
close(): void;
|
|
55
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bun:sqlite's surface, backed by node:sqlite, so tracer.ts and the visualizer's
|
|
3
|
+
* db.ts change only their import line.
|
|
4
|
+
*
|
|
5
|
+
* This is deliberately NOT a full bun:sqlite polyfill — it's the ~10-behavior
|
|
6
|
+
* subset SPF actually calls (query/exec/get/all/run, readonly, PRAGMAs, WAL).
|
|
7
|
+
* Every mapping below was verified live against node:sqlite, not assumed:
|
|
8
|
+
*
|
|
9
|
+
* - bun:sqlite's `{ readonly: true }` maps to node:sqlite's `{ readOnly: true }`
|
|
10
|
+
* (capital O). Passing the lowercase bun spelling to node:sqlite is NOT an
|
|
11
|
+
* error — it is silently ignored and the connection opens read-write. That
|
|
12
|
+
* would quietly void every "this connection never writes" guarantee in the
|
|
13
|
+
* codebase, so the translation below is deliberate, not cosmetic.
|
|
14
|
+
* - PRAGMAs (journal_mode, table_info) return rows through the normal
|
|
15
|
+
* prepare()/get()/all() path on both drivers, and multi-statement `exec()`
|
|
16
|
+
* works on both — so the tracer's schema creation and additive ALTER
|
|
17
|
+
* migrations need no changes at all.
|
|
18
|
+
* - `.query()` on bun:sqlite is a MEMOIZED prepare — the same SQL text
|
|
19
|
+
* returns the same cached statement. node:sqlite's `prepare()` is not
|
|
20
|
+
* memoized, and db.ts rebuilds its SQL per-request through an
|
|
21
|
+
* `optionalColumn()` probe, so this shim caches by exact SQL text. That is
|
|
22
|
+
* also *correct*: when a tracer ALTER lands mid-serve and the probed SQL
|
|
23
|
+
* text changes, a fresh statement is prepared automatically.
|
|
24
|
+
* - node:sqlite's `get()` returns `undefined` for no-row, not `null`; rows
|
|
25
|
+
* come back with a null prototype. Both are normalized below so existing
|
|
26
|
+
* `?? null` / `Object.assign(row, ...)` call sites stay honest.
|
|
27
|
+
* - node:sqlite defaults `enableForeignKeyConstraints: true` — unlike plain
|
|
28
|
+
* SQLite and bun:sqlite, which both default FK enforcement OFF. The
|
|
29
|
+
* tracer's schema uses `REFERENCES` purely as documentation (rows are not
|
|
30
|
+
* always inserted parent-before-child — e.g. the very first `events` row
|
|
31
|
+
* for a session lands before that session's own `sessions` row commits),
|
|
32
|
+
* so this is disabled explicitly to restore the behavior the rest of the
|
|
33
|
+
* codebase was written against. Confirmed by reproduction: enabling it
|
|
34
|
+
* throws `FOREIGN KEY constraint failed` on session start.
|
|
35
|
+
*/
|
|
36
|
+
import { DatabaseSync } from "node:sqlite";
|
|
37
|
+
/**
|
|
38
|
+
* bun:sqlite accepts any JS value as a bind param; node:sqlite is typed to
|
|
39
|
+
* null/number/bigint/string/ArrayBufferView. Every current call site only
|
|
40
|
+
* ever passes strings/numbers/null, so this cast documents the narrowing
|
|
41
|
+
* this adapter is responsible for rather than widening node:sqlite's types.
|
|
42
|
+
*/
|
|
43
|
+
function asSqlParams(params) {
|
|
44
|
+
return params;
|
|
45
|
+
}
|
|
46
|
+
const STATEMENT_CACHE_LIMIT = 200;
|
|
47
|
+
export class Database {
|
|
48
|
+
db;
|
|
49
|
+
statementCache = new Map();
|
|
50
|
+
constructor(path, options) {
|
|
51
|
+
this.db = new DatabaseSync(path, {
|
|
52
|
+
readOnly: options?.readonly ?? false,
|
|
53
|
+
timeout: 5000,
|
|
54
|
+
enableForeignKeyConstraints: false,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/** Cached prepare, keyed by exact SQL text — see the module doc comment. */
|
|
58
|
+
query(sql) {
|
|
59
|
+
let stmt = this.statementCache.get(sql);
|
|
60
|
+
if (!stmt) {
|
|
61
|
+
stmt = this.db.prepare(sql);
|
|
62
|
+
if (this.statementCache.size >= STATEMENT_CACHE_LIMIT) {
|
|
63
|
+
const oldest = this.statementCache.keys().next().value;
|
|
64
|
+
if (oldest !== undefined)
|
|
65
|
+
this.statementCache.delete(oldest);
|
|
66
|
+
}
|
|
67
|
+
this.statementCache.set(sql, stmt);
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
get: (...params) => {
|
|
71
|
+
guardNamedParams(params, sql);
|
|
72
|
+
const row = stmt.get(...asSqlParams(params));
|
|
73
|
+
return row === undefined ? null : { ...row };
|
|
74
|
+
},
|
|
75
|
+
all: (...params) => {
|
|
76
|
+
guardNamedParams(params, sql);
|
|
77
|
+
return stmt.all(...asSqlParams(params)).map((row) => ({ ...row }));
|
|
78
|
+
},
|
|
79
|
+
run: (...params) => {
|
|
80
|
+
guardNamedParams(params, sql);
|
|
81
|
+
const result = stmt.run(...asSqlParams(params));
|
|
82
|
+
return { changes: Number(result.changes), lastInsertRowid: result.lastInsertRowid };
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
exec(sql) {
|
|
87
|
+
this.db.exec(sql);
|
|
88
|
+
}
|
|
89
|
+
close() {
|
|
90
|
+
this.db.close();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Every current call site passes only strings/numbers, positionally. If a
|
|
95
|
+
* future call ever passes a bare object as the first param against SQL with
|
|
96
|
+
* no named-parameter markers, node:sqlite's `allowBareNamedParameters`
|
|
97
|
+
* default would silently reinterpret it — fail loudly instead.
|
|
98
|
+
*/
|
|
99
|
+
function guardNamedParams(params, sql) {
|
|
100
|
+
const first = params[0];
|
|
101
|
+
const looksNamed = first !== null && typeof first === "object";
|
|
102
|
+
const sqlHasNamedMarkers = /[:$@][A-Za-z_]/.test(sql);
|
|
103
|
+
if (looksNamed && !sqlHasNamedMarkers) {
|
|
104
|
+
throw new Error(`sqlite: unexpected object parameter for a positional query: ${sql}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracer: every event lands in JSONL and SQLite AS IT HAPPENS.
|
|
3
|
+
*
|
|
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
|
+
* WAL mode so the UI can read while ADW processes write.
|
|
7
|
+
*/
|
|
8
|
+
import { Database } from "./sqlite.ts";
|
|
9
|
+
import type { AgentConfig, EventRecord, GateReport, Phase } from "./data_types.ts";
|
|
10
|
+
export declare class Tracer {
|
|
11
|
+
db: Database;
|
|
12
|
+
dbPath: string;
|
|
13
|
+
eventsJsonl: string;
|
|
14
|
+
constructor(dbPath: string, eventsJsonl: string);
|
|
15
|
+
/** Additive column migrations, so a db from an older SPF still opens. */
|
|
16
|
+
private migrate;
|
|
17
|
+
event(record: EventRecord): string;
|
|
18
|
+
sessionStart(adwId: string, engineer: string, adwName?: string | null): void;
|
|
19
|
+
sessionRequest(adwId: string, request: string): void;
|
|
20
|
+
sessionFinish(adwId: string, ok: boolean): void;
|
|
21
|
+
sessionAddUsage(adwId: string, tokens: number, cost: number): void;
|
|
22
|
+
/**
|
|
23
|
+
* Record a live process for this run.
|
|
24
|
+
*
|
|
25
|
+
* A coding agent that hangs produces no events at all, which is exactly
|
|
26
|
+
* when you need its pid — and `ps` cannot tell you which adw_id it
|
|
27
|
+
* belongs to. Writing it here makes the trace the answer to "what is this
|
|
28
|
+
* run running, and how do I stop it".
|
|
29
|
+
*/
|
|
30
|
+
processStart(adwId: string, kind: string, name: string, pid: number, command: string): void;
|
|
31
|
+
/** Mark the newest live row for this pid as finished. */
|
|
32
|
+
processEnd(adwId: string, pid: number): void;
|
|
33
|
+
/** Close out every live row for a run — called when the session ends. */
|
|
34
|
+
processesEndAll(adwId: string): void;
|
|
35
|
+
/**
|
|
36
|
+
* Highest seq already recorded for this session; 0 when it is new.
|
|
37
|
+
*
|
|
38
|
+
* A joined run continues the sequence instead of restarting at 1 — which
|
|
39
|
+
* would collide with the first run's phases on both `seq` (breaking
|
|
40
|
+
* ordering) and `phase_id` (silently overwriting a row through the
|
|
41
|
+
* phase_upsert conflict clause).
|
|
42
|
+
*/
|
|
43
|
+
maxPhaseSeq(adwId: string): number;
|
|
44
|
+
phaseUpsert(phase: Phase): void;
|
|
45
|
+
envelopeRow(phase: Phase, agent: string, outputType: string, payloadJson: string, valid: boolean, attempt: number): void;
|
|
46
|
+
/** The report carries both the verdict and the evidence behind it. */
|
|
47
|
+
gateRow(phase: Phase, gate: string, report: GateReport, attempt: number): void;
|
|
48
|
+
/**
|
|
49
|
+
* The agent's config row is the source of truth for its label and color.
|
|
50
|
+
*
|
|
51
|
+
* Context is carried here rather than derived from events because the lane
|
|
52
|
+
* wants one number per agent — the latest — and a session that runs the
|
|
53
|
+
* same agent twice overwrites it, exactly like model and session_id.
|
|
54
|
+
*/
|
|
55
|
+
agentSessionRow(adwId: string, agent: AgentConfig, sessionId: string, contextTokens?: number, contextWindow?: number): void;
|
|
56
|
+
}
|