@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
|
@@ -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
|
-
|
|
289
|
+
// `db_path` is `null` for a `kind: "d1"` observability.db — there 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
|
|
754
|
-
// above is chain-scoped to whatever `required` it was called
|
|
755
|
-
// doctor happens to pass the whole roster there too, but this is
|
|
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
|
|
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 (
|
|
270
|
-
const preflight =
|
|
269
|
+
if (await SfDb.exists(dataPaths)) {
|
|
270
|
+
const preflight = await SfDb.open(dataPaths.db, dataPaths.sessions_dir);
|
|
271
271
|
try {
|
|
272
|
-
const
|
|
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
|
|
296
|
-
* attempt's tracer creates it, and `SfDb`
|
|
297
|
-
*
|
|
298
|
-
* siblings are still writing is exactly the
|
|
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`:
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
312
|
-
|
|
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.
|
|
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: {
|
|
47
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
/**
|
|
7
|
-
|
|
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 {
|
|
16
|
+
return { dataPaths, anchor, dataDir: dataPaths.data_dir, cfg };
|
|
12
17
|
}
|
|
13
|
-
export function openTrace(options) {
|
|
14
|
-
const {
|
|
15
|
-
return { db:
|
|
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
|
|
19
|
-
* in that one slot instead. `anchor`/`dataDir`/`cfg`
|
|
20
|
-
* SAME `resolveTrace` call either way — a cold start
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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 {
|
|
28
|
-
|
|
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
|
}
|