@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.
Files changed (64) hide show
  1. package/README.md +15 -5
  2. package/assets/skill/references/config.md +9 -5
  3. package/assets/skill/references/observability.md +57 -12
  4. package/assets/templates/ts-opencode.spf.config.yaml +54 -0
  5. package/dist/chains/index.js +1 -1
  6. package/dist/chains/simple_sdlc.d.ts +2 -2
  7. package/dist/chains/simple_sdlc.js +13 -13
  8. package/dist/chains/steps.d.ts +2 -2
  9. package/dist/chains/steps.js +35 -19
  10. package/dist/cli/commands/abort.d.ts +1 -1
  11. package/dist/cli/commands/abort.js +30 -3
  12. package/dist/cli/commands/doctor.js +109 -8
  13. package/dist/cli/commands/estimate.js +3 -3
  14. package/dist/cli/commands/events.js +4 -4
  15. package/dist/cli/commands/fanout.js +93 -21
  16. package/dist/cli/commands/loop.js +31 -32
  17. package/dist/cli/commands/migrate.js +8 -1
  18. package/dist/cli/commands/phases.js +2 -2
  19. package/dist/cli/commands/sessions.js +2 -2
  20. package/dist/cli/commands/trace.d.ts +28 -8
  21. package/dist/cli/commands/trace.js +28 -15
  22. package/dist/cli/commands/ui.js +15 -5
  23. package/dist/cli/commands/watch.js +27 -27
  24. package/dist/cli/index.js +3 -1
  25. package/dist/cli/interview.d.ts +1 -0
  26. package/dist/cli/interview.js +86 -4
  27. package/dist/core/agent_opencode.d.ts +247 -0
  28. package/dist/core/agent_opencode.js +590 -0
  29. package/dist/core/agents.d.ts +12 -12
  30. package/dist/core/agents.js +113 -46
  31. package/dist/core/console.d.ts +12 -12
  32. package/dist/core/console.js +25 -25
  33. package/dist/core/data_types.d.ts +126 -12
  34. package/dist/core/data_types.js +101 -4
  35. package/dist/core/fanout.d.ts +1 -1
  36. package/dist/core/fanout.js +1 -1
  37. package/dist/core/gates.js +14 -1
  38. package/dist/core/paths.d.ts +41 -4
  39. package/dist/core/paths.js +32 -3
  40. package/dist/core/quality.d.ts +7 -7
  41. package/dist/core/quality.js +16 -10
  42. package/dist/core/runner.d.ts +9 -3
  43. package/dist/core/runner.js +39 -27
  44. package/dist/core/session.d.ts +2 -2
  45. package/dist/core/session.js +39 -18
  46. package/dist/core/sqlite.d.ts +14 -7
  47. package/dist/core/sqlite.js +14 -7
  48. package/dist/core/trace_db.d.ts +118 -0
  49. package/dist/core/trace_db.js +278 -0
  50. package/dist/core/tracer.d.ts +64 -34
  51. package/dist/core/tracer.js +141 -69
  52. package/dist/core/watch.d.ts +4 -4
  53. package/dist/core/watch.js +2 -2
  54. package/dist/ui/server/app.js +10 -10
  55. package/dist/ui/server/db.d.ts +89 -21
  56. package/dist/ui/server/db.js +235 -99
  57. package/dist/ui/server/serve.d.ts +5 -1
  58. package/dist/ui/server/serve.js +4 -5
  59. package/package.json +1 -1
  60. package/web/assets/index-CQ3k1Y1-.css +1 -0
  61. package/web/assets/index-CU8tom6S.js +21 -0
  62. package/web/index.html +2 -2
  63. package/web/assets/index-CRujNW-1.js +0 -11
  64. package/web/assets/index-Cto6nuQL.css +0 -1
@@ -8,11 +8,13 @@
8
8
  */
9
9
  import { existsSync, statSync } from "node:fs";
10
10
  import { spawnSync } from "node:child_process";
11
+ import { homedir } from "node:os";
11
12
  import path from "node:path";
12
13
  import * as agents from "../../core/agents.js";
13
14
  import * as paths from "../../core/paths.js";
14
15
  import * as permissions from "../../core/permissions.js";
15
16
  import * as agentCc from "../../core/agent_cc.js";
17
+ import * as agentOpencode from "../../core/agent_opencode.js";
16
18
  import { DEFAULT_NOTIFY_ENV_KEY } from "../../core/notify/notifier.js";
17
19
  import { endpointLabel, redact, resolveTracesUrl } from "../../core/otel.js";
18
20
  import { isKnownToolName as isKnownFlueToolName, resolveModel } from "../../core/agent_flue.js";
@@ -28,6 +30,7 @@ import * as sandbox from "../../core/sandbox.js";
28
30
  import { loadOpenSandboxSdk } from "../../core/sandbox_opensandbox.js";
29
31
  import { isInteractive } from "../ask.js";
30
32
  import { paint as paintPlain } from "../../core/console.js";
33
+ import { SfDb } from "../../ui/server/db.js";
31
34
  /**
32
35
  * A transient "checking X..." status line around a network probe — real
33
36
  * work only starts inside `run()`; this doesn't touch the promise's timing.
@@ -283,7 +286,54 @@ export async function doctorCommand(argv) {
283
286
  }
284
287
  const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
285
288
  check(report, "data_dir", true, dataPaths.data_dir);
286
- check(report, "db_path", true, `${dataPaths.db_path}${existsSync(dataPaths.db_path) ? "" : " (not created yet fine before the first run)"}`);
289
+ // `db_path` is `null` for a `kind: "d1"` observability.dbthere is no
290
+ // local file to check for existence; `spf doctor`'s own D1 reachability
291
+ // probe (account id / API token / database id) is PR 3's job (SPF #66).
292
+ check(report, "db_path", true, dataPaths.db_path
293
+ ? `${dataPaths.db_path}${existsSync(dataPaths.db_path) ? "" : " (not created yet — fine before the first run)"}`
294
+ : `d1 database_id=${JSON.stringify(dataPaths.db.kind === "d1" ? dataPaths.db.database_id : "")} (remote — no local file)`);
295
+ // D1 trace db — only when observability.db resolves to a remote Cloudflare
296
+ // D1 database (PR 1's resolveObservabilityDb / PR 2's D1TraceDb, both
297
+ // already folded into `dataPaths.db` by `paths.resolveDataPaths` above).
298
+ // Same two-step shape as every other Cloudflare probe in this file (Workers
299
+ // AI above, sandbox.cloudflare below): a static config-shape check first —
300
+ // the account/token env vars actually need to be SET before there's
301
+ // anything to probe — then a separate, NEVER-hard-failing reachability
302
+ // check, gated on --no-probe like every other live probe here.
303
+ if (dataPaths.db.kind === "d1") {
304
+ const { account_id_env, api_token_env, database_id } = dataPaths.db;
305
+ const missingEnv = [account_id_env, api_token_env].filter((k) => !process.env[k]);
306
+ check(report, "D1 trace db credentials", true, // informational/warning only — same never-a-hard-failure contract as the Cloudflare Workers AI endpoint check above
307
+ missingEnv.length === 0
308
+ ? `${account_id_env} and ${api_token_env} are set`
309
+ : `${missingEnv.join(", ")} not set — required for observability.db kind: "d1"`, missingEnv.length === 0 ? undefined : "warn");
310
+ if (missingEnv.length === 0 && !flags["no-probe"]) {
311
+ // A cheap, read-only "does a sessions table exist yet" check —
312
+ // `SfDb.exists()` (PR 2), never a full `Tracer`/write. Mirrors
313
+ // `SfDb.open()`'s own friendly-error probe but collapses "nothing
314
+ // written yet" to a soft finding instead of throwing.
315
+ const probe = await withProbeStatus("D1 trace db reachability", async () => {
316
+ try {
317
+ return { ok: true, exists: await SfDb.exists(dataPaths) };
318
+ }
319
+ catch (error) {
320
+ return { ok: false, error: error.message };
321
+ }
322
+ });
323
+ check(report, "D1 trace db reachability", true, // informational/warning only — same contract as every other reachability probe in this file
324
+ probe.ok
325
+ ? probe.exists
326
+ ? `reachable: D1 database ${database_id} has a "sessions" table`
327
+ : `reachable: D1 database ${database_id} has no "sessions" table yet (fine before the first run)`
328
+ : `unreachable or errored: ${probe.error}`,
329
+ // "warn" only when the request itself failed (unreachable, bad
330
+ // credentials, wrong database_id) — that's the case an operator
331
+ // needs to act on. A successful request that simply finds no
332
+ // "sessions" table yet is the healthy fresh-database state (same
333
+ // as the local `db_path` check above), so it's "info", not "warn".
334
+ probe.ok ? "info" : "warn");
335
+ }
336
+ }
287
337
  check(report, "flue_db_path", true, path.join(dataPaths.data_dir, "flue.db"));
288
338
  // Validate the WHOLE roster and EVERY declared suite — doctor's job is "is
289
339
  // everything defined here healthy", not "can one specific chain run".
@@ -366,6 +416,57 @@ export async function doctorCommand(argv) {
366
416
  " (this is a real, billable inference request — pass --no-probe to skip both network probes)", result.ok ? "info" : "warn");
367
417
  }
368
418
  }
419
+ const usesOpencode = cfg.agents.some((a) => a.coding_agent === "opencode");
420
+ if (usesOpencode) {
421
+ // Mirrors the "claude CLI" check above — SPF_OPENCODE_CMD can point at a
422
+ // wrapper/launcher instead of the literal `opencode` binary (see
423
+ // agent_opencode.ts's module comment), so check whatever
424
+ // agent_opencode.ts will actually spawn: cmdSpec's first token.
425
+ const cmdSpec = process.env["SPF_OPENCODE_CMD"] || "opencode";
426
+ const cmdTokens = cmdSpec.split(/\s+/).filter(Boolean);
427
+ const cmdBin = cmdTokens[0] || "opencode";
428
+ const opencodeOnPath = binaryOnPath(cmdBin);
429
+ let version = "";
430
+ if (opencodeOnPath && cmdBin === "opencode") {
431
+ const result = spawnSync("opencode", ["--version"], { encoding: "utf-8" });
432
+ version = result.status === 0 ? result.stdout.trim() : "";
433
+ }
434
+ check(report, "opencode CLI", opencodeOnPath, opencodeOnPath
435
+ ? version || (cmdBin === "opencode" ? "on PATH, but --version failed" : `"${cmdBin}" on PATH (via SPF_OPENCODE_CMD) — --version not checked for a wrapper/launcher`)
436
+ : `"${cmdBin}" not found on PATH — required by any coding_agent: opencode agent${cmdBin !== "opencode" ? " (checked SPF_OPENCODE_CMD's first token, not the literal \"opencode\")" : ""}`);
437
+ // Auth is treated as ALREADY CONFIGURED territory here, not something
438
+ // doctor drives interactively (no `opencode auth login` flow) — per
439
+ // opencode's own docs, ~/.local/share/opencode/auth.json is the
440
+ // credential store and `opencode auth list` is the documented
441
+ // non-interactive check. Informational only (`ok: true` regardless),
442
+ // same "fine if authenticated another way" contract as the
443
+ // ANTHROPIC_API_KEY check above — and it also defends against a known
444
+ // upstream race condition that can write an empty (0-byte) token file,
445
+ // which existsSync alone would miss.
446
+ // `~/.local/share` is only the XDG_DATA_HOME *default* — an operator
447
+ // with that env var set (routine on Linux) has opencode's credential
448
+ // store elsewhere; honoring it here avoids a false "not authenticated"
449
+ // warning that `opencode auth login` (writing to the SAME XDG location)
450
+ // would never actually clear.
451
+ const dataHome = process.env["XDG_DATA_HOME"] || path.join(homedir(), ".local", "share");
452
+ const authPath = path.join(dataHome, "opencode", "auth.json");
453
+ let authDetail;
454
+ let authWarn = true;
455
+ if (!existsSync(authPath)) {
456
+ authDetail = `${authPath} not found — run \`opencode auth login\` (or set the provider's own env var) before running spf`;
457
+ }
458
+ else {
459
+ const size = statSync(authPath).size;
460
+ if (size > 0) {
461
+ authDetail = `${authPath} present (${size} bytes)`;
462
+ authWarn = false;
463
+ }
464
+ else {
465
+ authDetail = `${authPath} exists but is empty — a known opencode upstream race can write an empty token file; re-run \`opencode auth login\``;
466
+ }
467
+ }
468
+ check(report, "opencode auth", true, authDetail, authWarn ? "warn" : "info");
469
+ }
369
470
  // Flue agents pointed at a local Ollama server (`model: ollama/...`) have
370
471
  // no API key to check (providers.ts's PROVIDER_ENV_KEYS.ollama is `[]`,
371
472
  // handled in the per-agent loop below) but DO have a server that might
@@ -454,7 +555,7 @@ export async function doctorCommand(argv) {
454
555
  check(report, `${label} model`, false, error.message);
455
556
  }
456
557
  }
457
- const isKnownToolName = agent.coding_agent === "claude_code" ? agentCc.isKnownToolName : isKnownFlueToolName;
558
+ const isKnownToolName = agent.coding_agent === "claude_code" ? agentCc.isKnownToolName : agent.coding_agent === "opencode" ? agentOpencode.isKnownToolName : isKnownFlueToolName;
458
559
  for (const toolName of agent.tools ?? []) {
459
560
  if (!isKnownToolName(toolName))
460
561
  check(report, `${label} tool "${toolName}"`, false, "not a known tool name");
@@ -750,14 +851,14 @@ export async function doctorCommand(argv) {
750
851
  const live = sandbox.leases();
751
852
  check(report, "sandbox live leases", true, `${live.length} in THIS process (the lease journal at <data_dir>/sandboxes.json is not implemented in this build — cross-process/orphan visibility via \`spf sandbox list\` is not yet available)`, "info");
752
853
  }
753
- // #12 — claude_code x remote backend, hard ✗, roster-wide (validate()
754
- // above is chain-scoped to whatever `required` it was called with —
755
- // doctor happens to pass the whole roster there too, but this is named
756
- // separately so the specific rule that failed has its own line).
854
+ // #12 — claude_code/opencode x remote backend, hard ✗, roster-wide
855
+ // (validate() above is chain-scoped to whatever `required` it was called
856
+ // with — doctor happens to pass the whole roster there too, but this is
857
+ // named separately so the specific rule that failed has its own line).
757
858
  for (const agent of cfg.agents) {
758
859
  const backend = resolvedBackend(agent);
759
- if (agent.coding_agent === "claude_code" && backend !== "local") {
760
- check(report, `agent "${agent.name}" coding_agent x sandbox`, false, `coding_agent "claude_code" cannot use sandbox backend ${JSON.stringify(backend)} — it always spawns a host process with no sandbox seam; set agent.sandbox: local (or sandbox.backend: local) for this agent`);
860
+ if ((agent.coding_agent === "claude_code" || agent.coding_agent === "opencode") && backend !== "local") {
861
+ check(report, `agent "${agent.name}" coding_agent x sandbox`, false, `coding_agent ${JSON.stringify(agent.coding_agent)} cannot use sandbox backend ${JSON.stringify(backend)} — it always spawns a host process with no sandbox seam; set agent.sandbox: local (or sandbox.backend: local) for this agent`);
761
862
  }
762
863
  }
763
864
  // #13 — image set, and able to run the transport. Hard ✗ only when
@@ -173,7 +173,7 @@ export async function estimateCommand(argv) {
173
173
  return 1;
174
174
  }
175
175
  const prompt = resolvePrompt(promptArg);
176
- const trace = openTraceIfExists(options);
176
+ const trace = await openTraceIfExists(options);
177
177
  const cfg = trace.cfg;
178
178
  // Same shape as run.ts:54-56 — conditional insertion, never `{agent: flags["agent"] ?? ""}`.
179
179
  // `flags` never carries a value-taking option, and an empty-string sentinel
@@ -195,7 +195,7 @@ export async function estimateCommand(argv) {
195
195
  catch (error) {
196
196
  validateError = error instanceof Error ? error.message : String(error);
197
197
  }
198
- const history = trace.db ? trace.db.chainPhaseHistory(chain.name) : { sessions: [], joinedExcluded: 0 };
198
+ const history = trace.db ? await trace.db.chainPhaseHistory(chain.name) : { sessions: [], joinedExcluded: 0 };
199
199
  const sample = selectSample(history.sessions, history.joinedExcluded);
200
200
  const coldStart = sample.sessions.length === 0;
201
201
  const phases = aggregatePhases(sample.sessions);
@@ -205,7 +205,7 @@ export async function estimateCommand(argv) {
205
205
  const historicalModels = new Map();
206
206
  if (trace.db) {
207
207
  for (const session of sample.sessions) {
208
- for (const row of trace.db.agentSessions(session.adw_id)) {
208
+ for (const row of await trace.db.agentSessions(session.adw_id)) {
209
209
  if (row.model !== null && !historicalModels.has(row.agent))
210
210
  historicalModels.set(row.agent, row.model);
211
211
  }
@@ -20,9 +20,9 @@ export async function eventsCommand(argv) {
20
20
  return 1;
21
21
  }
22
22
  const adwId = positionals[0];
23
- const { db } = openTrace(options);
23
+ const { db } = await openTrace(options);
24
24
  let after = options["after"] ? Number.parseInt(options["after"], 10) : 0;
25
- const page = db.events(adwId, after, options["limit"] ? Number.parseInt(options["limit"], 10) : 500);
25
+ const page = await db.events(adwId, after, options["limit"] ? Number.parseInt(options["limit"], 10) : 500);
26
26
  if (flags["json"] && !flags["follow"]) {
27
27
  console.log(JSON.stringify(page.events, null, 2));
28
28
  return 0;
@@ -35,8 +35,8 @@ export async function eventsCommand(argv) {
35
35
  console.error(`-- following ${adwId}; ^C to stop --`);
36
36
  for (;;) {
37
37
  await sleep(500);
38
- const session = db.session(adwId);
39
- const next = db.events(adwId, after, 500);
38
+ const session = await db.session(adwId);
39
+ const next = await db.events(adwId, after, 500);
40
40
  for (const e of next.events)
41
41
  printEvent(e);
42
42
  if (next.events.length > 0)
@@ -266,10 +266,11 @@ export async function fanoutCommand(argv) {
266
266
  // `sessionAddUsage` ACCUMULATES onto it — so a collision would silently mix
267
267
  // the previous run's cost/tokens/gate rows into this run's selection
268
268
  // basis, and the human reading `basis:` would be told a wrong reason.
269
- if (existsSync(dataPaths.db_path)) {
270
- const preflight = new SfDb(dataPaths.db_path);
269
+ if (await SfDb.exists(dataPaths)) {
270
+ const preflight = await SfDb.open(dataPaths.db, dataPaths.sessions_dir);
271
271
  try {
272
- const collisions = Array.from({ length: n }, (_, i) => attemptAdwId(baseAdwId, i + 1)).filter((id) => preflight.session(id) !== null);
272
+ const collisionChecks = await Promise.all(Array.from({ length: n }, (_, i) => attemptAdwId(baseAdwId, i + 1)).map(async (id) => ((await preflight.session(id)) !== null ? id : null)));
273
+ const collisions = collisionChecks.filter((id) => id !== null);
273
274
  if (collisions.length > 0) {
274
275
  console.error(`--adw-id ${baseAdwId} already has session rows for ${collisions.join(", ")} from a previous fanout ` +
275
276
  `run — reusing it would mix that run's cost/tokens/gates into this one's selection basis. Use a ` +
@@ -279,7 +280,7 @@ export async function fanoutCommand(argv) {
279
280
  }
280
281
  }
281
282
  finally {
282
- preflight.close();
283
+ await preflight.close();
283
284
  }
284
285
  }
285
286
  if (cfg.defaults.max_run_cost === undefined && cfg.defaults.max_run_tokens === undefined) {
@@ -292,24 +293,82 @@ export async function fanoutCommand(argv) {
292
293
  }
293
294
  const linkDataDir = (worktreePath) => linkFanoutDataDir(worktreePath, dataPaths.data_dir);
294
295
  /**
295
- * The shared db, opened lazily and once: it does not exist until the first
296
- * attempt's tracer creates it, and `SfDb`'s constructor throws on a missing
297
- * file. Readonly + WAL, so reading one finished attempt's rows while its
298
- * siblings are still writing is exactly the access pattern the tracer's
299
- * PRAGMAs are set up for.
296
+ * The shared db, opened lazily: it does not exist until the first
297
+ * attempt's tracer creates it, and `SfDb.open` throws on a repo whose db
298
+ * has never been written to. Readonly + WAL, so reading one finished
299
+ * attempt's rows while its siblings are still writing is exactly the
300
+ * access pattern the tracer's PRAGMAs are set up for.
301
+ *
302
+ * The IN-FLIGHT PROMISE is memoized, not the resolved value: `core/
303
+ * fanout.ts` runs `concurrency` workers in parallel, each calling
304
+ * `readMetrics` independently, so a bare "check `held.db`, then `await`
305
+ * open, then assign" would let two workers both observe `held.db` unset,
306
+ * both open a connection, and orphan one unclosed `SfDb` handle. Every
307
+ * concurrent caller instead awaits this SAME promise, so at most one
308
+ * `SfDb.exists`/`SfDb.open` pair ever runs at a time.
309
+ *
310
+ * Memoized ONLY while pending or successful, though — a rejected open (a
311
+ * transient D1 network/auth blip) clears the memo (`held.opening = null`
312
+ * in `ensureDb`'s `.catch()` below) rather than caching the failure
313
+ * forever. Without that, one blip on attempt 1 would leave every later
314
+ * attempt reading `ZERO_METRICS` for the rest of the run, degrading the
315
+ * whole fanout's ranking over a single transient error instead of just
316
+ * the one attempt that hit it.
317
+ *
318
+ * The same reasoning applies to `SfDb.exists` resolving `false`: "not
319
+ * created yet" is a snapshot, not a permanent fact — a sibling attempt
320
+ * still mid-`session.ensure` can create the db moments later. HEAD's
321
+ * `existsSync` re-checked on every call; memoizing a `false` verdict
322
+ * across this run's whole lifetime would silently zero out every later
323
+ * attempt's real metrics once the db does show up, so `ensureDb` clears
324
+ * the memo on a `false` verdict too, not just on rejection.
300
325
  */
301
- // A holder rather than a bare `let`: assigned only inside the closure below,
302
- // a plain local narrows to `null` everywhere else and the `finally`'s
303
- // `close()` stops typechecking.
304
- const held = { db: null };
305
- function readMetrics(adwId) {
306
- if (!held.db) {
307
- if (!existsSync(dataPaths.db_path))
308
- return ZERO_METRICS;
309
- held.db = new SfDb(dataPaths.db_path);
326
+ // A holder object rather than a bare `let`: with a bare `let db: SfDb |
327
+ // null = null` reassigned only inside `ensureDb`'s nested async closure,
328
+ // TypeScript cannot track that reassignment across the closure boundary —
329
+ // at the outer `finally`'s `db?.close()` read site (below), it infers
330
+ // `db`'s type as `never` (narrowed from the closure never having run, as
331
+ // far as the checker can tell at that point) and refuses to compile:
332
+ // "Property 'close' does not exist on type 'never'". Routing the same
333
+ // mutable cell through a holder object's property sidesteps that —
334
+ // `held.db` is never narrowed to a literal `null` the way a bare `let`
335
+ // is, so the read site stays typed `SfDb | null` throughout. `opening`
336
+ // rides the same holder for the same reason: it too is reassigned inside
337
+ // `ensureDb`'s `.catch()` closure (to clear the memo — see below), and a
338
+ // bare `let` there trips the identical `never`-narrowing bug at the
339
+ // outer `finally`'s own read of it.
340
+ const held = { db: null, opening: null };
341
+ function ensureDb() {
342
+ if (!held.opening) {
343
+ held.opening = (async () => {
344
+ if (await SfDb.exists(dataPaths)) {
345
+ held.db = await SfDb.open(dataPaths.db, dataPaths.sessions_dir);
346
+ }
347
+ else {
348
+ held.opening = null;
349
+ }
350
+ })().catch((error) => {
351
+ // Do not let one transient failure (a D1 network/auth blip) poison
352
+ // every subsequent attempt's metrics for the rest of this run: clear
353
+ // the memo so the NEXT caller gets a fresh open attempt instead of
354
+ // permanently reusing this rejected promise. The rejection itself
355
+ // still propagates to whichever concurrent callers are already
356
+ // awaiting this exact promise object (reassigning `held.opening`
357
+ // here does not change what an already-returned promise resolves
358
+ // to) — `readMetrics` above (and its caller in `core/fanout.ts`)
359
+ // already catches that and ranks the attempt on zero metrics.
360
+ held.opening = null;
361
+ throw error;
362
+ });
310
363
  }
311
- const gates = held.db.gates(adwId);
312
- const session = held.db.session(adwId);
364
+ return held.opening;
365
+ }
366
+ async function readMetrics(adwId) {
367
+ await ensureDb();
368
+ if (!held.db)
369
+ return ZERO_METRICS;
370
+ const gates = await held.db.gates(adwId);
371
+ const session = await held.db.session(adwId);
313
372
  // `passed` is a SQLite integer boolean that CAN be NULL on a row an older
314
373
  // tracer wrote. Counted explicitly in both directions, never as
315
374
  // `!g.passed`: a NULL is unknown, and letting it read as a failure would
@@ -444,7 +503,20 @@ export async function fanoutCommand(argv) {
444
503
  return 0;
445
504
  }
446
505
  finally {
447
- held.db?.close();
506
+ // `.catch(() => {})`, not a bare `await`: `held.opening` is a settled
507
+ // promise `readMetrics` (via `ensureDb`) may have already awaited and
508
+ // handled upstream (`core/fanout.ts` catches a `readMetrics` rejection,
509
+ // logs it, and ranks that attempt on zero metrics) — re-awaiting the
510
+ // SAME rejected promise here a second time would throw straight out of
511
+ // this `finally`, turning a completed, winner-printed `spf fanout` run
512
+ // into a nonzero exit, and would also skip `held.db?.close()` and the
513
+ // `process.off` calls below (a `finally` that throws never reaches its
514
+ // own remaining statements). Nothing here needs the resolved value or
515
+ // the rejection reason — only that the open attempt has settled before
516
+ // deciding whether there's a `held.db` to close.
517
+ if (held.opening)
518
+ await held.opening.catch(() => { });
519
+ await held.db?.close();
448
520
  process.off("SIGINT", onSignal);
449
521
  process.off("SIGTERM", onSignal);
450
522
  }
@@ -1,23 +1,3 @@
1
- /**
2
- * `spf loop <chain> "<goal>" --until-suite <name> --max N` — the wiring half
3
- * of `core/loop.ts` (which owns the driver, the ledger, and the breakers):
4
- * config, the issue fetch for `--issue`, the per-iteration `runChain`
5
- * dispatch, the sqlite readback, and the final table. Same split as
6
- * `core/fanout.ts` / `cli/commands/fanout.ts` for the same reason — `core/`
7
- * stays out of `src/chains/`'s dependency direction, so the driver is
8
- * testable with no chains, no agents, no real git.
9
- *
10
- * `--issue <id>` resolves the SAME `IssueProvider` `spf watch` does
11
- * (`resolveIssueProvider`, exported from `watch.ts` for exactly this reuse)
12
- * and builds the goal prompt the same way `core/watch.ts`'s `runIssue` does
13
- * — `${issue.title}\n\n${issue.body}`.trim() — so a ticket-pointed loop and
14
- * a hand-typed one share one prompt convention. This is deliberately the
15
- * ONLY thing `--issue` does: it does not claim, label, or comment on the
16
- * issue, and no daemon watches it. Filing the goal AS a ticket the daemon
17
- * picks up on its own is a different, larger feature (a `goal-ready` watch
18
- * lane) that needs its own crash-safe marker schema and is not this.
19
- */
20
- import { existsSync } from "node:fs";
21
1
  import * as agents from "../../core/agents.js";
22
2
  import * as paths from "../../core/paths.js";
23
3
  import * as quality from "../../core/quality.js";
@@ -43,8 +23,13 @@ function qualityRunLike(input) {
43
23
  phases: [{ phase_id: `${input.adwId}_00_loop_stop`, adw_id: input.adwId, seq: 0, params: { name: "loop_stop", kind: "code", owner: "quality", description: "loop stop check", retries: 0 }, status: "running", attempt: 0 }],
44
24
  context_handoff_dir: input.contextHandoffDir,
45
25
  repo_root: input.repoRoot,
46
- console: { note: (message) => console.log(`[spf] loop ${message}`) },
47
- tracer: { event: () => "" },
26
+ console: {
27
+ note: (message) => {
28
+ console.log(`[spf] loop ${message}`);
29
+ return Promise.resolve();
30
+ },
31
+ },
32
+ tracer: { event: () => Promise.resolve("") },
48
33
  adw_id: input.adwId,
49
34
  };
50
35
  }
@@ -154,23 +139,37 @@ export async function loopCommand(argv) {
154
139
  // total, not for deciding correctness.
155
140
  let tokens = 0;
156
141
  let cost = 0;
157
- if (existsSync(dataPaths.db_path)) {
158
- const db = new SfDb(dataPaths.db_path);
159
- try {
160
- const session = db.session(iteration.adw_id);
161
- tokens = session?.total_tokens ?? 0;
162
- cost = session?.total_cost ?? 0;
163
- }
164
- finally {
165
- db.close();
142
+ // `SfDb.exists` covers both backends: `existsSync(dataPaths.db_path)`
143
+ // for local sqlite, and a cheap `sqlite_master` probe (false, not a
144
+ // throw, when the table isn't there yet) for d1. Both the existence
145
+ // check and the read itself are inside this loop's own try/catch —
146
+ // `runIteration` is documented as never throwing (see loop.ts's header),
147
+ // and a D1 hiccup reaching this readback must rank as zero, not abort
148
+ // the iteration, the same "never throw, rank as zero" discipline
149
+ // `fanout.ts`'s `readMetrics` already uses.
150
+ try {
151
+ if (await SfDb.exists(dataPaths)) {
152
+ const db = await SfDb.open(dataPaths.db, dataPaths.sessions_dir);
153
+ try {
154
+ const session = await db.session(iteration.adw_id);
155
+ tokens = session?.total_tokens ?? 0;
156
+ cost = session?.total_cost ?? 0;
157
+ }
158
+ finally {
159
+ await db.close();
160
+ }
166
161
  }
167
162
  }
163
+ catch {
164
+ // best-effort readback only — tokens/cost simply stay 0 for this
165
+ // iteration's ledger row.
166
+ }
168
167
  let commitSha = null;
169
168
  let stopVerdict = null;
170
169
  if (exitCode === 0 && error === null) {
171
170
  const git = makeGit(anchor.repo_root);
172
171
  commitSha = git.shortSha();
173
- const result = quality.runSuite(qualityRunLike({ cfg, repoRoot: anchor.repo_root, contextHandoffDir: dataPaths.sessions_dir, adwId: iteration.adw_id }), stop.suite);
172
+ const result = await quality.runSuite(qualityRunLike({ cfg, repoRoot: anchor.repo_root, contextHandoffDir: dataPaths.sessions_dir, adwId: iteration.adw_id }), stop.suite);
174
173
  stopVerdict = { passed: result.passed, failures: result.failures, artifacts: result.artifacts };
175
174
  }
176
175
  return { exit_code: exitCode, error, commit_sha: commitSha, tokens, cost, stop_verdict: stopVerdict };
@@ -101,7 +101,14 @@ export function migrateCommand(argv) {
101
101
  }
102
102
  }
103
103
  });
104
- const oldDbPath = resolveOld(oldConfig?.observability?.db ?? "adws/adw_data/spf.db");
104
+ // `observability.db` predates this schema's `{kind:"sqlite"|"d1",...}`
105
+ // object form (SPF #66) — a stamped `adws/` tree can only ever have written
106
+ // a bare path here, but a `typeof` guard costs nothing and keeps a
107
+ // legacy-config-carrying-a-d1-shaped-object from crashing `path.resolve()`
108
+ // with a TypeError, same explicit non-string handling `abort.ts` gives a
109
+ // d1-backed repo rather than letting it fail deep inside a file op.
110
+ const oldDbRaw = oldConfig?.observability?.db;
111
+ const oldDbPath = resolveOld(typeof oldDbRaw === "string" ? oldDbRaw : "adws/adw_data/spf.db");
105
112
  const oldSessionsDir = path.join(path.dirname(oldDbPath), "sessions");
106
113
  if (existsSync(oldDbPath)) {
107
114
  actions.push({ kind: "copy-file", detail: `${path.relative(anchor.repo_root, oldDbPath)} -> ${newDbRel} (WAL-checkpointed first)` });
@@ -8,8 +8,8 @@ export async function phasesCommand(argv) {
8
8
  return 1;
9
9
  }
10
10
  const adwId = positionals[0];
11
- const { db } = openTrace(options);
12
- const rows = db.phases(adwId);
11
+ const { db } = await openTrace(options);
12
+ const rows = await db.phases(adwId);
13
13
  if (flags["json"]) {
14
14
  console.log(JSON.stringify(rows, null, 2));
15
15
  return 0;
@@ -3,9 +3,9 @@ import { openTrace } from "./trace.js";
3
3
  import { isInteractive } from "../ask.js";
4
4
  export async function sessionsCommand(argv) {
5
5
  const { options, flags } = parseCli(argv, ["cwd", "config", "limit"], ["json"]);
6
- const { db } = openTrace(options);
6
+ const { db } = await openTrace(options);
7
7
  const limit = options["limit"] ? Number.parseInt(options["limit"], 10) : 20;
8
- const rows = db.sessions(limit);
8
+ const rows = await db.sessions(limit);
9
9
  if (flags["json"]) {
10
10
  console.log(JSON.stringify(rows, null, 2));
11
11
  return 0;
@@ -8,19 +8,39 @@ export interface TraceHandle {
8
8
  /** Already loaded by this same resolution — `spf estimate` needs it (ceilings, resolveTiering) and reusing it means one loadConfig, not two. */
9
9
  cfg: SFConfig;
10
10
  }
11
- export declare function openTrace(options: Record<string, string>): TraceHandle;
12
11
  /**
13
- * Like `openTrace`, but never throws on a missing db file: `db` is `null`
14
- * in that one slot instead. `anchor`/`dataDir`/`cfg` still come from the
15
- * SAME `resolveTrace` call either way a cold start (no `spf.db` yet)
16
- * still carries a real, loaded `cfg`, which is what lets `spf estimate`
17
- * echo `defaults.max_run_tokens`/`max_run_cost` and resolve planned routing
12
+ * The one place anchor/config/data-path resolution happens, so `openTrace`
13
+ * and `openTraceIfExists` cannot diverge on it. Exported (not just used
14
+ * internally) so a caller that needs to inspect `dataPaths.db.kind` BEFORE
15
+ * deciding whether to open anything at all `abort.ts`'s d1 guard — can
16
+ * reuse this exact resolution instead of a second, divergence-prone copy.
17
+ */
18
+ export declare function resolveTrace(options: Record<string, string>): {
19
+ dataPaths: paths.DataPaths;
20
+ anchor: paths.RepoAnchor;
21
+ dataDir: string;
22
+ cfg: SFConfig;
23
+ };
24
+ export declare function openTrace(options: Record<string, string>): Promise<TraceHandle>;
25
+ /**
26
+ * Like `openTrace`, but never throws on a db that has never been written
27
+ * to: `db` is `null` in that one slot instead. `anchor`/`dataDir`/`cfg`
28
+ * still come from the SAME `resolveTrace` call either way — a cold start
29
+ * (no `spf.db` yet, or a fresh D1 database with no `sessions` table) still
30
+ * carries a real, loaded `cfg`, which is what lets `spf estimate` echo
31
+ * `defaults.max_run_tokens`/`max_run_cost` and resolve planned routing
18
32
  * before it exits, rather than crashing with `SfDb`'s "point spf ui at a
19
33
  * target repo" message (which is about `spf ui`, not `spf estimate`).
34
+ *
35
+ * `SfDb.exists` covers both backends: `existsSync(dataPaths.db_path)` for
36
+ * local sqlite, and a cheap `sqlite_master` probe (false, not a throw, when
37
+ * the `sessions` table isn't there yet) for d1 — so `spf estimate`'s
38
+ * documented cold-start path (report + exit 3) actually reaches on a fresh
39
+ * D1 repo instead of crashing.
20
40
  */
21
- export declare function openTraceIfExists(options: Record<string, string>): {
41
+ export declare function openTraceIfExists(options: Record<string, string>): Promise<{
22
42
  db: SfDb | null;
23
43
  anchor: paths.RepoAnchor;
24
44
  dataDir: string;
25
45
  cfg: SFConfig;
26
- };
46
+ }>;
@@ -1,29 +1,42 @@
1
1
  /** Shared by sessions/phases/events/abort/estimate — resolve the workspace and open its trace db. */
2
- import { existsSync } from "node:fs";
3
2
  import * as agents from "../../core/agents.js";
4
3
  import * as paths from "../../core/paths.js";
5
4
  import { SfDb } from "../../ui/server/db.js";
6
- /** The one place anchor/config/data-path resolution happens, so `openTrace` and `openTraceIfExists` cannot diverge on it. */
7
- function resolveTrace(options) {
5
+ /**
6
+ * The one place anchor/config/data-path resolution happens, so `openTrace`
7
+ * and `openTraceIfExists` cannot diverge on it. Exported (not just used
8
+ * internally) so a caller that needs to inspect `dataPaths.db.kind` BEFORE
9
+ * deciding whether to open anything at all — `abort.ts`'s d1 guard — can
10
+ * reuse this exact resolution instead of a second, divergence-prone copy.
11
+ */
12
+ export function resolveTrace(options) {
8
13
  const anchor = paths.resolveAnchor(options["cwd"]);
9
14
  const cfg = agents.loadConfig(paths.resolveConfigPaths(anchor, options["config"]).paths);
10
15
  const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
11
- return { dbPath: dataPaths.db_path, anchor, dataDir: dataPaths.data_dir, cfg };
16
+ return { dataPaths, anchor, dataDir: dataPaths.data_dir, cfg };
12
17
  }
13
- export function openTrace(options) {
14
- const { dbPath, anchor, dataDir, cfg } = resolveTrace(options);
15
- return { db: new SfDb(dbPath), anchor, dataDir, cfg };
18
+ export async function openTrace(options) {
19
+ const { dataPaths, anchor, dataDir, cfg } = resolveTrace(options);
20
+ return { db: await SfDb.open(dataPaths.db, dataPaths.sessions_dir), anchor, dataDir, cfg };
16
21
  }
17
22
  /**
18
- * Like `openTrace`, but never throws on a missing db file: `db` is `null`
19
- * in that one slot instead. `anchor`/`dataDir`/`cfg` still come from the
20
- * SAME `resolveTrace` call either way — a cold start (no `spf.db` yet)
21
- * still carries a real, loaded `cfg`, which is what lets `spf estimate`
22
- * echo `defaults.max_run_tokens`/`max_run_cost` and resolve planned routing
23
+ * Like `openTrace`, but never throws on a db that has never been written
24
+ * to: `db` is `null` in that one slot instead. `anchor`/`dataDir`/`cfg`
25
+ * still come from the SAME `resolveTrace` call either way — a cold start
26
+ * (no `spf.db` yet, or a fresh D1 database with no `sessions` table) still
27
+ * carries a real, loaded `cfg`, which is what lets `spf estimate` echo
28
+ * `defaults.max_run_tokens`/`max_run_cost` and resolve planned routing
23
29
  * before it exits, rather than crashing with `SfDb`'s "point spf ui at a
24
30
  * target repo" message (which is about `spf ui`, not `spf estimate`).
31
+ *
32
+ * `SfDb.exists` covers both backends: `existsSync(dataPaths.db_path)` for
33
+ * local sqlite, and a cheap `sqlite_master` probe (false, not a throw, when
34
+ * the `sessions` table isn't there yet) for d1 — so `spf estimate`'s
35
+ * documented cold-start path (report + exit 3) actually reaches on a fresh
36
+ * D1 repo instead of crashing.
25
37
  */
26
- export function openTraceIfExists(options) {
27
- const { dbPath, anchor, dataDir, cfg } = resolveTrace(options);
28
- return { db: existsSync(dbPath) ? new SfDb(dbPath) : null, anchor, dataDir, cfg };
38
+ export async function openTraceIfExists(options) {
39
+ const { dataPaths, anchor, dataDir, cfg } = resolveTrace(options);
40
+ const db = (await SfDb.exists(dataPaths)) ? await SfDb.open(dataPaths.db, dataPaths.sessions_dir) : null;
41
+ return { db, anchor, dataDir, cfg };
29
42
  }