@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/core/tracer.d.ts
CHANGED
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tracer: every event lands in JSONL and
|
|
2
|
+
* Tracer: every event lands in JSONL and the trace db AS IT HAPPENS.
|
|
3
3
|
*
|
|
4
|
-
* Files are the raw record;
|
|
5
|
-
*
|
|
4
|
+
* Files are the raw record; the trace db (local sqlite, or remote D1 — see
|
|
5
|
+
* `core/trace_db.ts`) is the queryable mirror the UI polls. For the LOCAL
|
|
6
|
+
* backend, WAL mode lets the UI read while ADW processes write, in the same
|
|
7
|
+
* instant, because both sides share one file on one filesystem.
|
|
8
|
+
*
|
|
9
|
+
* THE D1 BACKEND DOES NOT MAKE THAT SAME PROMISE — see `core/trace_db.ts`'s
|
|
10
|
+
* `D1TraceDb` doc comment for the consistency model it actually has and why
|
|
11
|
+
* this is a deliberate, documented trade-off (SPF #66) rather than a gap to
|
|
12
|
+
* close here.
|
|
6
13
|
*
|
|
7
14
|
* No push transport in the CONTROL flow — that is always, still, and only:
|
|
8
|
-
* agents ->
|
|
15
|
+
* agents -> trace db -> web ui. The trace db is the source of truth; nothing
|
|
9
16
|
* downstream of it can affect a phase, a gate, or a run outcome.
|
|
10
17
|
*
|
|
18
|
+
* WHY EVERY WRITE METHOD BELOW IS ASYNC: the D1 backend's only way to reach
|
|
19
|
+
* a database from this plain Node CLI is its HTTP REST API — a network call.
|
|
20
|
+
* `core/trace_db.ts`'s `TraceDb` interface is async for that reason, and
|
|
21
|
+
* every write method here is a thin `await this.db...` over it — see that
|
|
22
|
+
* module's header. For the LOCAL backend this costs nothing but the syntax:
|
|
23
|
+
* `LocalTraceDb` wraps the exact same synchronous `Database` calls this file
|
|
24
|
+
* always made, immediately resolved.
|
|
25
|
+
*
|
|
11
26
|
* The one amendment: when (and only when) `observability.otel` is configured,
|
|
12
27
|
* each write method below ends with a single fan-out line to an optional
|
|
13
28
|
* OtelExporter — a lossy, allowlisted PROJECTION of what was just written,
|
|
@@ -21,43 +36,58 @@
|
|
|
21
36
|
* `processStart`/`processEnd` (pids) — have NO fan-out line on purpose. Do not
|
|
22
37
|
* add one.
|
|
23
38
|
*/
|
|
24
|
-
import {
|
|
25
|
-
import type { AgentConfig, EventRecord, GateReport, Phase } from "./data_types.ts";
|
|
39
|
+
import type { AgentConfig, EventRecord, GateReport, NormalizedObservabilityDb, Phase } from "./data_types.ts";
|
|
26
40
|
import type { OtelExporter } from "./otel.ts";
|
|
41
|
+
import { type TraceDb } from "./trace_db.ts";
|
|
27
42
|
export declare class Tracer {
|
|
28
|
-
db:
|
|
29
|
-
|
|
43
|
+
db: TraceDb;
|
|
44
|
+
/** Absolute local sqlite path — `null` for a `kind:"d1"` config (`Console.sessionFinished`'s "db" line falls back to `runner.ts`'s `describeObservabilityDb` for a human label in that case). */
|
|
45
|
+
dbPath: string | null;
|
|
30
46
|
eventsJsonl: string;
|
|
31
47
|
/** `null` unless `observability.otel` is configured — see the header. */
|
|
32
48
|
otel: OtelExporter | null;
|
|
33
|
-
constructor(
|
|
49
|
+
private constructor();
|
|
50
|
+
/**
|
|
51
|
+
* Open (or create) the trace db, run schema + migrations, and return a
|
|
52
|
+
* ready-to-use Tracer. The one constructor path — a plain `new Tracer(...)`
|
|
53
|
+
* cannot do this work itself because opening a D1-backed db means awaited
|
|
54
|
+
* HTTP calls before the schema exists, and a constructor cannot be async.
|
|
55
|
+
*
|
|
56
|
+
* `dbConfig` accepts either a bare local sqlite path (sugar for
|
|
57
|
+
* `{kind:"sqlite",path}` — every test's shorthand, and what every caller
|
|
58
|
+
* before SPF #66 passed) or a normalized `observability.db` descriptor
|
|
59
|
+
* (`paths.resolveDataPaths(...).db` — sqlite or d1).
|
|
60
|
+
*/
|
|
61
|
+
static open(dbConfig: NormalizedObservabilityDb | string, eventsJsonl: string, otel?: OtelExporter | null, traceDbOptions?: {
|
|
62
|
+
fetchImpl?: typeof fetch;
|
|
63
|
+
}): Promise<Tracer>;
|
|
34
64
|
/**
|
|
35
|
-
* Close the
|
|
65
|
+
* Close the trace db handle. A one-shot CLI process never needs this — it
|
|
36
66
|
* exits right after its one Tracer anyway — but `spf watch`'s daemon loop
|
|
37
|
-
* builds a fresh Tracer (and a fresh
|
|
38
|
-
* issue, in-process, for the life of the
|
|
39
|
-
* issue's handle stayed open forever.
|
|
40
|
-
* `finalize()`, once a run's own dispatch has
|
|
41
|
-
* comment for why that timing is safe.
|
|
67
|
+
* builds a fresh Tracer (and a fresh local db connection, or a fresh
|
|
68
|
+
* stateless D1 client) per claimed issue, in-process, for the life of the
|
|
69
|
+
* daemon; without this, every issue's local handle stayed open forever.
|
|
70
|
+
* Called from `session.ts`'s `finalize()`, once a run's own dispatch has
|
|
71
|
+
* fully settled — see its comment for why that timing is safe. A no-op for
|
|
72
|
+
* `D1TraceDb` (stateless HTTP — nothing to release).
|
|
42
73
|
*/
|
|
43
|
-
close(): void
|
|
74
|
+
close(): Promise<void>;
|
|
44
75
|
/** Additive column migrations, so a db from an older SPF still opens. */
|
|
45
76
|
private migrate;
|
|
46
77
|
/**
|
|
47
|
-
* The ONE door to the optional otel projection
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* failed phase, because export is not allowed to dispose of anything. The
|
|
78
|
+
* The ONE door to the optional otel projection. It is a no-op when
|
|
79
|
+
* unconfigured, and it swallows everything: an exporter bug, a malformed
|
|
80
|
+
* span, an exhausted queue — none of it may ever surface as a failed
|
|
81
|
+
* phase, because export is not allowed to dispose of anything. The
|
|
52
82
|
* exporter's own methods are synchronous enqueues; the network happens later,
|
|
53
83
|
* on an unref'd timer.
|
|
54
84
|
*/
|
|
55
85
|
private fanOut;
|
|
56
|
-
event(record: EventRecord): string
|
|
57
|
-
sessionStart(adwId: string, engineer: string, adwName?: string | null): void
|
|
58
|
-
sessionRequest(adwId: string, request: string): void
|
|
59
|
-
sessionFinish(adwId: string, ok: boolean): void
|
|
60
|
-
sessionAddUsage(adwId: string, tokens: number, cost: number): void
|
|
86
|
+
event(record: EventRecord): Promise<string>;
|
|
87
|
+
sessionStart(adwId: string, engineer: string, adwName?: string | null): Promise<void>;
|
|
88
|
+
sessionRequest(adwId: string, request: string): Promise<void>;
|
|
89
|
+
sessionFinish(adwId: string, ok: boolean): Promise<void>;
|
|
90
|
+
sessionAddUsage(adwId: string, tokens: number, cost: number): Promise<void>;
|
|
61
91
|
/**
|
|
62
92
|
* Record a live process for this run.
|
|
63
93
|
*
|
|
@@ -66,11 +96,11 @@ export declare class Tracer {
|
|
|
66
96
|
* belongs to. Writing it here makes the trace the answer to "what is this
|
|
67
97
|
* run running, and how do I stop it".
|
|
68
98
|
*/
|
|
69
|
-
processStart(adwId: string, kind: string, name: string, pid: number, command: string): void
|
|
99
|
+
processStart(adwId: string, kind: string, name: string, pid: number, command: string): Promise<void>;
|
|
70
100
|
/** Mark the newest live row for this pid as finished. */
|
|
71
|
-
processEnd(adwId: string, pid: number): void
|
|
101
|
+
processEnd(adwId: string, pid: number): Promise<void>;
|
|
72
102
|
/** Close out every live row for a run — called when the session ends. */
|
|
73
|
-
processesEndAll(adwId: string): void
|
|
103
|
+
processesEndAll(adwId: string): Promise<void>;
|
|
74
104
|
/**
|
|
75
105
|
* Highest seq already recorded for this session; 0 when it is new.
|
|
76
106
|
*
|
|
@@ -79,11 +109,11 @@ export declare class Tracer {
|
|
|
79
109
|
* ordering) and `phase_id` (silently overwriting a row through the
|
|
80
110
|
* phase_upsert conflict clause).
|
|
81
111
|
*/
|
|
82
|
-
maxPhaseSeq(adwId: string): number
|
|
83
|
-
phaseUpsert(phase: Phase): void
|
|
84
|
-
envelopeRow(phase: Phase, agent: string, outputType: string, payloadJson: string, valid: boolean, attempt: number): void
|
|
112
|
+
maxPhaseSeq(adwId: string): Promise<number>;
|
|
113
|
+
phaseUpsert(phase: Phase): Promise<void>;
|
|
114
|
+
envelopeRow(phase: Phase, agent: string, outputType: string, payloadJson: string, valid: boolean, attempt: number): Promise<void>;
|
|
85
115
|
/** The report carries both the verdict and the evidence behind it. */
|
|
86
|
-
gateRow(phase: Phase, gate: string, report: GateReport, attempt: number): void
|
|
116
|
+
gateRow(phase: Phase, gate: string, report: GateReport, attempt: number): Promise<void>;
|
|
87
117
|
/**
|
|
88
118
|
* The agent's config row is the source of truth for its label and color.
|
|
89
119
|
*
|
|
@@ -91,5 +121,5 @@ export declare class Tracer {
|
|
|
91
121
|
* wants one number per agent — the latest — and a session that runs the
|
|
92
122
|
* same agent twice overwrites it, exactly like model and session_id.
|
|
93
123
|
*/
|
|
94
|
-
agentSessionRow(adwId: string, agent: AgentConfig, sessionId: string, contextTokens?: number, contextWindow?: number): void
|
|
124
|
+
agentSessionRow(adwId: string, agent: AgentConfig, sessionId: string, contextTokens?: number, contextWindow?: number): Promise<void>;
|
|
95
125
|
}
|
package/dist/core/tracer.js
CHANGED
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tracer: every event lands in JSONL and
|
|
2
|
+
* Tracer: every event lands in JSONL and the trace db AS IT HAPPENS.
|
|
3
3
|
*
|
|
4
|
-
* Files are the raw record;
|
|
5
|
-
*
|
|
4
|
+
* Files are the raw record; the trace db (local sqlite, or remote D1 — see
|
|
5
|
+
* `core/trace_db.ts`) is the queryable mirror the UI polls. For the LOCAL
|
|
6
|
+
* backend, WAL mode lets the UI read while ADW processes write, in the same
|
|
7
|
+
* instant, because both sides share one file on one filesystem.
|
|
8
|
+
*
|
|
9
|
+
* THE D1 BACKEND DOES NOT MAKE THAT SAME PROMISE — see `core/trace_db.ts`'s
|
|
10
|
+
* `D1TraceDb` doc comment for the consistency model it actually has and why
|
|
11
|
+
* this is a deliberate, documented trade-off (SPF #66) rather than a gap to
|
|
12
|
+
* close here.
|
|
6
13
|
*
|
|
7
14
|
* No push transport in the CONTROL flow — that is always, still, and only:
|
|
8
|
-
* agents ->
|
|
15
|
+
* agents -> trace db -> web ui. The trace db is the source of truth; nothing
|
|
9
16
|
* downstream of it can affect a phase, a gate, or a run outcome.
|
|
10
17
|
*
|
|
18
|
+
* WHY EVERY WRITE METHOD BELOW IS ASYNC: the D1 backend's only way to reach
|
|
19
|
+
* a database from this plain Node CLI is its HTTP REST API — a network call.
|
|
20
|
+
* `core/trace_db.ts`'s `TraceDb` interface is async for that reason, and
|
|
21
|
+
* every write method here is a thin `await this.db...` over it — see that
|
|
22
|
+
* module's header. For the LOCAL backend this costs nothing but the syntax:
|
|
23
|
+
* `LocalTraceDb` wraps the exact same synchronous `Database` calls this file
|
|
24
|
+
* always made, immediately resolved.
|
|
25
|
+
*
|
|
11
26
|
* The one amendment: when (and only when) `observability.otel` is configured,
|
|
12
27
|
* each write method below ends with a single fan-out line to an optional
|
|
13
28
|
* OtelExporter — a lossy, allowlisted PROJECTION of what was just written,
|
|
@@ -21,9 +36,9 @@
|
|
|
21
36
|
* `processStart`/`processEnd` (pids) — have NO fan-out line on purpose. Do not
|
|
22
37
|
* add one.
|
|
23
38
|
*/
|
|
24
|
-
import { Database } from "./sqlite.js";
|
|
25
39
|
import { appendFileSync, mkdirSync } from "node:fs";
|
|
26
40
|
import path from "node:path";
|
|
41
|
+
import { createTraceDb } from "./trace_db.js";
|
|
27
42
|
import { newId, nowIso } from "./utils.js";
|
|
28
43
|
const SCHEMA = `
|
|
29
44
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
@@ -38,7 +53,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|
|
38
53
|
);
|
|
39
54
|
CREATE TABLE IF NOT EXISTS phases (
|
|
40
55
|
phase_id TEXT PRIMARY KEY,
|
|
41
|
-
adw_id TEXT
|
|
56
|
+
adw_id TEXT,
|
|
42
57
|
seq INTEGER,
|
|
43
58
|
name TEXT, kind TEXT, owner TEXT, description TEXT,
|
|
44
59
|
status TEXT DEFAULT 'fail',
|
|
@@ -48,8 +63,8 @@ CREATE TABLE IF NOT EXISTS phases (
|
|
|
48
63
|
);
|
|
49
64
|
CREATE TABLE IF NOT EXISTS events (
|
|
50
65
|
event_id TEXT PRIMARY KEY,
|
|
51
|
-
adw_id TEXT
|
|
52
|
-
phase_id TEXT
|
|
66
|
+
adw_id TEXT,
|
|
67
|
+
phase_id TEXT,
|
|
53
68
|
parent_id TEXT,
|
|
54
69
|
type TEXT,
|
|
55
70
|
name TEXT,
|
|
@@ -59,8 +74,8 @@ CREATE TABLE IF NOT EXISTS events (
|
|
|
59
74
|
);
|
|
60
75
|
CREATE TABLE IF NOT EXISTS envelopes (
|
|
61
76
|
envelope_id TEXT PRIMARY KEY,
|
|
62
|
-
adw_id TEXT
|
|
63
|
-
phase_id TEXT
|
|
77
|
+
adw_id TEXT,
|
|
78
|
+
phase_id TEXT,
|
|
64
79
|
agent TEXT,
|
|
65
80
|
output_type TEXT,
|
|
66
81
|
payload_json TEXT,
|
|
@@ -70,8 +85,8 @@ CREATE TABLE IF NOT EXISTS envelopes (
|
|
|
70
85
|
);
|
|
71
86
|
CREATE TABLE IF NOT EXISTS gate_results (
|
|
72
87
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
73
|
-
adw_id TEXT
|
|
74
|
-
phase_id TEXT
|
|
88
|
+
adw_id TEXT,
|
|
89
|
+
phase_id TEXT,
|
|
75
90
|
attempt INTEGER,
|
|
76
91
|
gate TEXT,
|
|
77
92
|
passed INTEGER,
|
|
@@ -81,7 +96,7 @@ CREATE TABLE IF NOT EXISTS gate_results (
|
|
|
81
96
|
);
|
|
82
97
|
CREATE TABLE IF NOT EXISTS processes (
|
|
83
98
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
84
|
-
adw_id TEXT
|
|
99
|
+
adw_id TEXT,
|
|
85
100
|
kind TEXT, -- 'adw' (the workflow process) | 'agent' (a coding-agent child)
|
|
86
101
|
name TEXT, -- '' for the adw, the agent name for a child
|
|
87
102
|
pid INTEGER,
|
|
@@ -89,7 +104,7 @@ CREATE TABLE IF NOT EXISTS processes (
|
|
|
89
104
|
started_at TEXT, ended_at TEXT -- ended_at NULL = believed alive
|
|
90
105
|
);
|
|
91
106
|
CREATE TABLE IF NOT EXISTS agent_sessions (
|
|
92
|
-
adw_id TEXT
|
|
107
|
+
adw_id TEXT,
|
|
93
108
|
agent TEXT,
|
|
94
109
|
coding_agent TEXT, model TEXT, color TEXT,
|
|
95
110
|
session_id TEXT,
|
|
@@ -111,50 +126,72 @@ const MIGRATIONS = [
|
|
|
111
126
|
];
|
|
112
127
|
export class Tracer {
|
|
113
128
|
db;
|
|
129
|
+
/** Absolute local sqlite path — `null` for a `kind:"d1"` config (`Console.sessionFinished`'s "db" line falls back to `runner.ts`'s `describeObservabilityDb` for a human label in that case). */
|
|
114
130
|
dbPath;
|
|
115
131
|
eventsJsonl;
|
|
116
132
|
/** `null` unless `observability.otel` is configured — see the header. */
|
|
117
133
|
otel;
|
|
118
|
-
constructor(dbPath, eventsJsonl, otel) {
|
|
119
|
-
this.
|
|
120
|
-
mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
134
|
+
constructor(db, dbPath, eventsJsonl, otel) {
|
|
135
|
+
this.db = db;
|
|
121
136
|
this.dbPath = dbPath;
|
|
122
137
|
this.eventsJsonl = eventsJsonl;
|
|
138
|
+
this.otel = otel;
|
|
123
139
|
mkdirSync(path.dirname(eventsJsonl), { recursive: true });
|
|
124
|
-
this.db = new Database(this.dbPath);
|
|
125
|
-
this.db.exec("PRAGMA journal_mode=WAL;");
|
|
126
|
-
this.db.exec("PRAGMA synchronous=NORMAL;");
|
|
127
|
-
this.db.exec("PRAGMA busy_timeout=5000;");
|
|
128
|
-
this.db.exec(SCHEMA);
|
|
129
|
-
this.migrate();
|
|
130
140
|
}
|
|
131
141
|
/**
|
|
132
|
-
*
|
|
142
|
+
* Open (or create) the trace db, run schema + migrations, and return a
|
|
143
|
+
* ready-to-use Tracer. The one constructor path — a plain `new Tracer(...)`
|
|
144
|
+
* cannot do this work itself because opening a D1-backed db means awaited
|
|
145
|
+
* HTTP calls before the schema exists, and a constructor cannot be async.
|
|
146
|
+
*
|
|
147
|
+
* `dbConfig` accepts either a bare local sqlite path (sugar for
|
|
148
|
+
* `{kind:"sqlite",path}` — every test's shorthand, and what every caller
|
|
149
|
+
* before SPF #66 passed) or a normalized `observability.db` descriptor
|
|
150
|
+
* (`paths.resolveDataPaths(...).db` — sqlite or d1).
|
|
151
|
+
*/
|
|
152
|
+
static async open(dbConfig, eventsJsonl, otel, traceDbOptions) {
|
|
153
|
+
const normalized = typeof dbConfig === "string" ? { kind: "sqlite", path: dbConfig } : dbConfig;
|
|
154
|
+
const db = createTraceDb(normalized, { fetchImpl: traceDbOptions?.fetchImpl });
|
|
155
|
+
if (normalized.kind === "sqlite") {
|
|
156
|
+
// WAL + the busy/synchronous pragmas below are meaningless over D1's
|
|
157
|
+
// HTTP API (there is no local journal file to configure), so this
|
|
158
|
+
// block only ever runs for the local backend.
|
|
159
|
+
await db.exec("PRAGMA journal_mode=WAL;");
|
|
160
|
+
await db.exec("PRAGMA synchronous=NORMAL;");
|
|
161
|
+
await db.exec("PRAGMA busy_timeout=5000;");
|
|
162
|
+
}
|
|
163
|
+
await db.exec(SCHEMA);
|
|
164
|
+
const tracer = new Tracer(db, normalized.kind === "sqlite" ? normalized.path : null, eventsJsonl, otel ?? null);
|
|
165
|
+
await tracer.migrate();
|
|
166
|
+
return tracer;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Close the trace db handle. A one-shot CLI process never needs this — it
|
|
133
170
|
* exits right after its one Tracer anyway — but `spf watch`'s daemon loop
|
|
134
|
-
* builds a fresh Tracer (and a fresh
|
|
135
|
-
* issue, in-process, for the life of the
|
|
136
|
-
* issue's handle stayed open forever.
|
|
137
|
-
* `finalize()`, once a run's own dispatch has
|
|
138
|
-
* comment for why that timing is safe.
|
|
171
|
+
* builds a fresh Tracer (and a fresh local db connection, or a fresh
|
|
172
|
+
* stateless D1 client) per claimed issue, in-process, for the life of the
|
|
173
|
+
* daemon; without this, every issue's local handle stayed open forever.
|
|
174
|
+
* Called from `session.ts`'s `finalize()`, once a run's own dispatch has
|
|
175
|
+
* fully settled — see its comment for why that timing is safe. A no-op for
|
|
176
|
+
* `D1TraceDb` (stateless HTTP — nothing to release).
|
|
139
177
|
*/
|
|
140
|
-
close() {
|
|
141
|
-
this.db.close();
|
|
178
|
+
async close() {
|
|
179
|
+
await this.db.close();
|
|
142
180
|
}
|
|
143
181
|
/** Additive column migrations, so a db from an older SPF still opens. */
|
|
144
|
-
migrate() {
|
|
182
|
+
async migrate() {
|
|
145
183
|
for (const [table, column, decl] of MIGRATIONS) {
|
|
146
|
-
const columns = new Set(this.db.query(`PRAGMA table_info(${table})`).all().map((row) => row.name));
|
|
184
|
+
const columns = new Set((await this.db.query(`PRAGMA table_info(${table})`).all()).map((row) => row.name));
|
|
147
185
|
if (!columns.has(column)) {
|
|
148
|
-
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${decl}`);
|
|
186
|
+
await this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${decl}`);
|
|
149
187
|
}
|
|
150
188
|
}
|
|
151
189
|
}
|
|
152
190
|
/**
|
|
153
|
-
* The ONE door to the optional otel projection
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
* failed phase, because export is not allowed to dispose of anything. The
|
|
191
|
+
* The ONE door to the optional otel projection. It is a no-op when
|
|
192
|
+
* unconfigured, and it swallows everything: an exporter bug, a malformed
|
|
193
|
+
* span, an exhausted queue — none of it may ever surface as a failed
|
|
194
|
+
* phase, because export is not allowed to dispose of anything. The
|
|
158
195
|
* exporter's own methods are synchronous enqueues; the network happens later,
|
|
159
196
|
* on an unref'd timer.
|
|
160
197
|
*/
|
|
@@ -171,12 +208,12 @@ export class Tracer {
|
|
|
171
208
|
}
|
|
172
209
|
}
|
|
173
210
|
// ── events ──────────────────────────────────────────────────────────────
|
|
174
|
-
event(record) {
|
|
211
|
+
async event(record) {
|
|
175
212
|
const eventId = `evt_${newId(12)}`;
|
|
176
213
|
const ts = nowIso();
|
|
177
214
|
const line = { event_id: eventId, ts, ...record };
|
|
178
215
|
appendFileSync(this.eventsJsonl, JSON.stringify(line) + "\n");
|
|
179
|
-
this.db
|
|
216
|
+
await this.db
|
|
180
217
|
.query(`INSERT INTO events (event_id, adw_id, phase_id, parent_id, type, name,
|
|
181
218
|
payload_json, tokens, started_at, ended_at) VALUES (?,?,?,?,?,?,?,?,?,?)`)
|
|
182
219
|
.run(eventId, record.adw_id, record.phase_id, record.parent_id, record.type, record.name, JSON.stringify(record.payload), record.tokens ?? null, record.started_at || ts, record.ended_at ?? null);
|
|
@@ -184,37 +221,72 @@ export class Tracer {
|
|
|
184
221
|
return eventId;
|
|
185
222
|
}
|
|
186
223
|
// ── sessions ────────────────────────────────────────────────────────────
|
|
187
|
-
sessionStart(adwId, engineer, adwName) {
|
|
224
|
+
async sessionStart(adwId, engineer, adwName) {
|
|
188
225
|
const startedAt = nowIso();
|
|
189
|
-
this.db
|
|
226
|
+
await this.db
|
|
190
227
|
.query(`INSERT INTO sessions (adw_id, status, engineer, started_at) VALUES (?,?,?,?)
|
|
191
228
|
ON CONFLICT(adw_id) DO UPDATE SET status='running'`)
|
|
192
229
|
.run(adwId, "running", engineer, startedAt);
|
|
193
230
|
if (adwName) {
|
|
194
231
|
// A joined session chains ADWs — record each distinct one, in run order.
|
|
195
|
-
const row = this.db.query("SELECT adw_name FROM sessions WHERE adw_id=?").get(adwId);
|
|
232
|
+
const row = (await this.db.query("SELECT adw_name FROM sessions WHERE adw_id=?").get(adwId));
|
|
196
233
|
const names = row?.adw_name ? row.adw_name.split(" + ") : [];
|
|
197
234
|
if (!names.includes(adwName)) {
|
|
198
235
|
names.push(adwName);
|
|
199
|
-
this.db.query("UPDATE sessions SET adw_name=? WHERE adw_id=?").run(names.join(" + "), adwId);
|
|
236
|
+
await this.db.query("UPDATE sessions SET adw_name=? WHERE adw_id=?").run(names.join(" + "), adwId);
|
|
200
237
|
}
|
|
201
238
|
}
|
|
202
239
|
// otel projection: the run's clock only. `engineer` is a person's name —
|
|
203
240
|
// outside the allowlist, and not a measure of anything.
|
|
204
241
|
this.fanOut((otel) => otel.recordSessionStart(startedAt));
|
|
205
242
|
}
|
|
206
|
-
sessionRequest(adwId, request) {
|
|
207
|
-
this.db.query("UPDATE sessions SET request=? WHERE adw_id=?").run(request.slice(0, 500), adwId);
|
|
243
|
+
async sessionRequest(adwId, request) {
|
|
244
|
+
await this.db.query("UPDATE sessions SET request=? WHERE adw_id=?").run(request.slice(0, 500), adwId);
|
|
208
245
|
}
|
|
209
|
-
sessionFinish(adwId, ok) {
|
|
210
|
-
this
|
|
246
|
+
async sessionFinish(adwId, ok) {
|
|
247
|
+
// otel projection FIRST — synchronously, before ANY `await` in this
|
|
248
|
+
// method, including the ones below. `session.ts`'s `handleSignal` fires
|
|
249
|
+
// this method un-awaited and then calls `otel.flushAll()` -> `drain()`
|
|
250
|
+
// in that SAME synchronous turn (no microtask has run yet). `drain()`
|
|
251
|
+
// emits a placeholder "incomplete" root span the instant `rootEmitted`
|
|
252
|
+
// is still false, and `OtelExporter.emitRootSpan` latches on that flag —
|
|
253
|
+
// so once the placeholder fires, the real verdict below is silently
|
|
254
|
+
// dropped forever. `recordSessionFinish` -> `emitRootSpan` is a pure,
|
|
255
|
+
// synchronous, in-memory queue push (see otel.ts): safe to fire before
|
|
256
|
+
// either write below has actually landed, and it must fire here, not
|
|
257
|
+
// after them, to win that race unconditionally on both backends.
|
|
258
|
+
this.fanOut((otel) => otel.recordSessionFinish(ok)); // otel projection — see fanOut
|
|
259
|
+
// `processesEndAll` is CALLED (not awaited) before the sessions-row
|
|
260
|
+
// update below, on purpose: `session.ts`'s SIGINT/SIGTERM handler fires
|
|
261
|
+
// this method un-awaited and, on the local backend with no otel/notify
|
|
262
|
+
// configured, calls `process.exit()` in the very same synchronous turn —
|
|
263
|
+
// which never drains the microtask queue. `LocalTraceDb`'s writes happen
|
|
264
|
+
// synchronously the moment `.run(...)` is CALLED (only the `await` after
|
|
265
|
+
// it is deferred), so calling this first guarantees its write has
|
|
266
|
+
// already landed by the time this function reaches its own first
|
|
267
|
+
// `await`, exactly like the sessions-row write below. Awaiting it only
|
|
268
|
+
// after starting the sessions-row update would put it entirely after
|
|
269
|
+
// that update's `await`, i.e. in a microtask `process.exit()` never
|
|
270
|
+
// reaches — see session.ts's `handleSignal` for the full trace of why.
|
|
271
|
+
const processesDone = this.processesEndAll(adwId); // nothing of this run is alive any more
|
|
272
|
+
// Attach a handler to `processesDone` IMMEDIATELY — not after the
|
|
273
|
+
// sessions-row write below — so it can never become an unhandled
|
|
274
|
+
// rejection (fatal on Node 22). If the sessions-row write throws first,
|
|
275
|
+
// execution never reaches the `await processesDone` further down; without
|
|
276
|
+
// this line, `processesDone`'s own eventual rejection would then have no
|
|
277
|
+
// attached handler at all. `processesResult` resolves to the error
|
|
278
|
+
// (never rethrows here) so both writes stay independently handled; it is
|
|
279
|
+
// re-thrown below only once the sessions-row write has itself succeeded.
|
|
280
|
+
const processesResult = processesDone.then(() => null, (err) => err);
|
|
281
|
+
await this.db
|
|
211
282
|
.query("UPDATE sessions SET status=?, ended_at=? WHERE adw_id=?")
|
|
212
283
|
.run(ok ? "success" : "fail", nowIso(), adwId);
|
|
213
|
-
|
|
214
|
-
|
|
284
|
+
const processesError = await processesResult;
|
|
285
|
+
if (processesError)
|
|
286
|
+
throw processesError;
|
|
215
287
|
}
|
|
216
|
-
sessionAddUsage(adwId, tokens, cost) {
|
|
217
|
-
this.db
|
|
288
|
+
async sessionAddUsage(adwId, tokens, cost) {
|
|
289
|
+
await this.db
|
|
218
290
|
.query("UPDATE sessions SET total_tokens=total_tokens+?, total_cost=total_cost+? WHERE adw_id=?")
|
|
219
291
|
.run(tokens, cost, adwId);
|
|
220
292
|
}
|
|
@@ -227,22 +299,22 @@ export class Tracer {
|
|
|
227
299
|
* belongs to. Writing it here makes the trace the answer to "what is this
|
|
228
300
|
* run running, and how do I stop it".
|
|
229
301
|
*/
|
|
230
|
-
processStart(adwId, kind, name, pid, command) {
|
|
231
|
-
this.db
|
|
302
|
+
async processStart(adwId, kind, name, pid, command) {
|
|
303
|
+
await this.db
|
|
232
304
|
.query(`INSERT INTO processes (adw_id, kind, name, pid, command, started_at) VALUES (?,?,?,?,?,?)`)
|
|
233
305
|
.run(adwId, kind, name, pid, command.slice(0, 500), nowIso());
|
|
234
306
|
}
|
|
235
307
|
/** Mark the newest live row for this pid as finished. */
|
|
236
|
-
processEnd(adwId, pid) {
|
|
237
|
-
this.db
|
|
308
|
+
async processEnd(adwId, pid) {
|
|
309
|
+
await this.db
|
|
238
310
|
.query(`UPDATE processes SET ended_at=? WHERE id = (
|
|
239
311
|
SELECT id FROM processes WHERE adw_id=? AND pid=? AND ended_at IS NULL
|
|
240
312
|
ORDER BY id DESC LIMIT 1)`)
|
|
241
313
|
.run(nowIso(), adwId, pid);
|
|
242
314
|
}
|
|
243
315
|
/** Close out every live row for a run — called when the session ends. */
|
|
244
|
-
processesEndAll(adwId) {
|
|
245
|
-
this.db.query("UPDATE processes SET ended_at=? WHERE adw_id=? AND ended_at IS NULL").run(nowIso(), adwId);
|
|
316
|
+
async processesEndAll(adwId) {
|
|
317
|
+
await this.db.query("UPDATE processes SET ended_at=? WHERE adw_id=? AND ended_at IS NULL").run(nowIso(), adwId);
|
|
246
318
|
}
|
|
247
319
|
// ── phases ──────────────────────────────────────────────────────────────
|
|
248
320
|
/**
|
|
@@ -253,13 +325,13 @@ export class Tracer {
|
|
|
253
325
|
* ordering) and `phase_id` (silently overwriting a row through the
|
|
254
326
|
* phase_upsert conflict clause).
|
|
255
327
|
*/
|
|
256
|
-
maxPhaseSeq(adwId) {
|
|
257
|
-
const row = this.db.query("SELECT MAX(seq) as m FROM phases WHERE adw_id = ?").get(adwId);
|
|
328
|
+
async maxPhaseSeq(adwId) {
|
|
329
|
+
const row = (await this.db.query("SELECT MAX(seq) as m FROM phases WHERE adw_id = ?").get(adwId));
|
|
258
330
|
return row?.m ?? 0;
|
|
259
331
|
}
|
|
260
|
-
phaseUpsert(phase) {
|
|
332
|
+
async phaseUpsert(phase) {
|
|
261
333
|
const p = phase.params;
|
|
262
|
-
this.db
|
|
334
|
+
await this.db
|
|
263
335
|
.query(`INSERT INTO phases (phase_id, adw_id, seq, name, kind, owner, description,
|
|
264
336
|
status, attempt, retries, error, started_at, ended_at)
|
|
265
337
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
@@ -271,15 +343,15 @@ export class Tracer {
|
|
|
271
343
|
this.fanOut((otel) => otel.recordPhase(phase));
|
|
272
344
|
}
|
|
273
345
|
// ── envelopes / gates / agent sessions ──────────────────────────────────
|
|
274
|
-
envelopeRow(phase, agent, outputType, payloadJson, valid, attempt) {
|
|
275
|
-
this.db
|
|
346
|
+
async envelopeRow(phase, agent, outputType, payloadJson, valid, attempt) {
|
|
347
|
+
await this.db
|
|
276
348
|
.query(`INSERT INTO envelopes (envelope_id, adw_id, phase_id, agent, output_type,
|
|
277
349
|
payload_json, valid, attempt, created_at) VALUES (?,?,?,?,?,?,?,?,?)`)
|
|
278
350
|
.run(`env_${newId(12)}`, phase.adw_id, phase.phase_id, agent, outputType, payloadJson, valid ? 1 : 0, attempt, nowIso());
|
|
279
351
|
}
|
|
280
352
|
/** The report carries both the verdict and the evidence behind it. */
|
|
281
|
-
gateRow(phase, gate, report, attempt) {
|
|
282
|
-
this.db
|
|
353
|
+
async gateRow(phase, gate, report, attempt) {
|
|
354
|
+
await this.db
|
|
283
355
|
.query(`INSERT INTO gate_results (adw_id, phase_id, attempt, gate, passed,
|
|
284
356
|
violations_json, checks_json, created_at) VALUES (?,?,?,?,?,?,?,?)`)
|
|
285
357
|
.run(phase.adw_id, phase.phase_id, attempt, gate, report.passed ? 1 : 0, JSON.stringify(report.violations), JSON.stringify(report.checks), nowIso());
|
|
@@ -295,9 +367,9 @@ export class Tracer {
|
|
|
295
367
|
* wants one number per agent — the latest — and a session that runs the
|
|
296
368
|
* same agent twice overwrites it, exactly like model and session_id.
|
|
297
369
|
*/
|
|
298
|
-
agentSessionRow(adwId, agent, sessionId, contextTokens = 0, contextWindow = 0) {
|
|
370
|
+
async agentSessionRow(adwId, agent, sessionId, contextTokens = 0, contextWindow = 0) {
|
|
299
371
|
const ts = nowIso();
|
|
300
|
-
this.db
|
|
372
|
+
await this.db
|
|
301
373
|
.query(`INSERT INTO agent_sessions (adw_id, agent, coding_agent, model, color,
|
|
302
374
|
session_id, context_tokens, context_window, created_at, last_used_at)
|
|
303
375
|
VALUES (?,?,?,?,?,?,?,?,?,?)
|
package/dist/core/watch.d.ts
CHANGED
|
@@ -87,7 +87,7 @@ export interface WatchFanoutDeps {
|
|
|
87
87
|
/** One attempt's chain, in that attempt's own worktree — the same ctx `runChain` builds, per attempt. */
|
|
88
88
|
runAttempt: (dispatch: AttemptDispatch) => Promise<number>;
|
|
89
89
|
/** Gate/usage rows for one attempt's adw_id, from the SHARED db. Must not throw. */
|
|
90
|
-
readMetrics: (adwId: string) => AttemptMetrics
|
|
90
|
+
readMetrics: (adwId: string) => Promise<AttemptMetrics>;
|
|
91
91
|
/**
|
|
92
92
|
* True when NOT ONE of these adw_ids has a session row yet — the same db,
|
|
93
93
|
* the same predicate and the same reasoning as `spf fanout`'s reuse
|
|
@@ -103,16 +103,16 @@ export interface WatchFanoutDeps {
|
|
|
103
103
|
* rather than reporting "free" and letting a previous run's rows decide
|
|
104
104
|
* this run's winner.
|
|
105
105
|
*/
|
|
106
|
-
adwIdsFree: (adwIds: string[]) => boolean
|
|
106
|
+
adwIdsFree: (adwIds: string[]) => Promise<boolean>;
|
|
107
107
|
/** The winner's review posture, read back post-hoc — the same two fields `runChain` returns, keyed on a WINNER instead of the sole attempt. */
|
|
108
108
|
reviewFor: (opts: {
|
|
109
109
|
cwd: string;
|
|
110
110
|
adwId: string;
|
|
111
111
|
chainOptions: Record<string, string>;
|
|
112
|
-
}) => {
|
|
112
|
+
}) => Promise<{
|
|
113
113
|
reviewRequired: boolean;
|
|
114
114
|
reviewSummary?: string;
|
|
115
|
-
}
|
|
115
|
+
}>;
|
|
116
116
|
}
|
|
117
117
|
export interface WatchDeps {
|
|
118
118
|
provider: IssueProvider;
|
package/dist/core/watch.js
CHANGED
|
@@ -878,7 +878,7 @@ async function runIssueFanout(deps, issue, fanout) {
|
|
|
878
878
|
let salt = 0;
|
|
879
879
|
for (; salt <= MAX_FANOUT_SALT; salt++) {
|
|
880
880
|
const ids = Array.from({ length: n }, (_, i) => attemptAdwId(baseFor(issue, salt), i + 1));
|
|
881
|
-
if (fanout.adwIdsFree(ids))
|
|
881
|
+
if (await fanout.adwIdsFree(ids))
|
|
882
882
|
break;
|
|
883
883
|
}
|
|
884
884
|
const baseAdwId = salt <= MAX_FANOUT_SALT ? baseFor(issue, salt) : `issue-${issue.id}-x${newId(4)}`;
|
|
@@ -994,7 +994,7 @@ async function runIssueFanout(deps, issue, fanout) {
|
|
|
994
994
|
deps.git.deleteLocalBranch(winner.branch);
|
|
995
995
|
won = { worktree: winner.worktree, branch };
|
|
996
996
|
await deps.provider.writeMarker(issue, { worktree: won.worktree, branch: won.branch, attempt: 0 });
|
|
997
|
-
const review = fanout.reviewFor({ cwd: winner.worktree, adwId: winner.adw_id, chainOptions: deps.chainOptions });
|
|
997
|
+
const review = await fanout.reviewFor({ cwd: winner.worktree, adwId: winner.adw_id, chainOptions: deps.chainOptions });
|
|
998
998
|
// `won` stays set from here on — openPrForWinner catches nothing, so
|
|
999
999
|
// this function's own catch (below) is the only cleanup for a throw
|
|
1000
1000
|
// from push/openPr/writeMarker/transition/notify inside it, exactly as
|
package/dist/ui/server/app.js
CHANGED
|
@@ -39,11 +39,11 @@ export function createApp(db, webDir) {
|
|
|
39
39
|
await next();
|
|
40
40
|
c.header("cache-control", "no-store");
|
|
41
41
|
});
|
|
42
|
-
app.get("/api/health", (c) => c.json({ ok: true, db: db.
|
|
43
|
-
app.get("/api/sessions", (c) => c.json(db.sessions(intQueryParam(c.req.query("limit"), 200))));
|
|
44
|
-
app.get("/api/sessions/:adw_id", (c) => {
|
|
42
|
+
app.get("/api/health", async (c) => c.json({ ok: true, db: db.label, journal_mode: db.journalMode, sessions: await db.sessionCount() }));
|
|
43
|
+
app.get("/api/sessions", async (c) => c.json(await db.sessions(intQueryParam(c.req.query("limit"), 200))));
|
|
44
|
+
app.get("/api/sessions/:adw_id", async (c) => {
|
|
45
45
|
const adwId = c.req.param("adw_id");
|
|
46
|
-
const detail = db.sessionDetail(adwId);
|
|
46
|
+
const detail = await db.sessionDetail(adwId);
|
|
47
47
|
if (!detail)
|
|
48
48
|
return c.json({ error: `no session ${adwId}` }, 404);
|
|
49
49
|
return c.json(detail);
|
|
@@ -56,22 +56,22 @@ export function createApp(db, webDir) {
|
|
|
56
56
|
return c.json({ error: "invalid adw_id" }, 400);
|
|
57
57
|
const body = (await c.req.json().catch(() => ({})));
|
|
58
58
|
const archived = body.archived === undefined ? true : Boolean(body.archived);
|
|
59
|
-
if (!db.setArchived(adwId, archived))
|
|
59
|
+
if (!(await db.setArchived(adwId, archived)))
|
|
60
60
|
return c.json({ error: `no session ${adwId}` }, 404);
|
|
61
61
|
return c.json({ adw_id: adwId, archived });
|
|
62
62
|
});
|
|
63
|
-
app.get("/api/sessions/:adw_id/events", (c) => c.json(db.events(c.req.param("adw_id"), intQueryParam(c.req.query("after"), 0), intQueryParam(c.req.query("limit"), 500))));
|
|
64
|
-
app.get("/api/sessions/:adw_id/envelopes", (c) => c.json(db.envelopes(c.req.param("adw_id"))));
|
|
65
|
-
app.get("/api/sessions/:adw_id/gates", (c) => c.json(db.gates(c.req.param("adw_id"))));
|
|
63
|
+
app.get("/api/sessions/:adw_id/events", async (c) => c.json(await db.events(c.req.param("adw_id"), intQueryParam(c.req.query("after"), 0), intQueryParam(c.req.query("limit"), 500))));
|
|
64
|
+
app.get("/api/sessions/:adw_id/envelopes", async (c) => c.json(await db.envelopes(c.req.param("adw_id"))));
|
|
65
|
+
app.get("/api/sessions/:adw_id/gates", async (c) => c.json(await db.gates(c.req.param("adw_id"))));
|
|
66
66
|
// The exact prompts an agent was sent, read from the session dir. Files are
|
|
67
67
|
// the raw record; the db has no copy of them.
|
|
68
|
-
app.get("/api/sessions/:adw_id/agents/:agent/prompts", (c) => {
|
|
68
|
+
app.get("/api/sessions/:adw_id/agents/:agent/prompts", async (c) => {
|
|
69
69
|
const adwId = c.req.param("adw_id");
|
|
70
70
|
const agent = c.req.param("agent");
|
|
71
71
|
if (!isSafeSegment(adwId) || !isSafeSegment(agent)) {
|
|
72
72
|
return c.json({ error: "invalid adw_id or agent" }, 400);
|
|
73
73
|
}
|
|
74
|
-
if (!db.session(adwId))
|
|
74
|
+
if (!(await db.session(adwId)))
|
|
75
75
|
return c.json({ error: `no session ${adwId}` }, 404);
|
|
76
76
|
const dir = resolve(db.sessionsDir, adwId, agent, "prompts");
|
|
77
77
|
// Defense in depth: the segment check already forbids traversal.
|