@gr8ful/spf 0.14.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 +51 -9
- package/assets/skill/references/config.md +12 -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 +121 -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 +91 -30
- package/dist/cli/index.js +3 -1
- package/dist/cli/interview.d.ts +1 -0
- package/dist/cli/interview.js +95 -5
- 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 +356 -15
- package/dist/core/data_types.js +180 -7
- 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/issues/github_provider.d.ts +66 -2
- package/dist/core/issues/github_provider.js +161 -2
- package/dist/core/issues/jira_provider.d.ts +50 -9
- package/dist/core/issues/jira_provider.js +62 -2
- 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/refine.js +2 -2
- 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/assets/overpass-latin-400-normal-BpeLJ0bs.woff2 +0 -0
- package/web/assets/overpass-latin-600-normal-25RhTNCi.woff2 +0 -0
- package/web/assets/overpass-latin-700-normal-CQX2QTgM.woff2 +0 -0
- package/web/assets/overpass-mono-latin-400-normal-VINZG6Js.woff2 +0 -0
- package/web/assets/overpass-mono-latin-700-normal-D6nRBrbd.woff2 +0 -0
- package/web/index.html +33 -2
- package/web/logo.svg +4 -4
- package/web/assets/index-C7nF068F.css +0 -1
- package/web/assets/index-mzSArcnQ.js +0 -11
- package/web/assets/play-latin-400-normal-GKW-4YV7.woff2 +0 -0
- package/web/assets/play-latin-700-normal-DyPlLDbb.woff2 +0 -0
package/dist/chains/steps.js
CHANGED
|
@@ -73,7 +73,7 @@ function makeStep(fn, meta = {}) {
|
|
|
73
73
|
export async function startRun(ctx, requiredAgents, requiredSuites) {
|
|
74
74
|
const cfg = agentsCfg.loadConfig(ctx.config_paths);
|
|
75
75
|
agentsCfg.validate(cfg, requiredAgents, requiredSuites, ctx.cwd);
|
|
76
|
-
const run = session.ensure(cfg, ctx.adw_id, ctx.cwd, ctx.chain_name, ctx.render_hooks);
|
|
76
|
+
const run = await session.ensure(cfg, ctx.adw_id, ctx.cwd, ctx.chain_name, ctx.render_hooks);
|
|
77
77
|
// Provenance, once per run, before any phase opens: a repo-local chain
|
|
78
78
|
// (.spf/chains/*.yaml) records the file it came from. `chain_name` alone
|
|
79
79
|
// stops being enough to reconstruct a run the moment a target repo can
|
|
@@ -88,7 +88,7 @@ export async function startRun(ctx, requiredAgents, requiredSuites) {
|
|
|
88
88
|
// it is a note. Adding a picklist member would force a UI change for a
|
|
89
89
|
// payload the UI already renders generically.
|
|
90
90
|
if (ctx.chain_source) {
|
|
91
|
-
run.tracer.event(makeEventRecord({ adw_id: run.adw_id, type: "log", name: "chain_source", payload: { source: ctx.chain_source } }));
|
|
91
|
+
await run.tracer.event(makeEventRecord({ adw_id: run.adw_id, type: "log", name: "chain_source", payload: { source: ctx.chain_source } }));
|
|
92
92
|
}
|
|
93
93
|
// Tiering resolution (SPF #14) — one more run-scoped fact, computed once,
|
|
94
94
|
// before any phase opens, beside chain_source above. `risk`/`signals` are
|
|
@@ -103,7 +103,7 @@ export async function startRun(ctx, requiredAgents, requiredSuites) {
|
|
|
103
103
|
servedOllamaTags,
|
|
104
104
|
required: requiredAgents,
|
|
105
105
|
});
|
|
106
|
-
run.tracer.event(makeEventRecord({
|
|
106
|
+
await run.tracer.event(makeEventRecord({
|
|
107
107
|
adw_id: run.adw_id,
|
|
108
108
|
type: "log",
|
|
109
109
|
name: "tiering",
|
|
@@ -123,7 +123,7 @@ export async function startRun(ctx, requiredAgents, requiredSuites) {
|
|
|
123
123
|
// reading the console when the two disagree.
|
|
124
124
|
for (const [agentName, effective] of Object.entries(tiering.changedModels(run.tiering))) {
|
|
125
125
|
const route = run.tiering.routing[agentName];
|
|
126
|
-
run.console.note(`[spf] tiering ${agentName} ${route.tier} (${route.configured} -> ${effective}) risk=${run.tiering.risk}`);
|
|
126
|
+
await run.console.note(`[spf] tiering ${agentName} ${route.tier} (${route.configured} -> ${effective}) risk=${run.tiering.risk}`);
|
|
127
127
|
}
|
|
128
128
|
return run;
|
|
129
129
|
}
|
|
@@ -176,7 +176,23 @@ function appendTrailer(message, trailer) {
|
|
|
176
176
|
* -> no trailer, ever: a trailer that lies launders an AI verdict into a git
|
|
177
177
|
* attestation.
|
|
178
178
|
*/
|
|
179
|
-
export function commitEnvelope(run, ph, envelope, signoff) {
|
|
179
|
+
export async function commitEnvelope(run, ph, envelope, signoff) {
|
|
180
|
+
// An agent's own plan can (and sometimes does) instruct it to `git commit`
|
|
181
|
+
// its own work directly — e.g. a "Commit convention" section the planner
|
|
182
|
+
// wrote into the plan, following a target repo's own commit-message
|
|
183
|
+
// rules. When that happens, this phase's working tree is already clean by
|
|
184
|
+
// the time it runs: `commitAll`'s `git status --porcelain` finds nothing
|
|
185
|
+
// staged and throws "nothing to commit", even though real, tested work
|
|
186
|
+
// already landed on the branch a phase ago. Distinguish that from the
|
|
187
|
+
// genuine "nothing happened at all" case by checking whether HEAD already
|
|
188
|
+
// holds committed changes ahead of `run.base` — if so, this phase has
|
|
189
|
+
// nothing left to do and the existing HEAD *is* the result, not a failure.
|
|
190
|
+
const base = run.cfg.watch?.base_branch ?? "main";
|
|
191
|
+
if (!run.git.isDirty() && run.git.diffFiles(base).length > 0) {
|
|
192
|
+
const sha = run.git.shortSha();
|
|
193
|
+
await ph.log({ sha, message: "(already committed by a preceding phase — nothing new to stage)" });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
180
196
|
let message = envelope.commit_message || `spf(${run.adw_id}): ${envelope.summary}`;
|
|
181
197
|
if (signoff) {
|
|
182
198
|
const trailerLine = `Signed-off-by: ${signoff.name} <${signoff.email}>`;
|
|
@@ -186,11 +202,11 @@ export function commitEnvelope(run, ph, envelope, signoff) {
|
|
|
186
202
|
if (!alreadyPresent)
|
|
187
203
|
message = appendTrailer(message, trailerLine);
|
|
188
204
|
}
|
|
189
|
-
ph.log({ sha: run.git.commitAll(message), message });
|
|
205
|
+
await ph.log({ sha: run.git.commitAll(message), message });
|
|
190
206
|
}
|
|
191
207
|
/** Log a change-capture result the same way every chain that captures one did. */
|
|
192
|
-
export function logChangeset(ph, result) {
|
|
193
|
-
ph.log({
|
|
208
|
+
export async function logChangeset(ph, result) {
|
|
209
|
+
await ph.log({
|
|
194
210
|
base: `${result.base.label} @ ${result.base.commit.slice(0, 7)}`,
|
|
195
211
|
reason: result.base.reason,
|
|
196
212
|
files: result.files.length + result.untracked.length,
|
|
@@ -328,7 +344,7 @@ export function request(opts = {}) {
|
|
|
328
344
|
const payload = { input: state.prompt };
|
|
329
345
|
if (opts.logBaseline)
|
|
330
346
|
payload.baseline = run.git.shortSha(state.baseline);
|
|
331
|
-
ph.log(payload);
|
|
347
|
+
await ph.log(payload);
|
|
332
348
|
});
|
|
333
349
|
};
|
|
334
350
|
return makeStep(fn, { label: "engineer(request)" });
|
|
@@ -454,8 +470,8 @@ export function qualityCheck(opts = { suite: "all" }) {
|
|
|
454
470
|
description: opts.description ??
|
|
455
471
|
(suiteName === "all" ? "Run the deterministic quality blocks" : "Run the suite — a known command, so code runs it and no agent has to rediscover it"),
|
|
456
472
|
}), async (ph) => {
|
|
457
|
-
const result = quality.runSuite(run, suiteName);
|
|
458
|
-
quality.record(ph, result);
|
|
473
|
+
const result = await quality.runSuite(run, suiteName);
|
|
474
|
+
await quality.record(ph, result);
|
|
459
475
|
state.quality = result;
|
|
460
476
|
state.accepted = result.passed;
|
|
461
477
|
state.reason = result.passed ? "" : `quality failed: ${result.failures.join("; ")}`;
|
|
@@ -498,8 +514,8 @@ export function fixLoop(opts = { suite: "test" }) {
|
|
|
498
514
|
? "Lint, typecheck, and build before testing"
|
|
499
515
|
: "Run the suite — a known command, so code runs it and no agent has to rediscover it"),
|
|
500
516
|
}), async (ph) => {
|
|
501
|
-
const r = quality.runSuite(run, suiteName);
|
|
502
|
-
quality.record(ph, r);
|
|
517
|
+
const r = await quality.runSuite(run, suiteName);
|
|
518
|
+
await quality.record(ph, r);
|
|
503
519
|
return r;
|
|
504
520
|
});
|
|
505
521
|
if (result.passed)
|
|
@@ -597,7 +613,7 @@ export function commit(opts = {}) {
|
|
|
597
613
|
owner: "git",
|
|
598
614
|
description: opts.description ??
|
|
599
615
|
(opts.onlyIfAccepted ? "Land the code only after the suite came back green" : "Land the builder's changes, using the message it wrote"),
|
|
600
|
-
}), async (ph) => commitEnvelope(run, ph, state.previous));
|
|
616
|
+
}), async (ph) => await commitEnvelope(run, ph, state.previous));
|
|
601
617
|
};
|
|
602
618
|
return makeStep(fn, { label: "git(commit)" });
|
|
603
619
|
}
|
|
@@ -616,7 +632,7 @@ export function changes(opts = {}) {
|
|
|
616
632
|
description: opts.description ?? `Diff the working tree against ${base} — the change to be written up`,
|
|
617
633
|
}), async (ph) => {
|
|
618
634
|
const result = changesLib.capture(run, makeChangeCapture({ base }));
|
|
619
|
-
logChangeset(ph, result);
|
|
635
|
+
await logChangeset(ph, result);
|
|
620
636
|
if (result.empty) {
|
|
621
637
|
throw new Error(`nothing changed since ${result.base.label} (${result.base.reason}) — documenting runs after a build. ` +
|
|
622
638
|
`Build something first, or point --base at the ref the work should be measured from.`);
|
|
@@ -770,7 +786,7 @@ export function publishIssues(opts = {}) {
|
|
|
770
786
|
clearStaleRefineOutputFiles(run.context_handoff_dir);
|
|
771
787
|
if (questions.length > 0) {
|
|
772
788
|
writeFileSync(path.join(run.context_handoff_dir, "refine_questions.json"), JSON.stringify(questions, null, 2));
|
|
773
|
-
ph.log({ escalated: questions.length });
|
|
789
|
+
await ph.log({ escalated: questions.length });
|
|
774
790
|
return;
|
|
775
791
|
}
|
|
776
792
|
if (split.length > 0) {
|
|
@@ -781,7 +797,7 @@ export function publishIssues(opts = {}) {
|
|
|
781
797
|
// `proposeSpecSplit`, same division of labor as the `questions`
|
|
782
798
|
// branch above (this writes, `runSpec` posts/transitions).
|
|
783
799
|
writeFileSync(path.join(run.context_handoff_dir, "refine_split.json"), JSON.stringify(split, null, 2));
|
|
784
|
-
ph.log({ split_proposed: split.length });
|
|
800
|
+
await ph.log({ split_proposed: split.length });
|
|
785
801
|
return;
|
|
786
802
|
}
|
|
787
803
|
const tracker = refineLib.resolveAuthoringProvider(run.cfg);
|
|
@@ -791,7 +807,7 @@ export function publishIssues(opts = {}) {
|
|
|
791
807
|
priorityCeiling,
|
|
792
808
|
});
|
|
793
809
|
writeFileSync(path.join(run.context_handoff_dir, "refine_publish.json"), JSON.stringify(created.map((c) => ({ id: c.issue.id, title: c.issue.title, kind: c.kind, isLeaf: c.isLeaf })), null, 2));
|
|
794
|
-
ph.log({ created: created.length, leaves: created.filter((c) => c.isLeaf).length, priority_ceiling: priorityCeiling });
|
|
810
|
+
await ph.log({ created: created.length, leaves: created.filter((c) => c.isLeaf).length, priority_ceiling: priorityCeiling });
|
|
795
811
|
});
|
|
796
812
|
};
|
|
797
813
|
return makeStep(fn, { label: "code(publish)" });
|
|
@@ -843,5 +859,5 @@ export async function runSteps(ctx, requiredAgents, requiredSuites, steps, optio
|
|
|
843
859
|
for (const step of steps) {
|
|
844
860
|
await step(run, state);
|
|
845
861
|
}
|
|
846
|
-
return run.finish(state.accepted, state.reason);
|
|
862
|
+
return await run.finish(state.accepted, state.reason);
|
|
847
863
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function abortCommand(argv: string[]): number
|
|
1
|
+
export declare function abortCommand(argv: string[]): Promise<number>;
|
|
@@ -4,18 +4,45 @@
|
|
|
4
4
|
* recorded as still running for this adw_id and SIGTERM them. Since Flue
|
|
5
5
|
* runs in-process, that pid is the whole `spf` invocation driving the chain
|
|
6
6
|
* — this stops the run, not just the current model call.
|
|
7
|
+
*
|
|
8
|
+
* LOCAL-FILE-SPECIFIC: reads the `processes` table through a second raw
|
|
9
|
+
* `Database` handle on the same sqlite file `db` already opened — there is
|
|
10
|
+
* no such file for a `kind:"d1"` repo (SPF #66), so this fails clearly
|
|
11
|
+
* instead of pretending a network-backed process table works the same way.
|
|
12
|
+
* A future release could reach the same table over `SfDb`'s own (now async)
|
|
13
|
+
* query surface instead of a raw second handle — not done here because nothing
|
|
14
|
+
* else in this command needs it, and the raw handle already matched
|
|
15
|
+
* `db.journalMode`'s existing guarantees for the local case.
|
|
7
16
|
*/
|
|
8
17
|
import { Database } from "../../core/sqlite.js";
|
|
9
18
|
import { parseCli } from "../../core/utils.js";
|
|
10
|
-
import { openTrace } from "./trace.js";
|
|
11
|
-
export function abortCommand(argv) {
|
|
19
|
+
import { openTrace, resolveTrace } from "./trace.js";
|
|
20
|
+
export async function abortCommand(argv) {
|
|
12
21
|
const { positionals, options } = parseCli(argv, ["cwd", "config"]);
|
|
13
22
|
if (positionals.length < 1) {
|
|
14
23
|
console.error("usage: spf abort <adw_id> [--cwd <dir>] [--config <path>]");
|
|
15
24
|
return 1;
|
|
16
25
|
}
|
|
17
26
|
const adwId = positionals[0];
|
|
18
|
-
|
|
27
|
+
// Checked BEFORE opening anything: a d1-backed repo has no local process
|
|
28
|
+
// table regardless of whether its trace db can even be reached, so there
|
|
29
|
+
// is no reason to pay for (or risk failing) an `openTrace` HTTP round
|
|
30
|
+
// trip just to then refuse. This also means no `SfDb` handle is ever
|
|
31
|
+
// opened for the d1 case — nothing to leave unclosed.
|
|
32
|
+
const { dataPaths } = resolveTrace(options);
|
|
33
|
+
if (dataPaths.db.kind === "d1") {
|
|
34
|
+
console.error(`spf abort is not supported for a d1-backed repo (observability.db.kind: "d1") — ` +
|
|
35
|
+
`there is no local process table to read for database_id ${JSON.stringify(dataPaths.db.database_id)}. ` +
|
|
36
|
+
`Stop the process by pid/OS tooling directly, or on the machine that ran it.`);
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
const { db, dataDir } = await openTrace(options);
|
|
40
|
+
if (db.path === null) {
|
|
41
|
+
// Unreachable in practice — the `dataPaths.db.kind === "d1"` guard above
|
|
42
|
+
// already rejected the only case `db.path` can be null for — but keeps
|
|
43
|
+
// this typed as a real narrowing rather than a `!` assertion.
|
|
44
|
+
throw new Error("spf abort: db.path is null after the d1 guard — this should never happen");
|
|
45
|
+
}
|
|
19
46
|
// SfDb opens read-only; a live process row needs a separate writable
|
|
20
47
|
// handle only to read it here (no write happens) — reuse the same file.
|
|
21
48
|
const raw = new Database(db.path, { readonly: true });
|
|
@@ -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
|
|
@@ -861,6 +962,14 @@ export async function doctorCommand(argv) {
|
|
|
861
962
|
? "set"
|
|
862
963
|
: 'not set — spf watch needs a classic PAT with "repo" scope (or "public_repo" for a public-only repo); see README.md\'s "GITHUB_TOKEN scope" section');
|
|
863
964
|
}
|
|
965
|
+
if (cfg.watch.issue_provider === "github") {
|
|
966
|
+
const statusMapEntries = Object.entries(cfg.watch.github.status_map).filter(([, v]) => Boolean(v));
|
|
967
|
+
check(report, "watch.github.status_map", true, statusMapEntries.length > 0
|
|
968
|
+
? cfg.watch.github.project_number
|
|
969
|
+
? `${statusMapEntries.length} state(s) configured to sync Projects v2 #${cfg.watch.github.project_number}'s Status field — run \`spf watch init\` to validate them, and make sure GITHUB_TOKEN has the "project" scope`
|
|
970
|
+
: `${statusMapEntries.length} state(s) configured but watch.github.project_number is unset — status sync stays disabled until it's set`
|
|
971
|
+
: "not configured — spf watch will only update labels on this repo, never a Projects v2 board (optional, off by default)", "info");
|
|
972
|
+
}
|
|
864
973
|
if (cfg.watch.issue_provider === "jira") {
|
|
865
974
|
check(report, "watch.jira", Boolean(cfg.watch.jira.base_url.trim() && cfg.watch.jira.project_key.trim()), cfg.watch.jira.base_url.trim() && cfg.watch.jira.project_key.trim()
|
|
866
975
|
? `${cfg.watch.jira.base_url} (${cfg.watch.jira.project_key})`
|
|
@@ -868,6 +977,10 @@ export async function doctorCommand(argv) {
|
|
|
868
977
|
check(report, "JIRA_EMAIL / JIRA_API_TOKEN", Boolean(process.env["JIRA_EMAIL"] && process.env["JIRA_API_TOKEN"]), process.env["JIRA_EMAIL"] && process.env["JIRA_API_TOKEN"]
|
|
869
978
|
? "set"
|
|
870
979
|
: 'not set — spf watch needs an Atlassian account email plus an API token (id.atlassian.com -> Security -> API tokens); see README.md\'s "spf watch" section');
|
|
980
|
+
const statusMapEntries = Object.entries(cfg.watch.jira.status_map).filter(([, v]) => Boolean(v));
|
|
981
|
+
check(report, "watch.jira.status_map", true, statusMapEntries.length > 0
|
|
982
|
+
? `${statusMapEntries.length} state(s) configured to sync a native Jira status — run \`spf watch init\` to validate them against the real project's statuses`
|
|
983
|
+
: "not configured — spf watch will only update labels on this project, never the Jira Status field (optional, off by default)", "info");
|
|
871
984
|
}
|
|
872
985
|
if (cfg.watch.code_host === "bitbucket") {
|
|
873
986
|
check(report, "BITBUCKET_EMAIL / BITBUCKET_API_TOKEN", Boolean(process.env["BITBUCKET_EMAIL"] && process.env["BITBUCKET_API_TOKEN"]), process.env["BITBUCKET_EMAIL"] && process.env["BITBUCKET_API_TOKEN"]
|
|
@@ -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
|
}
|