@gr8ful/spf 0.15.0 → 0.16.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 +15 -5
- package/assets/skill/references/config.md +9 -5
- package/assets/skill/references/observability.md +57 -12
- package/assets/templates/ts-opencode.spf.config.yaml +54 -0
- package/dist/chains/index.js +1 -1
- package/dist/chains/simple_sdlc.d.ts +2 -2
- package/dist/chains/simple_sdlc.js +13 -13
- package/dist/chains/steps.d.ts +2 -2
- package/dist/chains/steps.js +35 -19
- package/dist/cli/commands/abort.d.ts +1 -1
- package/dist/cli/commands/abort.js +30 -3
- package/dist/cli/commands/doctor.js +109 -8
- package/dist/cli/commands/estimate.js +3 -3
- package/dist/cli/commands/events.js +4 -4
- package/dist/cli/commands/fanout.js +93 -21
- package/dist/cli/commands/loop.js +31 -32
- package/dist/cli/commands/migrate.js +8 -1
- package/dist/cli/commands/phases.js +2 -2
- package/dist/cli/commands/sessions.js +2 -2
- package/dist/cli/commands/trace.d.ts +28 -8
- package/dist/cli/commands/trace.js +28 -15
- package/dist/cli/commands/ui.js +15 -5
- package/dist/cli/commands/watch.js +27 -27
- package/dist/cli/index.js +3 -1
- package/dist/cli/interview.d.ts +1 -0
- package/dist/cli/interview.js +86 -4
- package/dist/core/agent_opencode.d.ts +247 -0
- package/dist/core/agent_opencode.js +590 -0
- package/dist/core/agents.d.ts +12 -12
- package/dist/core/agents.js +113 -46
- package/dist/core/console.d.ts +12 -12
- package/dist/core/console.js +25 -25
- package/dist/core/data_types.d.ts +126 -12
- package/dist/core/data_types.js +101 -4
- package/dist/core/fanout.d.ts +1 -1
- package/dist/core/fanout.js +1 -1
- package/dist/core/gates.js +14 -1
- package/dist/core/paths.d.ts +41 -4
- package/dist/core/paths.js +32 -3
- package/dist/core/quality.d.ts +7 -7
- package/dist/core/quality.js +16 -10
- package/dist/core/runner.d.ts +9 -3
- package/dist/core/runner.js +39 -27
- package/dist/core/session.d.ts +2 -2
- package/dist/core/session.js +39 -18
- package/dist/core/sqlite.d.ts +14 -7
- package/dist/core/sqlite.js +14 -7
- package/dist/core/trace_db.d.ts +118 -0
- package/dist/core/trace_db.js +278 -0
- package/dist/core/tracer.d.ts +64 -34
- package/dist/core/tracer.js +141 -69
- package/dist/core/watch.d.ts +4 -4
- package/dist/core/watch.js +2 -2
- package/dist/ui/server/app.js +10 -10
- package/dist/ui/server/db.d.ts +89 -21
- package/dist/ui/server/db.js +235 -99
- package/dist/ui/server/serve.d.ts +5 -1
- package/dist/ui/server/serve.js +4 -5
- package/package.json +1 -1
- package/web/assets/index-CQ3k1Y1-.css +1 -0
- package/web/assets/index-CU8tom6S.js +21 -0
- package/web/index.html +2 -2
- package/web/assets/index-CRujNW-1.js +0 -11
- package/web/assets/index-Cto6nuQL.css +0 -1
package/dist/core/runner.d.ts
CHANGED
|
@@ -20,13 +20,19 @@ interface AgentMapEntry {
|
|
|
20
20
|
coding_agent: string;
|
|
21
21
|
}
|
|
22
22
|
export interface PhaseHandle {
|
|
23
|
-
log(payload: Record<string, unknown>): void
|
|
23
|
+
log(payload: Record<string, unknown>): Promise<void>;
|
|
24
24
|
call<T extends EnvelopeBase>(call: AgentCall<T>): Promise<T>;
|
|
25
25
|
}
|
|
26
26
|
export interface RunInit {
|
|
27
27
|
cfg: SFConfig;
|
|
28
28
|
adwId: string;
|
|
29
29
|
tracer: Tracer;
|
|
30
|
+
/**
|
|
31
|
+
* `tracer.maxPhaseSeq(adwId)`, resolved by the caller BEFORE constructing
|
|
32
|
+
* `Run` — a real trace-db read now (a network call for a D1-backed repo),
|
|
33
|
+
* and `Run`'s own constructor cannot be async. See `session.ts`'s `ensure()`.
|
|
34
|
+
*/
|
|
35
|
+
startSeq: number;
|
|
30
36
|
engineer: string;
|
|
31
37
|
/** Absolute. Resolved once, upstream, by paths.resolveAnchor(). */
|
|
32
38
|
repoRoot: string;
|
|
@@ -75,7 +81,7 @@ export declare class Run {
|
|
|
75
81
|
private agentMapPath;
|
|
76
82
|
constructor(init: RunInit);
|
|
77
83
|
saveAgentMap(agent: string, entry: AgentMapEntry): void;
|
|
78
|
-
addUsage(tokens: number, cost: number): void
|
|
84
|
+
addUsage(tokens: number, cost: number): Promise<void>;
|
|
79
85
|
phase<T>(params: PhaseParams, fn: (ph: PhaseHandle) => Promise<T>): Promise<T>;
|
|
80
86
|
/**
|
|
81
87
|
* Finalize the run and return its exit code. Call this exactly once.
|
|
@@ -86,6 +92,6 @@ export declare class Run {
|
|
|
86
92
|
* so the exit code, the session status, and the banner are decided
|
|
87
93
|
* together and cannot disagree.
|
|
88
94
|
*/
|
|
89
|
-
finish(accepted?: boolean, reason?: string): number
|
|
95
|
+
finish(accepted?: boolean, reason?: string): Promise<number>;
|
|
90
96
|
}
|
|
91
97
|
export {};
|
package/dist/core/runner.js
CHANGED
|
@@ -14,8 +14,20 @@ import * as agents from "./agents.js";
|
|
|
14
14
|
import { makeGit } from "./git_helper.js";
|
|
15
15
|
import { Console } from "./console.js";
|
|
16
16
|
import { Tracer } from "./tracer.js";
|
|
17
|
-
import { makeEventRecord } from "./data_types.js";
|
|
17
|
+
import { makeEventRecord, resolveObservabilityDb } from "./data_types.js";
|
|
18
18
|
import { ensureDir, nowIso } from "./utils.js";
|
|
19
|
+
/**
|
|
20
|
+
* `Console.sessionFinished`'s trailing `db` row wants a short, human-facing
|
|
21
|
+
* label, not a filesystem path specifically — `cfg.observability.db` is now
|
|
22
|
+
* a discriminated shape (see `data_types.ts`'s migration note), so this
|
|
23
|
+
* normalizes it to whichever of "the sqlite path" or "the D1 database id"
|
|
24
|
+
* is actually meaningful to show, rather than assuming it's always a path
|
|
25
|
+
* string the way this call site did before that field existed.
|
|
26
|
+
*/
|
|
27
|
+
function describeObservabilityDb(db) {
|
|
28
|
+
const resolved = resolveObservabilityDb(db);
|
|
29
|
+
return resolved.kind === "sqlite" ? resolved.path : `d1:${resolved.database_id}`;
|
|
30
|
+
}
|
|
19
31
|
class PhaseHandleImpl {
|
|
20
32
|
run;
|
|
21
33
|
phase;
|
|
@@ -23,17 +35,17 @@ class PhaseHandleImpl {
|
|
|
23
35
|
this.run = run;
|
|
24
36
|
this.phase = phase;
|
|
25
37
|
}
|
|
26
|
-
log(payload) {
|
|
27
|
-
this.run.tracer.event(makeEventRecord({
|
|
38
|
+
async log(payload) {
|
|
39
|
+
await this.run.tracer.event(makeEventRecord({
|
|
28
40
|
adw_id: this.run.adw_id,
|
|
29
41
|
phase_id: this.phase.phase_id,
|
|
30
42
|
type: "log",
|
|
31
43
|
name: this.phase.params.name,
|
|
32
44
|
payload,
|
|
33
45
|
}));
|
|
34
|
-
this.run.console.note(Object.entries(payload).map(([k, v]) => `${k}: ${v}`).join(", "));
|
|
46
|
+
await this.run.console.note(Object.entries(payload).map(([k, v]) => `${k}: ${v}`).join(", "));
|
|
35
47
|
if (this.phase.params.kind === "engineer" && "input" in payload) {
|
|
36
|
-
this.run.tracer.sessionRequest(this.run.adw_id, String(payload.input));
|
|
48
|
+
await this.run.tracer.sessionRequest(this.run.adw_id, String(payload.input));
|
|
37
49
|
}
|
|
38
50
|
}
|
|
39
51
|
async call(call) {
|
|
@@ -80,7 +92,7 @@ export class Run {
|
|
|
80
92
|
this.notify = init.notifier ?? null;
|
|
81
93
|
this.console = new Console(init.tracer, init.adwId, this.notify, init.chainName || "adw", init.sink, init.observer);
|
|
82
94
|
this.engineer = init.engineer;
|
|
83
|
-
this.seq = init.
|
|
95
|
+
this.seq = init.startSeq;
|
|
84
96
|
this.repo_root = init.repoRoot;
|
|
85
97
|
this.spf_dir = init.sfDir;
|
|
86
98
|
this.git = makeGit(init.repoRoot);
|
|
@@ -95,12 +107,12 @@ export class Run {
|
|
|
95
107
|
this.agent_map[agent] = entry;
|
|
96
108
|
writeFileSync(this.agentMapPath, JSON.stringify(this.agent_map, null, 2));
|
|
97
109
|
}
|
|
98
|
-
// ── usage (run totals mirror what the tracer accumulates in
|
|
99
|
-
addUsage(tokens, cost) {
|
|
110
|
+
// ── usage (run totals mirror what the tracer accumulates in the trace db) ─
|
|
111
|
+
async addUsage(tokens, cost) {
|
|
100
112
|
this.tokens += tokens;
|
|
101
113
|
this.cost += cost;
|
|
102
|
-
this.tracer.sessionAddUsage(this.adw_id, tokens, cost);
|
|
103
|
-
this.console.notifyUsage(this.tokens, this.cost);
|
|
114
|
+
await this.tracer.sessionAddUsage(this.adw_id, tokens, cost);
|
|
115
|
+
await this.console.notifyUsage(this.tokens, this.cost);
|
|
104
116
|
}
|
|
105
117
|
// ── the phase primitive ─────────────────────────────────────────────────
|
|
106
118
|
async phase(params, fn) {
|
|
@@ -117,35 +129,35 @@ export class Run {
|
|
|
117
129
|
ended_at: null,
|
|
118
130
|
};
|
|
119
131
|
this.phases.push(phase);
|
|
120
|
-
this.tracer.phaseUpsert(phase);
|
|
121
|
-
this.tracer.event(makeEventRecord({
|
|
132
|
+
await this.tracer.phaseUpsert(phase);
|
|
133
|
+
await this.tracer.event(makeEventRecord({
|
|
122
134
|
adw_id: this.adw_id,
|
|
123
135
|
phase_id: phase.phase_id,
|
|
124
136
|
type: "phase_start",
|
|
125
137
|
name: params.name,
|
|
126
138
|
payload: { kind: params.kind, owner: params.owner, description: params.description },
|
|
127
139
|
}));
|
|
128
|
-
this.console.phaseStarted(phase);
|
|
140
|
+
await this.console.phaseStarted(phase);
|
|
129
141
|
const clock = performance.now();
|
|
130
142
|
try {
|
|
131
143
|
const result = await fn(new PhaseHandleImpl(this, phase));
|
|
132
144
|
phase.status = "success";
|
|
133
145
|
phase.ended_at = nowIso();
|
|
134
|
-
this.tracer.event(makeEventRecord({ adw_id: this.adw_id, phase_id: phase.phase_id, type: "phase_end", name: params.name, payload: { status: "success" } }));
|
|
135
|
-
this.tracer.phaseUpsert(phase);
|
|
136
|
-
this.console.phaseEnded(phase, (performance.now() - clock) / 1000);
|
|
146
|
+
await this.tracer.event(makeEventRecord({ adw_id: this.adw_id, phase_id: phase.phase_id, type: "phase_end", name: params.name, payload: { status: "success" } }));
|
|
147
|
+
await this.tracer.phaseUpsert(phase);
|
|
148
|
+
await this.console.phaseEnded(phase, (performance.now() - clock) / 1000);
|
|
137
149
|
return result;
|
|
138
150
|
}
|
|
139
151
|
catch (error) {
|
|
140
152
|
phase.status = "fail"; // success must be earned
|
|
141
153
|
phase.error = String(error?.message ?? error).slice(0, 1000);
|
|
142
154
|
phase.ended_at = nowIso();
|
|
143
|
-
this.tracer.event(makeEventRecord({ adw_id: this.adw_id, phase_id: phase.phase_id, type: "error", name: params.name, payload: { error: phase.error } }));
|
|
144
|
-
this.tracer.event(makeEventRecord({ adw_id: this.adw_id, phase_id: phase.phase_id, type: "phase_end", name: params.name, payload: { status: "fail" } }));
|
|
145
|
-
this.tracer.phaseUpsert(phase);
|
|
146
|
-
this.tracer.sessionFinish(this.adw_id, false);
|
|
147
|
-
this.console.phaseEnded(phase, (performance.now() - clock) / 1000);
|
|
148
|
-
this.console.sessionFinished(false, this.tokens, this.cost, this.cfg.observability.db);
|
|
155
|
+
await this.tracer.event(makeEventRecord({ adw_id: this.adw_id, phase_id: phase.phase_id, type: "error", name: params.name, payload: { error: phase.error } }));
|
|
156
|
+
await this.tracer.event(makeEventRecord({ adw_id: this.adw_id, phase_id: phase.phase_id, type: "phase_end", name: params.name, payload: { status: "fail" } }));
|
|
157
|
+
await this.tracer.phaseUpsert(phase);
|
|
158
|
+
await this.tracer.sessionFinish(this.adw_id, false);
|
|
159
|
+
await this.console.phaseEnded(phase, (performance.now() - clock) / 1000);
|
|
160
|
+
await this.console.sessionFinished(false, this.tokens, this.cost, describeObservabilityDb(this.cfg.observability.db));
|
|
149
161
|
throw error;
|
|
150
162
|
}
|
|
151
163
|
}
|
|
@@ -158,22 +170,22 @@ export class Run {
|
|
|
158
170
|
* so the exit code, the session status, and the banner are decided
|
|
159
171
|
* together and cannot disagree.
|
|
160
172
|
*/
|
|
161
|
-
finish(accepted = true, reason = "") {
|
|
173
|
+
async finish(accepted = true, reason = "") {
|
|
162
174
|
const phasesOk = this.phases.length > 0 && this.phases.every((p) => p.status === "success");
|
|
163
175
|
const ok = phasesOk && accepted;
|
|
164
176
|
if (phasesOk && !accepted) {
|
|
165
177
|
const note = reason || "the run's acceptance criterion was not met";
|
|
166
|
-
this.tracer.event(makeEventRecord({
|
|
178
|
+
await this.tracer.event(makeEventRecord({
|
|
167
179
|
adw_id: this.adw_id,
|
|
168
180
|
phase_id: this.phases.length > 0 ? this.phases[this.phases.length - 1].phase_id : "",
|
|
169
181
|
type: "error",
|
|
170
182
|
name: "not_accepted",
|
|
171
183
|
payload: { reason: note },
|
|
172
184
|
}));
|
|
173
|
-
this.console.note(`not accepted: ${note}`);
|
|
185
|
+
await this.console.note(`not accepted: ${note}`);
|
|
174
186
|
}
|
|
175
|
-
this.tracer.sessionFinish(this.adw_id, ok);
|
|
176
|
-
this.console.sessionFinished(ok, this.tokens, this.cost, this.cfg.observability.db);
|
|
187
|
+
await this.tracer.sessionFinish(this.adw_id, ok);
|
|
188
|
+
await this.console.sessionFinished(ok, this.tokens, this.cost, describeObservabilityDb(this.cfg.observability.db));
|
|
177
189
|
return ok ? 0 : 1;
|
|
178
190
|
}
|
|
179
191
|
}
|
package/dist/core/session.d.ts
CHANGED
|
@@ -30,7 +30,7 @@ import type { SFConfig } from "./data_types.ts";
|
|
|
30
30
|
* `releaseOtelExporter`, and harmless for the same reason: that process
|
|
31
31
|
* exits right after anyway.
|
|
32
32
|
*/
|
|
33
|
-
export declare function finalize(adwId: string | null | undefined): void
|
|
33
|
+
export declare function finalize(adwId: string | null | undefined): Promise<void>;
|
|
34
34
|
/** Tests only: which adw_ids the process-wide signal handler currently considers active. */
|
|
35
35
|
export declare function activeRunIdsForTest(): string[];
|
|
36
36
|
/**
|
|
@@ -49,4 +49,4 @@ export declare function ensure(cfg: SFConfig, adwId?: string | null, cwd?: strin
|
|
|
49
49
|
renderHooks?: {
|
|
50
50
|
sink?: (line: string) => void;
|
|
51
51
|
observer?: RunObserver | null;
|
|
52
|
-
}): Run
|
|
52
|
+
}): Promise<Run>;
|
package/dist/core/session.js
CHANGED
|
@@ -66,24 +66,41 @@ function handleSignal(signal) {
|
|
|
66
66
|
process.exit(code);
|
|
67
67
|
draining = true;
|
|
68
68
|
const runs = [...ACTIVE.values()]; // snapshot: finalize() may mutate ACTIVE mid-drain
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
// `sessionFinish` is now async (see tracer.ts's header — a D1-backed run's
|
|
70
|
+
// write is a real network call) — a signal handler cannot itself be async,
|
|
71
|
+
// so this fires every run's finish without awaiting it directly. For the
|
|
72
|
+
// LOCAL backend that costs nothing: `LocalTraceDb` performs the write
|
|
73
|
+
// SYNCHRONOUSLY before ever returning the (already-resolved) Promise this
|
|
74
|
+
// discards, so by the time `sessionFinish(...)` returns here the write has
|
|
75
|
+
// already landed — same as before this class existed. Only a run whose
|
|
76
|
+
// `tracer.dbPath` is `null` (d1-backed — see `Tracer.open`) is genuinely
|
|
77
|
+
// still in flight at this point, so ONLY THEN does this add a bounded wait
|
|
78
|
+
// (same `SIGNAL_DRAIN_MS` budget the otel/notify drains already use) before
|
|
79
|
+
// exiting — a killed D1-backed run gets a real chance to land its final
|
|
80
|
+
// write, and every local-only repo keeps exiting with no extra tick.
|
|
81
|
+
const finishes = runs.map((run) => run.tracer.sessionFinish(run.adw_id, false).catch(() => { })); // also closes process rows
|
|
82
|
+
const anyRemote = runs.some((run) => run.tracer.dbPath === null);
|
|
71
83
|
const drains = [];
|
|
84
|
+
if (anyRemote)
|
|
85
|
+
drains.push(Promise.race([Promise.all(finishes), sleep(SIGNAL_DRAIN_MS)]).then(() => { }));
|
|
72
86
|
if (runs.some((run) => run.tracer.otel))
|
|
73
87
|
drains.push(otel.flushAll(SIGNAL_DRAIN_MS));
|
|
74
88
|
if (runs.some((run) => run.notify))
|
|
75
89
|
drains.push(drainNotifiers(SIGNAL_DRAIN_MS));
|
|
76
|
-
// Unconfigured (the default) exits SYNCHRONOUSLY, exactly as it
|
|
77
|
-
// otel/notify existed — no extra tick between the signal and
|
|
78
|
-
// the repos that never opted in to
|
|
90
|
+
// Unconfigured, local-only (the default) exits SYNCHRONOUSLY, exactly as it
|
|
91
|
+
// did before otel/notify/D1 existed — no extra tick between the signal and
|
|
92
|
+
// the exit for the repos that never opted in to any of the three.
|
|
79
93
|
if (drains.length === 0) {
|
|
80
94
|
process.exit(code);
|
|
81
95
|
return;
|
|
82
96
|
}
|
|
83
|
-
// Bounded and never-throwing:
|
|
84
|
-
//
|
|
97
|
+
// Bounded and never-throwing: every drain swallows its own failures and
|
|
98
|
+
// resolves on its own deadline, so this always reaches process.exit().
|
|
85
99
|
void Promise.all(drains).then(() => process.exit(code), () => process.exit(code));
|
|
86
100
|
}
|
|
101
|
+
function sleep(ms) {
|
|
102
|
+
return new Promise((resolve) => setTimeout(resolve, ms).unref());
|
|
103
|
+
}
|
|
87
104
|
function finalizeWhenKilled(run) {
|
|
88
105
|
ACTIVE.set(run.adw_id, run);
|
|
89
106
|
if (installed)
|
|
@@ -114,14 +131,14 @@ function finalizeWhenKilled(run) {
|
|
|
114
131
|
* `releaseOtelExporter`, and harmless for the same reason: that process
|
|
115
132
|
* exits right after anyway.
|
|
116
133
|
*/
|
|
117
|
-
export function finalize(adwId) {
|
|
134
|
+
export async function finalize(adwId) {
|
|
118
135
|
if (!adwId)
|
|
119
136
|
return;
|
|
120
137
|
const run = ACTIVE.get(adwId);
|
|
121
138
|
if (!run)
|
|
122
139
|
return;
|
|
123
140
|
ACTIVE.delete(adwId);
|
|
124
|
-
run.tracer.close();
|
|
141
|
+
await run.tracer.close();
|
|
125
142
|
}
|
|
126
143
|
/** Tests only: which adw_ids the process-wide signal handler currently considers active. */
|
|
127
144
|
export function activeRunIdsForTest() {
|
|
@@ -138,7 +155,7 @@ export function activeRunIdsForTest() {
|
|
|
138
155
|
* longer a `process.argv[1]` basename that means anything. Direct callers
|
|
139
156
|
* that have no chain of their own fall back to `"adw"`.
|
|
140
157
|
*/
|
|
141
|
-
export function ensure(cfg, adwId, cwd, chainName,
|
|
158
|
+
export async function ensure(cfg, adwId, cwd, chainName,
|
|
142
159
|
/** See `RunObserver`'s doc comment (`core/console.ts`). Omitted for every caller except an interactive `cli/commands/run.ts` dispatch — a `spf watch` per-issue run, `spf fanout`'s per-attempt runs, and every test all continue to build a plain, unobserved `Console`. */
|
|
143
160
|
renderHooks) {
|
|
144
161
|
const id = adwId || newId(8);
|
|
@@ -147,16 +164,20 @@ renderHooks) {
|
|
|
147
164
|
// `null` unless `observability.otel` is configured — no environment variable
|
|
148
165
|
// can turn this on (see core/otel.ts's EXPLICIT CONFIG ONLY). Constructed
|
|
149
166
|
// BEFORE the Tracer because the Tracer's write methods are the fan-out
|
|
150
|
-
// seams:
|
|
151
|
-
// registering here (module-level LIVE, exactly like
|
|
152
|
-
// lets the CLI's finally block and the signal
|
|
153
|
-
// threading a handle through every call site.
|
|
167
|
+
// seams: the trace db stays the source of truth, otel is a projection off
|
|
168
|
+
// it, and registering here (module-level LIVE, exactly like
|
|
169
|
+
// resolveNotifier) is what lets the CLI's finally block and the signal
|
|
170
|
+
// handler above drain it without threading a handle through every call site.
|
|
154
171
|
const otelExporter = otel.resolveOtelExporter(cfg, { adwId: id, chainName: chainName || "adw" });
|
|
155
|
-
const tracer =
|
|
172
|
+
const tracer = await Tracer.open(dataPaths.db, path.join(dataPaths.sessions_dir, id, "events.jsonl"), otelExporter);
|
|
173
|
+
// `maxPhaseSeq` is a real trace-db read now (D1: a network call) — resolved
|
|
174
|
+
// BEFORE `new Run(...)` because `Run`'s constructor cannot itself be async.
|
|
175
|
+
const startSeq = await tracer.maxPhaseSeq(id);
|
|
156
176
|
const run = new Run({
|
|
157
177
|
cfg,
|
|
158
178
|
adwId: id,
|
|
159
179
|
tracer,
|
|
180
|
+
startSeq,
|
|
160
181
|
engineer: engineerName(),
|
|
161
182
|
repoRoot: anchor.repo_root,
|
|
162
183
|
sfDir: anchor.spf_dir,
|
|
@@ -167,11 +188,11 @@ renderHooks) {
|
|
|
167
188
|
observer: renderHooks?.observer,
|
|
168
189
|
});
|
|
169
190
|
const scriptPath = process.argv[1] || "adw";
|
|
170
|
-
tracer.sessionStart(id, run.engineer, chainName || "adw");
|
|
191
|
+
await tracer.sessionStart(id, run.engineer, chainName || "adw");
|
|
171
192
|
// This process is the run. Record it before any phase opens, so a run that
|
|
172
193
|
// hangs in its first agent call is still killable by adw_id.
|
|
173
|
-
tracer.processStart(id, "adw", "", process.pid ?? -1, [path.basename(scriptPath), ...process.argv.slice(2)].join(" "));
|
|
194
|
+
await tracer.processStart(id, "adw", "", process.pid ?? -1, [path.basename(scriptPath), ...process.argv.slice(2)].join(" "));
|
|
174
195
|
finalizeWhenKilled(run);
|
|
175
|
-
run.console.sessionStarted(id, run.engineer);
|
|
196
|
+
await run.console.sessionStarted(id, run.engineer);
|
|
176
197
|
return run;
|
|
177
198
|
}
|
package/dist/core/sqlite.d.ts
CHANGED
|
@@ -25,13 +25,20 @@
|
|
|
25
25
|
* come back with a null prototype. Both are normalized below so existing
|
|
26
26
|
* `?? null` / `Object.assign(row, ...)` call sites stay honest.
|
|
27
27
|
* - node:sqlite defaults `enableForeignKeyConstraints: true` — unlike plain
|
|
28
|
-
* SQLite and bun:sqlite, which both default FK enforcement OFF
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
28
|
+
* SQLite and bun:sqlite, which both default FK enforcement OFF — so this
|
|
29
|
+
* is disabled explicitly to restore the behavior the rest of the
|
|
30
|
+
* codebase was written against. `tracer.ts`'s SCHEMA carries no
|
|
31
|
+
* `REFERENCES` clauses at all (removed for SPF #66: rows are not always
|
|
32
|
+
* inserted parent-before-child — e.g. the very first `events` row for a
|
|
33
|
+
* session lands before that session's own `sessions` row commits, and an
|
|
34
|
+
* event recorded outside any phase carries `phase_id: ""`, never NULL —
|
|
35
|
+
* and Cloudflare D1 enforces FKs UNCONDITIONALLY with no way to disable
|
|
36
|
+
* them, so a clause that was already only decorative on local sqlite
|
|
37
|
+
* would crash every D1 session outright). This setting is now a no-op
|
|
38
|
+
* for `tracer.ts`'s own tables specifically, but is kept here as this
|
|
39
|
+
* wrapper's general default — `cli/commands/abort.ts` and `migrate.ts`
|
|
40
|
+
* also open a plain `Database` against the same db file for their own
|
|
41
|
+
* ad-hoc queries, and neither has any reason to want FK enforcement on.
|
|
35
42
|
*/
|
|
36
43
|
export interface DatabaseOptions {
|
|
37
44
|
readonly?: boolean;
|
package/dist/core/sqlite.js
CHANGED
|
@@ -25,13 +25,20 @@
|
|
|
25
25
|
* come back with a null prototype. Both are normalized below so existing
|
|
26
26
|
* `?? null` / `Object.assign(row, ...)` call sites stay honest.
|
|
27
27
|
* - node:sqlite defaults `enableForeignKeyConstraints: true` — unlike plain
|
|
28
|
-
* SQLite and bun:sqlite, which both default FK enforcement OFF
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
28
|
+
* SQLite and bun:sqlite, which both default FK enforcement OFF — so this
|
|
29
|
+
* is disabled explicitly to restore the behavior the rest of the
|
|
30
|
+
* codebase was written against. `tracer.ts`'s SCHEMA carries no
|
|
31
|
+
* `REFERENCES` clauses at all (removed for SPF #66: rows are not always
|
|
32
|
+
* inserted parent-before-child — e.g. the very first `events` row for a
|
|
33
|
+
* session lands before that session's own `sessions` row commits, and an
|
|
34
|
+
* event recorded outside any phase carries `phase_id: ""`, never NULL —
|
|
35
|
+
* and Cloudflare D1 enforces FKs UNCONDITIONALLY with no way to disable
|
|
36
|
+
* them, so a clause that was already only decorative on local sqlite
|
|
37
|
+
* would crash every D1 session outright). This setting is now a no-op
|
|
38
|
+
* for `tracer.ts`'s own tables specifically, but is kept here as this
|
|
39
|
+
* wrapper's general default — `cli/commands/abort.ts` and `migrate.ts`
|
|
40
|
+
* also open a plain `Database` against the same db file for their own
|
|
41
|
+
* ad-hoc queries, and neither has any reason to want FK enforcement on.
|
|
35
42
|
*/
|
|
36
43
|
import { DatabaseSync } from "node:sqlite";
|
|
37
44
|
/**
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TraceDb: the async storage interface the trace database (`Tracer`'s writes,
|
|
3
|
+
* `SfDb`'s reads) is built against — one interface, two backends.
|
|
4
|
+
*
|
|
5
|
+
* WHY ASYNC, WHEN `core/sqlite.ts`'s `Database` IS SYNCHRONOUS: Cloudflare
|
|
6
|
+
* D1's only way to reach a database from a plain Node process (this CLI —
|
|
7
|
+
* not a Cloudflare Worker, which would get a real binding) is its HTTP REST
|
|
8
|
+
* API — a network call, inescapably async. Rather than fake synchrony over
|
|
9
|
+
* that (shelling out to curl, a busy-wait/sync-XHR trick), every caller
|
|
10
|
+
* upstream of storage (`Tracer`'s write methods, `SfDb`'s read methods) is
|
|
11
|
+
* async too — see those modules' own headers for how far that propagates.
|
|
12
|
+
* `LocalTraceDb` below pays that cost for nothing (every call still runs
|
|
13
|
+
* synchronously, immediately; only the return value is wrapped as a settled
|
|
14
|
+
* `Promise`) so the local path stays byte-for-byte the same behavior it
|
|
15
|
+
* always had, just awaited.
|
|
16
|
+
*
|
|
17
|
+
* `query(sql)` mirrors `core/sqlite.ts`'s `Statement` shape exactly, only
|
|
18
|
+
* every method returns a `Promise` — so a caller migrating from `Database`
|
|
19
|
+
* changes `.get(...)` to `await .get(...)` and nothing else.
|
|
20
|
+
*/
|
|
21
|
+
import { type DatabaseOptions } from "./sqlite.ts";
|
|
22
|
+
import type { NormalizedObservabilityDb } from "./data_types.ts";
|
|
23
|
+
export interface AsyncStatement<Row, Params extends unknown[]> {
|
|
24
|
+
get(...params: Params): Promise<Row | null>;
|
|
25
|
+
all(...params: Params): Promise<Row[]>;
|
|
26
|
+
run(...params: Params): Promise<{
|
|
27
|
+
changes: number;
|
|
28
|
+
lastInsertRowid: number | bigint;
|
|
29
|
+
}>;
|
|
30
|
+
}
|
|
31
|
+
export interface TraceDb {
|
|
32
|
+
query<Row = unknown, Params extends unknown[] = unknown[]>(sql: string): AsyncStatement<Row, Params>;
|
|
33
|
+
/** One or more `;`-separated statements — schema DDL and additive migrations. */
|
|
34
|
+
exec(sql: string): Promise<void>;
|
|
35
|
+
close(): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A thin async wrapper over the existing synchronous `Database` — every call
|
|
39
|
+
* is dispatched immediately, synchronously, and its result (or thrown error)
|
|
40
|
+
* is handed back as an already-settled `Promise` (via an `async` wrapper, so
|
|
41
|
+
* a synchronous throw becomes a rejection rather than escaping the `Promise`
|
|
42
|
+
* contract). Zero behavior change from `Database` itself; only the signature
|
|
43
|
+
* is async, so this backend and `D1TraceDb` satisfy the exact same `TraceDb`
|
|
44
|
+
* interface.
|
|
45
|
+
*
|
|
46
|
+
* `core/sqlite.ts` is deliberately left untouched — `cli/commands/abort.ts`
|
|
47
|
+
* and `cli/commands/migrate.ts` still open a plain `Database` directly for
|
|
48
|
+
* local-file-specific operations (marking a session aborted, physically
|
|
49
|
+
* relocating the db file) that have no D1 equivalent. See those files' own
|
|
50
|
+
* D1 guards.
|
|
51
|
+
*/
|
|
52
|
+
export declare class LocalTraceDb implements TraceDb {
|
|
53
|
+
private readonly db;
|
|
54
|
+
constructor(dbPath: string, options?: DatabaseOptions);
|
|
55
|
+
query<Row = unknown, Params extends unknown[] = unknown[]>(sql: string): AsyncStatement<Row, Params>;
|
|
56
|
+
exec(sql: string): Promise<void>;
|
|
57
|
+
close(): Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* D1 IS NOT LOCAL WAL SQLITE — DO NOT ASSUME THE SAME LIVE-READ GUARANTEE.
|
|
61
|
+
*
|
|
62
|
+
* `core/tracer.ts`'s header describes the local backend's contract: WAL mode
|
|
63
|
+
* lets `spf ui` read the trace db WHILE an ADW process is still writing to
|
|
64
|
+
* it, in the same instant, because both sides share one file on one
|
|
65
|
+
* filesystem. A D1-backed repo has no such single file to share — `spf ui`
|
|
66
|
+
* and a running chain each speak to D1 over independent HTTP calls, and (per
|
|
67
|
+
* Cloudflare's own docs, https://developers.cloudflare.com/d1/best-practices/read-replication/)
|
|
68
|
+
* a D1 database with read replication enabled offers SEQUENTIAL consistency,
|
|
69
|
+
* not read-your-own-writes by default: a read immediately after a write can
|
|
70
|
+
* land on a replica that has not caught up yet. D1's Sessions API closes that
|
|
71
|
+
* gap with a "bookmark" a caller can pin subsequent reads to, but this
|
|
72
|
+
* adapter does not use it (a bookmark is a client-scoped promise across ONE
|
|
73
|
+
* lightweight connection; a Tracer that mints a fresh request per write and
|
|
74
|
+
* an SfDb serving unrelated browser requests have no session to share it
|
|
75
|
+
* through). This is a deliberate, documented trade-off (SPF #66) — not a bug
|
|
76
|
+
* to fix here: a D1-backed repo's UI may briefly show a slightly-stale trace
|
|
77
|
+
* while a chain is actively writing. Nothing about phase/gate/run OUTCOMES
|
|
78
|
+
* depends on that live read — see tracer.ts's header, "SQLite is the source
|
|
79
|
+
* of truth; nothing downstream of it can affect a phase, a gate, or a run
|
|
80
|
+
* outcome" holds exactly the same way against D1.
|
|
81
|
+
*/
|
|
82
|
+
export declare class D1TraceDb implements TraceDb {
|
|
83
|
+
private readonly accountId;
|
|
84
|
+
private readonly apiToken;
|
|
85
|
+
private readonly databaseId;
|
|
86
|
+
private readonly fetchImpl;
|
|
87
|
+
private readonly baseUrl;
|
|
88
|
+
constructor(config: Extract<NormalizedObservabilityDb, {
|
|
89
|
+
kind: "d1";
|
|
90
|
+
}>,
|
|
91
|
+
/** Injectable so tests never touch the real network — defaults to the global `fetch`. */
|
|
92
|
+
fetchImpl?: typeof fetch);
|
|
93
|
+
query<Row = unknown, Params extends unknown[] = unknown[]>(sql: string): AsyncStatement<Row, Params>;
|
|
94
|
+
/**
|
|
95
|
+
* D1's HTTP `/query` endpoint accepts exactly one statement per `sql`
|
|
96
|
+
* field (see the Workers binding's `prepare()`/`batch()` split — the HTTP
|
|
97
|
+
* endpoint's single-request shape mirrors `prepare()`, not `exec()`);
|
|
98
|
+
* `exec()` here — schema DDL and additive `ALTER TABLE` migrations, always
|
|
99
|
+
* multiple statements — splits the text into individual statements and
|
|
100
|
+
* sends them as one `batch` request instead, which D1 documents as
|
|
101
|
+
* running sequentially and atomically. The split is naive (`;` at
|
|
102
|
+
* statement end, one statement per line-ish chunk) because this only ever
|
|
103
|
+
* runs against `tracer.ts`'s own hand-written `SCHEMA`/`MIGRATIONS`
|
|
104
|
+
* constants — never arbitrary or user-supplied SQL.
|
|
105
|
+
*/
|
|
106
|
+
exec(sql: string): Promise<void>;
|
|
107
|
+
close(): Promise<void>;
|
|
108
|
+
private runOne;
|
|
109
|
+
private post;
|
|
110
|
+
}
|
|
111
|
+
export interface CreateTraceDbOptions {
|
|
112
|
+
/** Local (`sqlite`) only — opens the connection read-only, matching `SfDb`'s reader. */
|
|
113
|
+
readonly?: boolean;
|
|
114
|
+
/** D1 only — injectable for tests. Defaults to the global `fetch`. */
|
|
115
|
+
fetchImpl?: typeof fetch;
|
|
116
|
+
}
|
|
117
|
+
/** The one place `Tracer` and `SfDb` both go to get the right backend for `observability.db`'s resolved kind. */
|
|
118
|
+
export declare function createTraceDb(resolved: NormalizedObservabilityDb, options?: CreateTraceDbOptions): TraceDb;
|