@gr8ful/spf 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +15 -5
  2. package/assets/skill/references/config.md +9 -5
  3. package/assets/skill/references/observability.md +57 -12
  4. package/assets/templates/ts-opencode.spf.config.yaml +54 -0
  5. package/dist/chains/index.js +1 -1
  6. package/dist/chains/simple_sdlc.d.ts +2 -2
  7. package/dist/chains/simple_sdlc.js +13 -13
  8. package/dist/chains/steps.d.ts +2 -2
  9. package/dist/chains/steps.js +35 -19
  10. package/dist/cli/commands/abort.d.ts +1 -1
  11. package/dist/cli/commands/abort.js +30 -3
  12. package/dist/cli/commands/doctor.js +109 -8
  13. package/dist/cli/commands/estimate.js +3 -3
  14. package/dist/cli/commands/events.js +4 -4
  15. package/dist/cli/commands/fanout.js +93 -21
  16. package/dist/cli/commands/loop.js +31 -32
  17. package/dist/cli/commands/migrate.js +8 -1
  18. package/dist/cli/commands/phases.js +2 -2
  19. package/dist/cli/commands/sessions.js +2 -2
  20. package/dist/cli/commands/trace.d.ts +28 -8
  21. package/dist/cli/commands/trace.js +28 -15
  22. package/dist/cli/commands/ui.js +15 -5
  23. package/dist/cli/commands/watch.js +27 -27
  24. package/dist/cli/index.js +3 -1
  25. package/dist/cli/interview.d.ts +1 -0
  26. package/dist/cli/interview.js +86 -4
  27. package/dist/core/agent_opencode.d.ts +247 -0
  28. package/dist/core/agent_opencode.js +590 -0
  29. package/dist/core/agents.d.ts +12 -12
  30. package/dist/core/agents.js +113 -46
  31. package/dist/core/console.d.ts +12 -12
  32. package/dist/core/console.js +25 -25
  33. package/dist/core/data_types.d.ts +126 -12
  34. package/dist/core/data_types.js +101 -4
  35. package/dist/core/fanout.d.ts +1 -1
  36. package/dist/core/fanout.js +1 -1
  37. package/dist/core/gates.js +14 -1
  38. package/dist/core/paths.d.ts +41 -4
  39. package/dist/core/paths.js +32 -3
  40. package/dist/core/quality.d.ts +7 -7
  41. package/dist/core/quality.js +16 -10
  42. package/dist/core/runner.d.ts +9 -3
  43. package/dist/core/runner.js +39 -27
  44. package/dist/core/session.d.ts +2 -2
  45. package/dist/core/session.js +39 -18
  46. package/dist/core/sqlite.d.ts +14 -7
  47. package/dist/core/sqlite.js +14 -7
  48. package/dist/core/trace_db.d.ts +118 -0
  49. package/dist/core/trace_db.js +278 -0
  50. package/dist/core/tracer.d.ts +64 -34
  51. package/dist/core/tracer.js +141 -69
  52. package/dist/core/watch.d.ts +4 -4
  53. package/dist/core/watch.js +2 -2
  54. package/dist/ui/server/app.js +10 -10
  55. package/dist/ui/server/db.d.ts +89 -21
  56. package/dist/ui/server/db.js +235 -99
  57. package/dist/ui/server/serve.d.ts +5 -1
  58. package/dist/ui/server/serve.js +4 -5
  59. package/package.json +1 -1
  60. package/web/assets/index-CQ3k1Y1-.css +1 -0
  61. package/web/assets/index-CU8tom6S.js +21 -0
  62. package/web/index.html +2 -2
  63. package/web/assets/index-CRujNW-1.js +0 -11
  64. package/web/assets/index-Cto6nuQL.css +0 -1
@@ -1,9 +1,19 @@
1
1
  /**
2
- * SQLite reader over a target repo's spf.db.
2
+ * Trace-db reader over a target repo's observability.db (local sqlite, or a
3
+ * remote Cloudflare D1 database — see `core/trace_db.ts`).
3
4
  *
4
- * The read connection is opened readonly and every query on it is a SELECT —
5
- * the writers are the tracers of running ADW processes, and WAL lets us read
6
- * straight through their inserts.
5
+ * The read connection is opened readonly (local backend) and every query on
6
+ * it is a SELECT — the writers are the tracers of running ADW processes,
7
+ * and (for the LOCAL backend) WAL lets us read straight through their
8
+ * inserts. See `core/trace_db.ts`'s `D1TraceDb` header for why that same
9
+ * live-read guarantee does NOT hold for a D1-backed repo — a deliberate,
10
+ * documented trade-off (SPF #66), not something this class works around.
11
+ *
12
+ * Every method here is now async — even for the local backend, which pays
13
+ * nothing for it (`LocalTraceDb` resolves immediately) — because the D1
14
+ * backend's only way to reach a database from this plain Node CLI is a real
15
+ * HTTP call. See `core/tracer.ts`'s header for the fuller version of this
16
+ * same rationale on the write side.
7
17
  *
8
18
  * ONE exception, opened lazily on its own connection: `setArchived`. Archiving
9
19
  * is review triage — "I have looked at this run" — which has to outlive a
@@ -13,45 +23,140 @@
13
23
  */
14
24
  import { existsSync } from "node:fs";
15
25
  import { dirname, resolve } from "node:path";
16
- import { Database } from "../../core/sqlite.js";
26
+ import { createTraceDb } from "../../core/trace_db.js";
17
27
  const MAX_LIMIT = 1000;
18
28
  const DEFAULT_LIMIT = 500;
19
29
  export class SfDb {
30
+ /** Absolute local sqlite path — `null` for a `kind:"d1"` config. */
20
31
  path;
32
+ /** Human label for API/console display: the sqlite path, or `d1:<database_id>`. */
33
+ label;
21
34
  /**
22
35
  * Where the ADW session dirs live: `{data_dir}/sessions/{adw_id}/{agent}/`.
23
- * The db sits in the same data_dir (config's `observability.db` defaults to
24
- * `adws/adw_data/spf.db`), so deriving it as a sibling of the db file keeps
25
- * working when the whole data_dir is relocated.
36
+ * Passed in explicitly (from `paths.resolveDataPaths(...).sessions_dir`)
37
+ * rather than derived from the db path session JSONL/envelope artifact
38
+ * files always live on the local filesystem regardless of where the
39
+ * queryable trace mirror (`db` below) lives; see `paths.ts`'s `DataPaths`.
26
40
  */
27
41
  sessionsDir;
42
+ /** `"n/a (d1)"` for a remote backend — journal mode is a local-file concept. */
28
43
  journalMode;
29
44
  db;
30
- /** Opened on first archive and kept; null until then. */
45
+ dbConfig;
46
+ /** D1-only; carried from `open()`'s `options.fetchImpl` so a lazily-opened writer (`setArchived`) uses the same injected fetch a test gave the reader. */
47
+ fetchImpl;
48
+ /** Opened on first archive and kept; `null` until then. */
31
49
  writer = null;
32
50
  /** Cache for optionalColumn(), keyed "table.column". Only ever false → true. */
33
51
  columnCache = new Map();
34
- /** `path` is always absolute — resolved upstream by the ui command via paths.resolveAnchor/resolveDataPaths. */
35
- constructor(path) {
36
- if (!existsSync(path)) {
37
- throw new Error(`spf.db not found at ${path}\n` +
52
+ constructor(db, dbConfig, sessionsDir, journalMode, fetchImpl) {
53
+ this.db = db;
54
+ this.dbConfig = dbConfig;
55
+ this.path = dbConfig.kind === "sqlite" ? dbConfig.path : null;
56
+ this.label = dbConfig.kind === "sqlite" ? dbConfig.path : `d1:${dbConfig.database_id}`;
57
+ this.sessionsDir = sessionsDir;
58
+ this.journalMode = journalMode;
59
+ this.fetchImpl = fetchImpl;
60
+ }
61
+ /**
62
+ * Open a trace db for reading. `dbConfig` accepts either a bare local
63
+ * sqlite path (sugar for `{kind:"sqlite",path}` — `--db <path>`/`SF_DB`
64
+ * CLI overrides, and every test's shorthand) or a normalized
65
+ * `observability.db` descriptor (`paths.resolveDataPaths(...).db`).
66
+ *
67
+ * `sessionsDir` should be `paths.resolveDataPaths(...).sessions_dir`
68
+ * whenever the caller has a `DataPaths` in hand; omitted, it falls back to
69
+ * the historical "sibling of the sqlite file" derivation — only valid for
70
+ * the LOCAL backend (a bare-path caller has no other sessions dir to
71
+ * offer), so it is required when `dbConfig` names a `kind:"d1"` config.
72
+ *
73
+ * `options.fetchImpl` is D1-only and exists purely so tests can drive this
74
+ * end-to-end against a mocked D1 HTTP response shape — every real caller
75
+ * omits it and gets the global `fetch`, same as `createTraceDb` itself.
76
+ */
77
+ static async open(dbConfig, sessionsDir, options) {
78
+ const normalized = typeof dbConfig === "string" ? { kind: "sqlite", path: dbConfig } : dbConfig;
79
+ if (normalized.kind === "sqlite" && !existsSync(normalized.path)) {
80
+ throw new Error(`spf.db not found at ${normalized.path}\n` +
38
81
  `Point spf ui at a target repo: --db <path>, SF_DB=<path>, --cwd <repo>, or run it from inside one.`);
39
82
  }
40
- this.path = path;
41
- this.sessionsDir = resolve(dirname(path), "sessions");
42
- this.db = new Database(path, { readonly: true });
83
+ const resolvedSessionsDir = sessionsDir ??
84
+ (normalized.kind === "sqlite"
85
+ ? resolve(dirname(normalized.path), "sessions")
86
+ : (() => {
87
+ throw new Error("SfDb.open: sessionsDir is required for a d1-backed repo — pass paths.resolveDataPaths(...).sessions_dir");
88
+ })());
89
+ const db = createTraceDb(normalized, { readonly: true, fetchImpl: options?.fetchImpl });
43
90
  // WAL is set by the tracer when it creates the db; a readonly connection
44
91
  // cannot change it, so we assert rather than set, and always take the
45
92
  // busy_timeout so a concurrent writer never turns into a failed request.
46
- this.db.exec("PRAGMA busy_timeout = 5000");
47
- this.db.exec("PRAGMA synchronous = NORMAL");
48
- const mode = this.db
49
- .query("PRAGMA journal_mode")
50
- .get();
51
- this.journalMode = mode?.journal_mode ?? "unknown";
52
- if (this.journalMode.toLowerCase() !== "wal") {
53
- console.warn(`[db] journal_mode is "${this.journalMode}", expected "wal" — ` +
54
- `live reads during agent writes may block`);
93
+ // None of this has a D1 equivalent — journal_mode/busy_timeout are local
94
+ // sqlite-file concepts (see the class header for what D1 offers instead).
95
+ let journalMode = "n/a (d1)";
96
+ if (normalized.kind === "sqlite") {
97
+ await db.exec("PRAGMA busy_timeout = 5000");
98
+ await db.exec("PRAGMA synchronous = NORMAL");
99
+ const mode = await db.query("PRAGMA journal_mode").get();
100
+ journalMode = mode?.journal_mode ?? "unknown";
101
+ if (journalMode.toLowerCase() !== "wal") {
102
+ console.warn(`[db] journal_mode is "${journalMode}", expected "wal" — ` +
103
+ `live reads during agent writes may block`);
104
+ }
105
+ }
106
+ else {
107
+ // The sqlite branch above gets a friendly "spf.db not found" check
108
+ // before ever issuing a query; a D1 database has no file to check for
109
+ // existence, so an empty/never-written one instead surfaces as a raw
110
+ // "no such table: sessions" the first time any API route queries it.
111
+ // A cheap sqlite_master probe here gets the same friendly failure the
112
+ // local backend already has, at open time, in one place.
113
+ const table = await db
114
+ .query("SELECT name FROM sqlite_master WHERE name='sessions'")
115
+ .get();
116
+ if (!table) {
117
+ throw new Error(`D1 database ${JSON.stringify(normalized.database_id)} has no "sessions" table — ` +
118
+ `point spf ui at a target repo whose D1 database has been written to at least once ` +
119
+ `(run any ADW against it first), or check observability.db in spf.config.yaml.`);
120
+ }
121
+ }
122
+ return new SfDb(db, normalized, resolvedSessionsDir, journalMode, options?.fetchImpl);
123
+ }
124
+ /**
125
+ * Cheap "is there anything to read yet" check — the async equivalent of
126
+ * the `existsSync(dataPaths.db_path)` fast path every sqlite-only caller
127
+ * used before D1 existed, extended to cover the d1 case those callers
128
+ * never accounted for.
129
+ *
130
+ * `open()` deliberately THROWS a friendly, actionable error on a
131
+ * never-written db (see above) — the right behavior for `spf ui`, where
132
+ * "nothing here yet" is a misconfiguration to surface loudly. Callers that
133
+ * instead want to treat a never-written db as "empty, skip this check" —
134
+ * `spf fanout`'s collision preflight and lazy metrics reader, `spf loop`'s
135
+ * per-iteration readback, `spf estimate`'s cold-start path, `spf watch`'s
136
+ * salt-collision short-circuit — use this instead, so they behave
137
+ * identically whether the backend is local sqlite or a fresh D1 database
138
+ * that has never been written to.
139
+ *
140
+ * For a `kind:"sqlite"` db: `existsSync(dataPaths.db_path)`, unchanged.
141
+ * For a `kind:"d1"` db: the same `sqlite_master` probe `open()`'s
142
+ * friendly-error path already runs, but returning `false` instead of
143
+ * throwing when the `sessions` table isn't there yet. Any OTHER failure
144
+ * (network, auth, a malformed database_id) still throws — only "nothing
145
+ * written yet" collapses to `false`.
146
+ */
147
+ static async exists(dataPaths, options) {
148
+ if (dataPaths.db.kind === "sqlite") {
149
+ return dataPaths.db_path !== null && existsSync(dataPaths.db_path);
150
+ }
151
+ const db = createTraceDb(dataPaths.db, { readonly: true, fetchImpl: options?.fetchImpl });
152
+ try {
153
+ const table = await db
154
+ .query("SELECT name FROM sqlite_master WHERE name='sessions'")
155
+ .get();
156
+ return table !== undefined && table !== null;
157
+ }
158
+ finally {
159
+ await db.close();
55
160
  }
56
161
  }
57
162
  /**
@@ -68,22 +173,43 @@ export class SfDb {
68
173
  * NULL for the rest of the process even after the data arrived. Once seen,
69
174
  * a column never goes away, so it latches.
70
175
  */
71
- hasColumn(table, column) {
176
+ async hasColumn(table, column) {
72
177
  const key = `${table}.${column}`;
73
178
  if (!this.columnCache.get(key)) {
74
- const cols = this.db
179
+ const cols = await this.db
75
180
  .query(`PRAGMA table_info(${table})`)
76
181
  .all();
77
182
  this.columnCache.set(key, cols.some((c) => c.name === column));
78
183
  }
79
184
  return this.columnCache.get(key) ?? false;
80
185
  }
81
- optionalColumn(table, column) {
82
- return this.hasColumn(table, column) ? column : `NULL AS ${column}`;
186
+ async optionalColumn(table, column) {
187
+ return (await this.hasColumn(table, column)) ? column : `NULL AS ${column}`;
188
+ }
189
+ /**
190
+ * D1 caps bound parameters at 100 per statement; local sqlite allows tens
191
+ * of thousands. Any query that binds a whole id list into an `IN (...)`
192
+ * clause must go through this instead of a single unbounded bind, or it
193
+ * throws past ~100 ids on the D1 backend. Chunking is harmless on the
194
+ * local backend too (just slightly less efficient), so this is used
195
+ * unconditionally rather than special-cased per backend.
196
+ *
197
+ * Each id belongs to exactly one chunk, so per-id ordering from `fn`'s own
198
+ * ORDER BY is preserved — only the relative order of rows belonging to
199
+ * *different* chunks can interleave, which none of this class's callers
200
+ * depend on (they always regroup by id afterward).
201
+ */
202
+ async chunked(ids, fn, size = 90) {
203
+ const out = [];
204
+ for (let i = 0; i < ids.length; i += size) {
205
+ out.push(...(await fn(ids.slice(i, i + size))));
206
+ }
207
+ return out;
83
208
  }
84
- close() {
85
- this.writer?.close();
86
- this.db.close();
209
+ async close() {
210
+ if (this.writer)
211
+ await this.writer.close();
212
+ await this.db.close();
87
213
  }
88
214
  /**
89
215
  * Archive or restore a session — the only write in this process.
@@ -92,28 +218,29 @@ export class SfDb {
92
218
  * click should wait its turn rather than fail. Returns false when the id
93
219
  * does not exist, so the route can 404 instead of silently succeeding.
94
220
  */
95
- setArchived(adwId, archived) {
96
- if (!this.hasColumn("sessions", "archived")) {
221
+ async setArchived(adwId, archived) {
222
+ if (!(await this.hasColumn("sessions", "archived"))) {
97
223
  throw new Error("this db predates the archived column — run any ADW once to migrate it");
98
224
  }
99
225
  if (!this.writer) {
100
- this.writer = new Database(this.path);
101
- this.writer.exec("PRAGMA busy_timeout=5000;");
226
+ this.writer = createTraceDb(this.dbConfig, { fetchImpl: this.fetchImpl });
227
+ if (this.dbConfig.kind === "sqlite")
228
+ await this.writer.exec("PRAGMA busy_timeout=5000;");
102
229
  }
103
- this.writer
230
+ await this.writer
104
231
  .query("UPDATE sessions SET archived = ? WHERE adw_id = ?")
105
232
  .run(archived ? 1 : 0, adwId);
106
- return this.session(adwId) !== null;
233
+ return (await this.session(adwId)) !== null;
107
234
  }
108
235
  /** Sessions, most recent first, each with its phase statuses for the progress dots. */
109
- sessions(limit = 200) {
110
- const rows = this.db
111
- .query(`SELECT adw_id, ${this.optionalColumn("sessions", "adw_name")}, request,
236
+ async sessions(limit = 200) {
237
+ const rows = await this.db
238
+ .query(`SELECT adw_id, ${await this.optionalColumn("sessions", "adw_name")}, request,
112
239
  status, engineer, started_at, ended_at,
113
240
  total_tokens, total_cost,
114
- ${this.optionalColumn("sessions", "archived")}
241
+ ${await this.optionalColumn("sessions", "archived")}
115
242
  FROM sessions
116
- WHERE COALESCE(${this.hasColumn("sessions", "archived") ? "archived" : "0"}, 0) = 0
243
+ WHERE COALESCE(${(await this.hasColumn("sessions", "archived")) ? "archived" : "0"}, 0) = 0
117
244
  ORDER BY started_at DESC, rowid DESC
118
245
  LIMIT ?`)
119
246
  .all(clamp(limit, 1, MAX_LIMIT));
@@ -121,12 +248,14 @@ export class SfDb {
121
248
  return [];
122
249
  // Embed each session's phases so the L1 progress dots cost no extra request.
123
250
  const ids = rows.map((row) => row.adw_id);
124
- const placeholders = ids.map(() => "?").join(", ");
125
- const phaseRows = this.db
126
- .query(`SELECT phase_id, adw_id, seq, name, kind, owner, description, status,
127
- attempt, retries, error, started_at, ended_at
128
- FROM phases WHERE adw_id IN (${placeholders}) ORDER BY seq, rowid`)
129
- .all(...ids);
251
+ const phaseRows = await this.chunked(ids, (chunk) => {
252
+ const placeholders = chunk.map(() => "?").join(", ");
253
+ return this.db
254
+ .query(`SELECT phase_id, adw_id, seq, name, kind, owner, description, status,
255
+ attempt, retries, error, started_at, ended_at
256
+ FROM phases WHERE adw_id IN (${placeholders}) ORDER BY seq, rowid`)
257
+ .all(...chunk);
258
+ });
130
259
  const byAdw = new Map();
131
260
  for (const phase of phaseRows) {
132
261
  const list = byAdw.get(phase.adw_id);
@@ -137,7 +266,7 @@ export class SfDb {
137
266
  }
138
267
  // Agents come along too: an L1 card draws a per-agent dot timeline, and its
139
268
  // dots are colored per agent — without this it would be one request per card.
140
- const agentsByAdw = this.agentsFor(ids);
269
+ const agentsByAdw = await this.agentsFor(ids);
141
270
  const summaries = [];
142
271
  for (const session of rows) {
143
272
  const phases = byAdw.get(session.adw_id) ?? [];
@@ -149,23 +278,23 @@ export class SfDb {
149
278
  }
150
279
  return summaries;
151
280
  }
152
- session(adwId) {
153
- return (this.db
154
- .query(`SELECT adw_id, ${this.optionalColumn("sessions", "adw_name")}, request,
281
+ async session(adwId) {
282
+ return ((await this.db
283
+ .query(`SELECT adw_id, ${await this.optionalColumn("sessions", "adw_name")}, request,
155
284
  status, engineer, started_at, ended_at,
156
285
  total_tokens, total_cost
157
286
  FROM sessions WHERE adw_id = ?`)
158
- .get(adwId) ?? null);
287
+ .get(adwId)) ?? null);
159
288
  }
160
- phases(adwId) {
289
+ async phases(adwId) {
161
290
  return this.db
162
291
  .query(`SELECT phase_id, adw_id, seq, name, kind, owner, description, status,
163
292
  attempt, retries, error, started_at, ended_at
164
293
  FROM phases WHERE adw_id = ? ORDER BY seq, rowid`)
165
294
  .all(adwId);
166
295
  }
167
- agentSessions(adwId) {
168
- return this.agentsFor([adwId]).get(adwId) ?? [];
296
+ async agentSessions(adwId) {
297
+ return (await this.agentsFor([adwId])).get(adwId) ?? [];
169
298
  }
170
299
  /**
171
300
  * Agents per session, for a set of ids at once: the agent_sessions rows plus
@@ -176,11 +305,10 @@ export class SfDb {
176
305
  * for. Its model, color and session_id are already on the agent_start event,
177
306
  * so a lane is labelled and colored from the moment the agent spawns.
178
307
  */
179
- agentsFor(adwIds) {
308
+ async agentsFor(adwIds) {
180
309
  const byAdw = new Map();
181
310
  if (adwIds.length === 0)
182
311
  return byAdw;
183
- const placeholders = adwIds.map(() => "?").join(", ");
184
312
  const append = (adwId, agent) => {
185
313
  const list = byAdw.get(adwId);
186
314
  if (list)
@@ -188,23 +316,29 @@ export class SfDb {
188
316
  else
189
317
  byAdw.set(adwId, [agent]);
190
318
  };
191
- const color = this.optionalColumn("agent_sessions", "color");
192
- const ctxUsed = this.optionalColumn("agent_sessions", "context_tokens");
193
- const ctxWindow = this.optionalColumn("agent_sessions", "context_window");
194
- const completed = this.db
195
- .query(`SELECT adw_id, agent, coding_agent, model, session_id, ${color},
196
- ${ctxUsed}, ${ctxWindow}, created_at, last_used_at
197
- FROM agent_sessions WHERE adw_id IN (${placeholders})
198
- ORDER BY created_at, agent`)
199
- .all(...adwIds);
319
+ const color = await this.optionalColumn("agent_sessions", "color");
320
+ const ctxUsed = await this.optionalColumn("agent_sessions", "context_tokens");
321
+ const ctxWindow = await this.optionalColumn("agent_sessions", "context_window");
322
+ const completed = await this.chunked(adwIds, (chunk) => {
323
+ const placeholders = chunk.map(() => "?").join(", ");
324
+ return this.db
325
+ .query(`SELECT adw_id, agent, coding_agent, model, session_id, ${color},
326
+ ${ctxUsed}, ${ctxWindow}, created_at, last_used_at
327
+ FROM agent_sessions WHERE adw_id IN (${placeholders})
328
+ ORDER BY created_at, agent`)
329
+ .all(...chunk);
330
+ });
200
331
  for (const row of completed)
201
332
  append(row.adw_id, row);
202
- const started = this.db
203
- .query(`SELECT e.adw_id, p.owner AS agent, e.payload_json, e.started_at
204
- FROM events e JOIN phases p ON p.phase_id = e.phase_id
205
- WHERE e.adw_id IN (${placeholders}) AND e.type = 'agent_start'
206
- ORDER BY e.rowid`)
207
- .all(...adwIds);
333
+ const started = await this.chunked(adwIds, (chunk) => {
334
+ const placeholders = chunk.map(() => "?").join(", ");
335
+ return this.db
336
+ .query(`SELECT e.adw_id, p.owner AS agent, e.payload_json, e.started_at
337
+ FROM events e JOIN phases p ON p.phase_id = e.phase_id
338
+ WHERE e.adw_id IN (${placeholders}) AND e.type = 'agent_start'
339
+ ORDER BY e.rowid`)
340
+ .all(...chunk);
341
+ });
208
342
  for (const row of started) {
209
343
  if (!row.agent)
210
344
  continue;
@@ -235,15 +369,15 @@ export class SfDb {
235
369
  return byAdw;
236
370
  }
237
371
  /** Session + phases + agents in one shot — L2 needs all three to draw lanes. */
238
- sessionDetail(adwId) {
239
- const session = this.session(adwId);
372
+ async sessionDetail(adwId) {
373
+ const session = await this.session(adwId);
240
374
  if (!session)
241
375
  return null;
242
376
  return {
243
377
  session,
244
- usage: this.usage(adwId),
245
- phases: this.phases(adwId),
246
- agents: this.agentSessions(adwId),
378
+ usage: await this.usage(adwId),
379
+ phases: await this.phases(adwId),
380
+ agents: await this.agentSessions(adwId),
247
381
  };
248
382
  }
249
383
  /**
@@ -258,8 +392,8 @@ export class SfDb {
258
392
  * material generated. The gap between them and the headline is cached
259
393
  * re-reads, which is usually most of it.
260
394
  */
261
- usage(adwId) {
262
- const rows = this.db
395
+ async usage(adwId) {
396
+ const rows = await this.db
263
397
  .query("SELECT payload_json FROM events WHERE adw_id = ? AND type = 'agent_end'")
264
398
  .all(adwId);
265
399
  let read = 0;
@@ -288,9 +422,9 @@ export class SfDb {
288
422
  * The polling query. Rowid cursor, insertion order, bounded page — the same
289
423
  * mechanism serves the live tail and lazy-paged history.
290
424
  */
291
- events(adwId, after = 0, limit = DEFAULT_LIMIT) {
425
+ async events(adwId, after = 0, limit = DEFAULT_LIMIT) {
292
426
  const cappedLimit = clamp(limit, 1, MAX_LIMIT);
293
- const events = this.db
427
+ const events = await this.db
294
428
  .query(`SELECT rowid, event_id, adw_id, phase_id, parent_id, type, name,
295
429
  payload_json, tokens, started_at, ended_at
296
430
  FROM events
@@ -304,23 +438,23 @@ export class SfDb {
304
438
  has_more: events.length === cappedLimit,
305
439
  };
306
440
  }
307
- envelopes(adwId) {
441
+ async envelopes(adwId) {
308
442
  return this.db
309
443
  .query(`SELECT envelope_id, adw_id, phase_id, agent, output_type, payload_json,
310
444
  valid, attempt, created_at
311
445
  FROM envelopes WHERE adw_id = ? ORDER BY created_at, rowid`)
312
446
  .all(adwId);
313
447
  }
314
- gates(adwId) {
315
- const checks = this.optionalColumn("gate_results", "checks_json");
448
+ async gates(adwId) {
449
+ const checks = await this.optionalColumn("gate_results", "checks_json");
316
450
  return this.db
317
451
  .query(`SELECT id, adw_id, phase_id, attempt, gate, passed, violations_json,
318
452
  ${checks}, created_at
319
453
  FROM gate_results WHERE adw_id = ? ORDER BY id`)
320
454
  .all(adwId);
321
455
  }
322
- sessionCount() {
323
- const row = this.db
456
+ async sessionCount() {
457
+ const row = await this.db
324
458
  .query("SELECT COUNT(*) AS n FROM sessions")
325
459
  .get();
326
460
  return row?.n ?? 0;
@@ -346,27 +480,29 @@ export class SfDb {
346
480
  * first (`spf estimate`'s own sample-selection order), which is a decision
347
481
  * `estimate.ts` makes, not this method.
348
482
  */
349
- chainPhaseHistory(chainName) {
350
- if (!this.hasColumn("sessions", "adw_name"))
483
+ async chainPhaseHistory(chainName) {
484
+ if (!(await this.hasColumn("sessions", "adw_name")))
351
485
  return { sessions: [], joinedExcluded: 0 };
352
- const joinedRow = this.db
486
+ const joinedRow = await this.db
353
487
  .query(`SELECT COUNT(*) AS n FROM sessions
354
488
  WHERE adw_name != ? AND (adw_name LIKE ? OR adw_name LIKE ? OR adw_name LIKE ?)`)
355
489
  .get(chainName, `${chainName} + %`, `% + ${chainName}`, `% + ${chainName} + %`);
356
- const sessionRows = this.db
490
+ const sessionRows = await this.db
357
491
  .query(`SELECT adw_id, status, started_at, total_tokens, total_cost
358
492
  FROM sessions WHERE adw_name = ? ORDER BY started_at DESC, rowid DESC`)
359
493
  .all(chainName);
360
494
  if (sessionRows.length === 0)
361
495
  return { sessions: [], joinedExcluded: joinedRow?.n ?? 0 };
362
496
  const ids = sessionRows.map((r) => r.adw_id);
363
- const placeholders = ids.map(() => "?").join(", ");
364
- const phaseRows = this.db
365
- .query(`SELECT p.adw_id, p.name, p.seq, e.tokens
366
- FROM phases p LEFT JOIN events e ON e.phase_id = p.phase_id AND e.type = 'agent_end'
367
- WHERE p.adw_id IN (${placeholders})
368
- ORDER BY p.seq ASC`)
369
- .all(...ids);
497
+ const phaseRows = await this.chunked(ids, (chunk) => {
498
+ const placeholders = chunk.map(() => "?").join(", ");
499
+ return this.db
500
+ .query(`SELECT p.adw_id, p.name, p.seq, e.tokens
501
+ FROM phases p LEFT JOIN events e ON e.phase_id = p.phase_id AND e.type = 'agent_end'
502
+ WHERE p.adw_id IN (${placeholders})
503
+ ORDER BY p.seq ASC`)
504
+ .all(...chunk);
505
+ });
370
506
  const phasesByAdw = new Map();
371
507
  for (const row of phaseRows) {
372
508
  const list = phasesByAdw.get(row.adw_id);
@@ -1,5 +1,9 @@
1
+ import type { NormalizedObservabilityDb } from "../../core/data_types.ts";
1
2
  export interface UiOptions {
2
- dbPath: string;
3
+ /** Bare local sqlite path (sugar for `{kind:"sqlite",path}`), or a normalized `observability.db` descriptor. */
4
+ db: NormalizedObservabilityDb | string;
5
+ /** `paths.resolveDataPaths(...).sessions_dir` — required whenever `db` is `kind:"d1"`; see `SfDb.open`'s doc comment. */
6
+ sessionsDir?: string;
3
7
  webDir: string;
4
8
  /** Explicit port. Omit to try the default and probe upward on collision. */
5
9
  port?: number;
@@ -35,7 +35,7 @@ function openUrl(url) {
35
35
  }
36
36
  }
37
37
  export async function runUi(options) {
38
- const db = new SfDb(options.dbPath);
38
+ const db = await SfDb.open(options.db, options.sessionsDir);
39
39
  const app = createApp(db, options.webDir);
40
40
  const explicit = options.port !== undefined;
41
41
  const startPort = options.port ?? DEFAULT_PORT;
@@ -52,7 +52,7 @@ export async function runUi(options) {
52
52
  if (!isAddrInUse(error))
53
53
  throw error;
54
54
  if (i === attempts - 1) {
55
- db.close();
55
+ await db.close();
56
56
  throw new Error(explicit
57
57
  ? `port ${port} is already in use — pick another with --port`
58
58
  : `ports ${startPort}-${port} are all in use — pick one explicitly with --port`);
@@ -65,10 +65,9 @@ export async function runUi(options) {
65
65
  const shouldOpen = options.open !== false && process.stdout.isTTY && !process.env.CI && !process.env.SSH_CONNECTION;
66
66
  if (shouldOpen)
67
67
  openUrl(url);
68
- const close = () => new Promise((resolvePromise) => {
68
+ const close = () => new Promise((resolvePromise, reject) => {
69
69
  server.close(() => {
70
- db.close();
71
- resolvePromise();
70
+ db.close().then(resolvePromise, reject);
72
71
  });
73
72
  });
74
73
  const shutdown = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gr8ful/spf",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "Super Portable Factory — a global CLI for repeatable agents-plus-code workflows (ADWs)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1 @@
1
+ @font-face{font-family:Overpass;font-style:normal;font-weight:400;font-display:swap;src:url(./overpass-latin-400-normal-BpeLJ0bs.woff2) format("woff2")}@font-face{font-family:Overpass;font-style:normal;font-weight:600;font-display:swap;src:url(./overpass-latin-600-normal-25RhTNCi.woff2) format("woff2")}@font-face{font-family:Overpass;font-style:normal;font-weight:700;font-display:swap;src:url(./overpass-latin-700-normal-CQX2QTgM.woff2) format("woff2")}@font-face{font-family:Overpass Mono;font-style:normal;font-weight:400;font-display:swap;src:url(./overpass-mono-latin-400-normal-VINZG6Js.woff2) format("woff2")}@font-face{font-family:Overpass Mono;font-style:normal;font-weight:700;font-display:swap;src:url(./overpass-mono-latin-700-normal-D6nRBrbd.woff2) format("woff2")}.dots[data-v-466cf629]{display:inline-flex;align-items:center;gap:5px;font-size:16px;letter-spacing:0}.d-more[data-v-466cf629]{color:var(--faint);font-size:16px}.d.success[data-v-466cf629]{color:var(--pass)}.d.fail[data-v-466cf629]{color:var(--fail)}.d.running[data-v-466cf629]{color:var(--live)}body.board .d.running[data-v-466cf629]{animation:pulse 1.2s ease-in-out infinite}.d.queued[data-v-466cf629]{color:var(--faint)}.row[data-v-4c0606e6]{display:grid;grid-template-columns:var(--tt-columns);align-items:baseline;column-gap:18px;padding:9px 0;border-bottom:1px solid var(--rule-soft);color:var(--fg);text-decoration:none;font-size:16px}.row[data-v-4c0606e6]:hover{background:var(--face)}.run[data-v-4c0606e6]{font-weight:700}.service[data-v-4c0606e6],.request[data-v-4c0606e6]{color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.phases[data-v-4c0606e6]{display:inline-flex;align-items:center;gap:10px;overflow:hidden;white-space:nowrap}.status[data-v-4c0606e6]{font-weight:600;white-space:nowrap}.num[data-v-4c0606e6]{text-align:right;white-space:nowrap}.mono[data-v-4c0606e6]{font-family:var(--mono);font-variant-numeric:tabular-nums}.status.success[data-v-4c0606e6]{color:var(--pass)}.status.fail[data-v-4c0606e6]{color:var(--fail)}.status.running[data-v-4c0606e6]{color:var(--live);font-style:italic}.status.queued[data-v-4c0606e6]{color:var(--faint);font-style:italic}.row.running[data-v-4c0606e6]{background:var(--face)}.act[data-v-4c0606e6]{justify-self:end;display:inline-flex;align-items:center;justify-content:center;padding:4px;border:1px solid transparent;border-radius:var(--radius);background:none;color:var(--faint);cursor:pointer}.act[data-v-4c0606e6]:hover,.act[data-v-4c0606e6]:focus-visible{border-color:var(--rule);color:var(--accent)}.meta[data-v-4c0606e6]{display:none}@media(max-width:980px){.row[data-v-4c0606e6]{grid-template-columns:1fr auto auto;row-gap:3px;padding:12px 0}.departed[data-v-4c0606e6],.service[data-v-4c0606e6],.phases[data-v-4c0606e6],.num[data-v-4c0606e6]{display:none}.request[data-v-4c0606e6]{grid-column:1 / -1}.meta[data-v-4c0606e6]{display:block;grid-column:1 / -1;font-size:16px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}.sessions[data-v-cd655e0c]{display:flex;flex-direction:column}.search-bar[data-v-cd655e0c]{display:flex;align-items:baseline;gap:12px;margin:18px 28px 0}.search-input[data-v-cd655e0c]{flex:1;max-width:420px;padding:7px 10px;border:1px solid var(--rule);border-radius:var(--radius);background:var(--face);color:var(--fg);font-family:var(--mono);font-size:16px}.search-input[data-v-cd655e0c]::placeholder{color:var(--faint)}.search-note[data-v-cd655e0c]{font-size:16px;white-space:nowrap}.tt[data-v-cd655e0c]{--tt-columns: 84px 160px minmax(90px, 140px) minmax(0, 1fr) 168px 100px 90px 90px 90px 70px;margin:8px 28px 40px}.tt-caption[data-v-cd655e0c]{display:flex;align-items:baseline;justify-content:space-between;padding:18px 0 8px}.tt-title[data-v-cd655e0c]{font-size:20px;font-weight:700;letter-spacing:.02em}.tt-head[data-v-cd655e0c]{display:grid;grid-template-columns:var(--tt-columns);column-gap:18px;padding:0 0 6px;border-bottom:1px solid var(--rule)}.h[data-v-cd655e0c]{font-size:16px;font-weight:600;letter-spacing:.06em;text-transform:lowercase;color:var(--faint);white-space:nowrap}.h-num[data-v-cd655e0c]{text-align:right}@media(max-width:980px){.search-bar[data-v-cd655e0c]{margin:12px 16px 0}.search-input[data-v-cd655e0c]{max-width:none}.tt[data-v-cd655e0c]{margin:8px 16px 40px}.tt-head[data-v-cd655e0c]{display:none}}.chip[data-v-fb81e248]{display:inline-flex;align-items:center;gap:7px;padding:2px 10px;border:1px solid var(--rule);border-radius:var(--radius);background:transparent;font-size:16px;font-weight:600;color:var(--dim);white-space:nowrap}.chip-icon[data-v-fb81e248]{flex:none}.chip-label[data-v-fb81e248]{display:inline-block;transform-origin:50% 65%}body.board .chip-label[data-v-fb81e248]{animation:flap-in .26s cubic-bezier(.2,.7,.3,1)}.chip.success[data-v-fb81e248]{color:var(--pass);border-color:var(--pass)}.chip.fail[data-v-fb81e248]{color:var(--fail);border-color:var(--fail)}.chip.running[data-v-fb81e248]{color:var(--live);border-color:var(--live)}body.board .chip.running .chip-icon[data-v-fb81e248]{animation:spin-fb81e248 1.1s linear infinite}@keyframes spin-fb81e248{to{transform:rotate(360deg)}}.chip.queued[data-v-fb81e248]{color:var(--faint);border-style:dashed}.stat[data-v-d11de936]{display:inline-flex;align-items:center;gap:7px;padding:2px 10px;border:1px solid var(--rule-soft);border-radius:var(--radius);font-size:16px;white-space:nowrap}.stat-icon[data-v-d11de936]{color:var(--faint);flex:none}.stat-value[data-v-d11de936]{color:var(--fg);font-family:var(--mono);font-variant-numeric:tabular-nums}.stat.compact[data-v-d11de936]{padding:0;border:none}.stat.compact .stat-value[data-v-d11de936]{color:var(--dim)}.dsec[data-v-5b638432]{margin-bottom:16px}.dsec-head[data-v-5b638432]{display:flex;align-items:center;gap:9px;width:100%;padding:7px 6px;background:none;border:none;border-bottom:1px solid var(--rule);border-radius:0;color:var(--dim);font-size:16px;font-weight:600;letter-spacing:.07em;text-transform:uppercase;cursor:pointer;text-align:left}.dsec-icon[data-v-5b638432]{flex:none;color:var(--faint)}.dsec-head[data-v-5b638432]:hover{background:var(--face);color:var(--fg)}.chev[data-v-5b638432]{color:var(--faint);flex:none;font-family:var(--mono)}.dsec-count[data-v-5b638432]{font-weight:400;text-transform:none;letter-spacing:0}.dsec-body[data-v-5b638432]{padding-top:12px}.detail[data-v-b072286b]{margin:0 28px 28px;border:1px solid var(--rule);border-radius:var(--radius);background:var(--face)}.d-head[data-v-b072286b]{display:flex;align-items:center;gap:20px;flex-wrap:wrap;padding:14px 18px;border-bottom:1px solid var(--rule);background:var(--inset)}.d-main[data-v-b072286b]{display:flex;align-items:center;gap:14px;flex-wrap:wrap}.d-name[data-v-b072286b]{font-size:20px;font-weight:700}.d-tags[data-v-b072286b]{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-left:auto}.tag[data-v-b072286b]{display:inline-flex;align-items:baseline;gap:7px;padding:2px 10px;border:1px solid var(--rule-soft);border-radius:var(--radius);background:var(--inset);font-size:16px;white-space:nowrap}.tag-k[data-v-b072286b]{color:var(--faint)}.tag-v[data-v-b072286b]{color:var(--fg)}.close[data-v-b072286b]{background:none;border:1px solid var(--rule);border-radius:var(--radius);color:var(--dim);font-family:var(--mono);font-size:16px;cursor:pointer;padding:3px 10px}.close[data-v-b072286b]:hover{color:var(--fg);border-color:var(--dim)}.d-desc[data-v-b072286b]{margin:0;color:var(--dim)}.d-request[data-v-b072286b]{margin:0;color:var(--fg);white-space:pre-wrap;overflow-wrap:anywhere}.cfg[data-v-b072286b]{display:flex;flex-direction:column;gap:7px}.cfg-row[data-v-b072286b]{display:flex;align-items:baseline;gap:12px}.cfg-k[data-v-b072286b]{flex:none;width:118px;color:var(--faint)}.cfg-v[data-v-b072286b]{color:var(--fg);min-width:0;overflow-wrap:anywhere}.cfg-model-icon[data-v-b072286b]{width:17px;height:17px;flex:none;object-fit:contain}.cfg-chips[data-v-b072286b]{display:inline-flex;flex-wrap:wrap;gap:6px}.cfg-chip[data-v-b072286b]{display:inline-flex;align-items:center;gap:8px;padding:2px 10px;border:1px solid var(--rule-soft);border-radius:var(--radius);background:var(--inset);font-family:var(--mono);font-size:16px;overflow-wrap:anywhere}.cfg-icon[data-v-b072286b]{flex:none;color:var(--faint)}.d-error[data-v-b072286b]{margin:14px 18px 0}.d-grid[data-v-b072286b]{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,3fr);gap:28px;padding:16px 18px 20px}@media(max-width:1100px){.d-grid[data-v-b072286b]{grid-template-columns:1fr}}h3[data-v-b072286b]{display:flex;align-items:center;gap:9px;margin:18px 0 10px;padding-bottom:6px;border-bottom:1px solid var(--rule-soft);font-size:16px;font-weight:700;color:var(--dim);text-transform:lowercase;letter-spacing:.05em}.h3-icon[data-v-b072286b]{flex:none;color:var(--faint)}h3[data-v-b072286b]:first-child{margin-top:0}.prompt-panel[data-v-b072286b]{margin-bottom:10px;border:1px solid var(--rule-soft);border-radius:var(--radius);background:var(--inset);overflow:hidden}.prompt-head[data-v-b072286b]{display:flex;align-items:baseline;gap:12px;width:100%;padding:9px 14px;background:none;border:none;color:var(--fg);font-size:16px;cursor:pointer;text-align:left}.prompt-head[data-v-b072286b]:hover{background:var(--face)}.chev[data-v-b072286b]{color:var(--faint);flex:none;font-family:var(--mono)}.prompt-title[data-v-b072286b]{font-weight:700}.prompt-head .dim[data-v-b072286b]{margin-left:auto}.prompt-body[data-v-b072286b]{padding:12px 14px 14px;border-top:1px solid var(--rule-soft);max-height:60vh;overflow:auto}.prompt-tools[data-v-b072286b]{display:flex;gap:8px;margin-bottom:12px}.prompt-tools button[data-v-b072286b]{padding:2px 12px;border:1px solid var(--rule-soft);border-radius:var(--radius);background:none;color:var(--dim);font-family:var(--mono);font-size:16px;cursor:pointer}.prompt-tools button.active[data-v-b072286b]{color:var(--fg);border-color:var(--rule);background:var(--face)}.prompt-raw[data-v-b072286b]{border:none;padding:0;background:transparent;max-height:none}.gate[data-v-b072286b]{margin-bottom:10px;padding:10px 14px;border:1px solid var(--rule-soft);border-radius:var(--radius);background:var(--inset)}.gate.pass[data-v-b072286b]{border-color:var(--rule)}.gate.fail[data-v-b072286b]{border-color:var(--fail)}.gate-line[data-v-b072286b]{display:flex;gap:12px;align-items:baseline;flex-wrap:wrap}.gate-toggle[data-v-b072286b]{width:100%;padding:0;background:none;border:none;color:var(--fg);font-size:16px;cursor:pointer;text-align:left}.gate-checks .check-item[data-v-b072286b]{font-family:var(--mono)}.gate-toggle .chev[data-v-b072286b]{color:var(--faint);flex:none}.gate-checks[data-v-b072286b]{margin-top:10px;padding-top:8px;border-top:1px solid var(--rule-soft)}.gate-check[data-v-b072286b]{display:flex;gap:10px;align-items:baseline;flex-wrap:wrap;padding:3px 0}.gate-check.pass .check-mark[data-v-b072286b]{color:var(--pass)}.gate-check.fail .check-mark[data-v-b072286b]{color:var(--fail)}.check-item[data-v-b072286b]{overflow-wrap:anywhere;min-width:0}.check-note[data-v-b072286b]{white-space:pre-wrap;overflow-wrap:anywhere}.check-note-block[data-v-b072286b]{flex-basis:100%;margin:4px 0 6px 26px;max-height:30vh;overflow:auto}.tag-fail[data-v-b072286b]{border-color:var(--fail)}.tag-fail .tag-v[data-v-b072286b]{color:var(--fail)}.gate.pass .gate-mark[data-v-b072286b]{color:var(--pass)}.gate.fail .gate-mark[data-v-b072286b]{color:var(--fail)}.gate-name[data-v-b072286b]{color:var(--fg);font-weight:700}.gate-time[data-v-b072286b]{margin-left:auto}.violations[data-v-b072286b]{margin:8px 0 2px;padding-left:24px;color:var(--fail)}.usage[data-v-b072286b]{width:100%;max-width:420px;border-collapse:collapse;font-size:16px}.usage th[data-v-b072286b]{padding:0 0 6px;font-size:16px;font-weight:400;letter-spacing:.06em;text-transform:uppercase;color:var(--faint);border-bottom:1px solid var(--rule-soft)}.usage td[data-v-b072286b]{padding:5px 0}.usage .u-k[data-v-b072286b]{text-align:left;color:var(--dim)}.usage .u-n[data-v-b072286b],.usage .u-c[data-v-b072286b]{text-align:right;font-family:var(--mono);color:var(--fg)}.u-nested .u-k[data-v-b072286b]{padding-left:18px;color:var(--faint)}.u-nested .u-n[data-v-b072286b],.u-nested .u-c[data-v-b072286b]{color:var(--faint)}.u-total td[data-v-b072286b]{padding-top:8px;border-top:1px solid var(--rule-soft);font-weight:700}.u-total .u-k[data-v-b072286b]{color:var(--fg)}.u-note[data-v-b072286b]{margin:10px 0 0;font-size:16px}.output[data-v-b072286b]{margin-bottom:14px}.output-line[data-v-b072286b]{display:flex;gap:12px;align-items:baseline;flex-wrap:wrap;margin-bottom:8px}.output-type[data-v-b072286b]{color:var(--fg);font-weight:700}.output-valid.pass[data-v-b072286b]{color:var(--pass)}.output-valid.fail[data-v-b072286b]{color:var(--fail)}.output pre[data-v-b072286b]{max-height:40vh;overflow:auto}.event[data-v-b072286b]{border-bottom:1px solid var(--rule-soft)}.event-row[data-v-b072286b]{display:flex;gap:14px;align-items:baseline;width:100%;padding:7px 6px;background:none;border:none;border-radius:0;color:var(--fg);font-family:var(--mono);font-size:16px;cursor:pointer;text-align:left}.event-row[data-v-b072286b]:hover,.event-row.open[data-v-b072286b]{background:var(--inset)}.e-time[data-v-b072286b]{flex:none;font-variant-numeric:tabular-nums}.e-type[data-v-b072286b]{flex:none;width:130px;color:var(--dim)}.e-name[data-v-b072286b]{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.e-extra[data-v-b072286b]{margin-left:auto;flex:none;display:inline-flex;gap:14px}.payload-panel[data-v-b072286b]{margin:6px 0 14px;padding:14px 16px;border:1px solid var(--rule);border-radius:var(--radius);background:var(--inset)}.p-meta[data-v-b072286b]{display:flex;gap:16px;align-items:baseline;margin-bottom:10px}.p-tool[data-v-b072286b]{color:var(--fg);font-weight:700;font-size:17px}.payload-panel h4[data-v-b072286b]{margin:14px 0 6px;font-size:16px;font-weight:700;color:var(--dim);text-transform:lowercase;letter-spacing:.06em}.payload-panel h4[data-v-b072286b]:first-of-type{margin-top:0}.p-pre[data-v-b072286b]{border:1px solid var(--rule-soft);border-radius:var(--radius);padding:10px 12px;background:var(--board);max-height:42vh;overflow:auto}.t-red[data-v-b072286b]{color:var(--fail)}.t-green[data-v-b072286b]{color:var(--pass)}@media(max-width:980px){.detail[data-v-b072286b]{margin:0 16px 24px}}.summary-card[data-v-c841437b]{margin:20px 28px 0;border:1px solid var(--rule);border-radius:var(--radius);background:var(--face);overflow:hidden}.summary-card[data-v-c841437b] .dsec{margin-bottom:0}.summary-card[data-v-c841437b] .dsec-head{padding:10px 16px;background:var(--inset)}.summary-card[data-v-c841437b] .dsec-head:hover{background:var(--inset)}.summary-card[data-v-c841437b] .dsec-body{padding:14px 16px}.sum-row[data-v-c841437b]{display:flex;align-items:center;gap:12px;flex-wrap:wrap;font-size:16px}.sum-btn[data-v-c841437b]{padding:5px 14px;border:1px solid var(--rule);border-radius:var(--radius);background:none;color:var(--dim);font-family:var(--mono);font-size:16px;cursor:pointer}.sum-btn[data-v-c841437b]:hover,.sum-btn[data-v-c841437b]:focus-visible{color:var(--fg);border-color:var(--accent)}.md[data-v-c841437b]{margin-bottom:12px}.qa-card[data-v-f39b4ea7]{margin:20px 28px 0;border:1px solid var(--rule);border-radius:var(--radius);background:var(--face);overflow:hidden}.qa-card[data-v-f39b4ea7] .dsec{margin-bottom:0}.qa-card[data-v-f39b4ea7] .dsec-head{padding:10px 16px;background:var(--inset)}.qa-card[data-v-f39b4ea7] .dsec-head:hover{background:var(--inset)}.qa-card[data-v-f39b4ea7] .dsec-body{padding:14px 16px}.qa-note[data-v-f39b4ea7]{margin-bottom:10px;font-size:16px;color:var(--dim)}.qa-note-warn[data-v-f39b4ea7]{color:var(--amber)}.qa-thread[data-v-f39b4ea7]{display:flex;flex-direction:column;gap:10px;max-height:360px;overflow-y:auto;margin-bottom:12px}.qa-empty[data-v-f39b4ea7]{font-size:16px}.qa-msg[data-v-f39b4ea7]{display:flex;flex-direction:column;gap:2px;padding:8px 10px;border:1px solid var(--rule-soft);border-radius:var(--radius);background:var(--inset)}.qa-role-user[data-v-f39b4ea7]{align-self:flex-end;max-width:85%;background:none}.qa-role-assistant[data-v-f39b4ea7]{max-width:85%}.qa-role-error[data-v-f39b4ea7]{border-color:var(--fail)}.qa-role[data-v-f39b4ea7]{font-size:16px;letter-spacing:.07em;text-transform:uppercase}.qa-text[data-v-f39b4ea7]{font-size:17px;color:var(--fg);white-space:pre-wrap}.qa-role-error .qa-text[data-v-f39b4ea7]{color:var(--fail)}.qa-input-row[data-v-f39b4ea7]{display:flex;gap:8px}.qa-input[data-v-f39b4ea7]{flex:1 1 auto;padding:7px 12px;border:1px solid var(--rule);border-radius:var(--radius);background:var(--ground);color:var(--fg);font-family:var(--mono);font-size:16px}.qa-input[data-v-f39b4ea7]:focus-visible{outline:none;border-color:var(--accent)}.qa-btn[data-v-f39b4ea7]{padding:5px 14px;border:1px solid var(--rule);border-radius:var(--radius);background:none;color:var(--dim);font-family:var(--mono);font-size:16px;cursor:pointer}.qa-btn[data-v-f39b4ea7]:hover:not(:disabled),.qa-btn[data-v-f39b4ea7]:focus-visible:not(:disabled){color:var(--fg);border-color:var(--accent)}.qa-btn[data-v-f39b4ea7]:disabled{opacity:.5;cursor:default}.trace[data-v-73d913f1]{padding:0 0 40px}.run-strip[data-v-73d913f1]{display:flex;align-items:center;gap:18px;padding:12px 24px;border-bottom:1px solid var(--rule);flex-wrap:wrap}.run-strip .request[data-v-73d913f1]{font-size:17px;font-weight:600;color:var(--fg);max-width:52ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.run-stats[data-v-73d913f1]{display:inline-flex;gap:10px;flex-wrap:wrap}.waterfall[data-v-73d913f1]{margin:20px 28px;border:1px solid var(--rule);border-radius:var(--radius);background:var(--face);overflow:hidden}.row[data-v-73d913f1]{display:grid;grid-template-columns:250px 1fr}.axis-row[data-v-73d913f1]{border-bottom:1px solid var(--rule);background:var(--inset)}.axis-row .track[data-v-73d913f1]{height:38px;overflow:hidden}.zone-head[data-v-73d913f1]{position:absolute;top:0;bottom:0;left:0;display:inline-flex;align-items:center;justify-content:center;font-size:16px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--amber);border-right:1px solid var(--rule)}.axis-label[data-v-73d913f1]{position:absolute;bottom:6px;transform:translate(-50%);font-family:var(--mono);font-size:16px;font-variant-numeric:tabular-nums;color:var(--dim);white-space:nowrap}.label[data-v-73d913f1]{padding:12px 16px;display:flex;flex-direction:column;justify-content:center;gap:3px;border-right:1px solid var(--rule);overflow:hidden;white-space:nowrap}.lane-name[data-v-73d913f1]{display:inline-flex;align-items:center;gap:8px;font-size:17px;font-weight:700;color:var(--fg);overflow:hidden;text-overflow:ellipsis}.tno[data-v-73d913f1]{flex:none;width:26px;color:var(--faint);font-size:16px}.lane-icon[data-v-73d913f1]{flex:none;color:var(--dim)}.lane-meta[data-v-73d913f1]{font-family:var(--mono);font-size:16px;color:var(--dim);overflow:hidden;text-overflow:ellipsis}.lane-model[data-v-73d913f1]{display:inline-flex;align-items:center;gap:7px}.model-icon[data-v-73d913f1]{width:16px;height:16px;flex:none;object-fit:contain}.lane-ctx[data-v-73d913f1]{display:flex;flex-direction:column;gap:4px;margin-top:2px;max-width:190px}.ctx-head[data-v-73d913f1]{display:flex;align-items:baseline;justify-content:space-between;gap:8px}.ctx-label[data-v-73d913f1]{font-size:16px;letter-spacing:.07em;text-transform:uppercase;color:var(--faint)}.ctx-pct[data-v-73d913f1]{font-family:var(--mono);font-size:16px;font-variant-numeric:tabular-nums;color:var(--dim)}.ctx-bar[data-v-73d913f1]{height:6px;background:var(--inset);border:1px solid var(--rule-soft);overflow:hidden}.ctx-fill[data-v-73d913f1]{display:block;width:100%;height:100%;background:var(--faint);transform-origin:left center;transition:transform .3s ease}.ctx-fill.live[data-v-73d913f1]{background:var(--amber)}.lane[data-v-73d913f1]{border-bottom:1px solid var(--rule-soft)}.lane[data-v-73d913f1]:last-child{border-bottom:none}.track[data-v-73d913f1]{position:relative;height:118px;overflow:hidden}.zone-divider[data-v-73d913f1]{position:absolute;top:0;bottom:0;border-left:1px solid var(--rule)}.gridline[data-v-73d913f1]{position:absolute;top:0;bottom:0;border-left:1px dashed rgba(239,232,212,.08)}.block[data-v-73d913f1]{position:absolute;top:13px;height:92px;display:flex;flex-direction:column;justify-content:flex-start;gap:4px;padding:10px 12px 16px;border-radius:var(--radius);border:1px solid var(--rule);background:var(--face);font-size:16px;color:var(--fg);cursor:pointer;overflow:hidden;white-space:nowrap;text-align:left;transition:border-color .12s ease,background .12s ease}.block[data-v-73d913f1]:hover{border-color:var(--dim);background:#242119}.b-top[data-v-73d913f1]{display:flex;align-items:baseline;gap:10px;min-width:0}.flap[data-v-73d913f1]{display:inline-block;transform-origin:50% 65%;animation:flap-in .28s cubic-bezier(.2,.7,.3,1),}.b-status[data-v-73d913f1]{flex:none;font-size:16px;padding-right:8px}.b-status.success[data-v-73d913f1]{color:var(--pass)}.b-status.fail[data-v-73d913f1]{color:var(--fail)}.b-status.running[data-v-73d913f1]{color:var(--amber);animation:flap-in .28s cubic-bezier(.2,.7,.3,1),pulse 1.6s ease-in-out .34s infinite}.b-status.queued[data-v-73d913f1]{color:var(--faint)}.block .b-name[data-v-73d913f1]{font-size:17px;font-weight:700;overflow:hidden;text-overflow:ellipsis}.block .b-dur[data-v-73d913f1]{margin-left:auto;flex:none}.block .b-desc[data-v-73d913f1]{color:var(--dim);font-size:16px;overflow:hidden;text-overflow:ellipsis;min-width:0}.block.running[data-v-73d913f1]{border-color:var(--amber)}.block.running .b-name[data-v-73d913f1]{color:var(--amber)}.block.fail[data-v-73d913f1]{border-color:var(--fail)}.block.queued[data-v-73d913f1]{background:transparent;border-style:dashed;color:var(--dim)}.block.selected[data-v-73d913f1]{outline:2px solid var(--accent);outline-offset:1px}.tool-tick[data-v-73d913f1]{position:absolute;bottom:4px;width:3px;height:9px;background:var(--faint);border-radius:0}.tool-tick.err[data-v-73d913f1]{background:var(--fail)}@media(max-width:980px){.run-strip[data-v-73d913f1]{padding:12px 16px}.waterfall[data-v-73d913f1]{margin:16px;overflow-x:auto}.waterfall .row[data-v-73d913f1]{min-width:700px}}.webmcp-confirm[data-v-397aa307]{position:fixed;right:20px;bottom:20px;z-index:1000;width:min(360px,calc(100vw - 40px));padding:14px 16px;border:1px solid var(--accent);border-radius:var(--radius);background:var(--face)}.wc-label[data-v-397aa307]{display:block;margin-bottom:8px;font-family:var(--mono);font-size:16px;font-weight:600;letter-spacing:.07em;text-transform:uppercase;color:var(--accent)}.wc-message[data-v-397aa307]{margin:0 0 14px;color:var(--fg);font-size:16px;overflow-wrap:anywhere}.wc-actions[data-v-397aa307]{display:flex;justify-content:flex-end;gap:10px}.wc-btn[data-v-397aa307]{padding:5px 14px;border:1px solid var(--rule);border-radius:var(--radius);background:none;color:var(--dim);font-family:var(--mono);font-size:16px;cursor:pointer}.wc-btn[data-v-397aa307]:hover,.wc-btn[data-v-397aa307]:focus-visible{color:var(--fg);border-color:var(--dim)}.wc-confirm[data-v-397aa307]{color:var(--fg);border-color:var(--accent)}.wc-confirm[data-v-397aa307]:hover,.wc-confirm[data-v-397aa307]:focus-visible{color:var(--accent);border-color:var(--accent)}@media(max-width:980px){.webmcp-confirm[data-v-397aa307]{right:12px;bottom:12px;left:12px;width:auto}}.masthead[data-v-cef4df00]{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px 28px 14px;position:sticky;top:0;z-index:10;background:var(--ground)}.masthead[data-v-cef4df00]:after{content:"";position:absolute;left:0;right:0;bottom:0;border-bottom:4px double var(--ink)}body.board .masthead[data-v-cef4df00]:after{border-bottom:1px solid var(--rule)}.crumbs[data-v-cef4df00]{display:flex;align-items:center;gap:10px;font-size:17px;flex:1 1 0;min-width:0}.logo[data-v-cef4df00]{width:26px;height:26px;flex:none}.logo-plate[data-v-cef4df00]{fill:var(--face);stroke:var(--fg);stroke-width:1.5}.logo-strip[data-v-cef4df00]{fill:var(--fg)}.logo-accent[data-v-cef4df00]{fill:var(--accent)}.brand[data-v-cef4df00]{color:var(--fg);font-weight:700;letter-spacing:.02em;white-space:nowrap;text-decoration:none;min-width:0;overflow:hidden;text-overflow:ellipsis}.sep[data-v-cef4df00]{color:var(--faint);flex:none}.crumbs a[data-v-cef4df00]{color:var(--dim)}.crumbs a[data-v-cef4df00]:hover{color:var(--fg)}.crumb-id[data-v-cef4df00]{font-family:var(--mono);font-size:16px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.crumbs .current[data-v-cef4df00]{color:var(--fg)}.live-hint[data-v-cef4df00]{flex:none;display:inline-flex;align-items:center;gap:8px;font-family:var(--mono);font-size:16px;color:var(--live);white-space:nowrap}.live-marker[data-v-cef4df00]{width:9px;height:9px;background:var(--live)}body.board .live-marker[data-v-cef4df00]{border-radius:50%;animation:pulse 1.8s ease-in-out infinite}@media(max-width:980px){.masthead[data-v-cef4df00]{padding:14px 16px 12px}}:root{--sans: "Overpass", system-ui, "Helvetica Neue", Arial, sans-serif;--mono: "Overpass Mono", ui-monospace, "SF Mono", SFMono-Regular, Menlo, Monaco, "Cascadia Mono", "Roboto Mono", monospace;--paper: #f6f3ec;--paper-raised: #fcfaf4;--paper-sunk: #ede8db;--ink: #211e14;--ink-dim: #5f5948;--ink-faint: #76705e;--paper-rule: #d8d2c0;--paper-rule-soft: #e6e1d0;--board: #141311;--board-raised: #1e1c17;--board-sunk: #0c0b09;--bone: #efe8d4;--bone-dim: #96907c;--bone-faint: #857f6a;--board-rule: #37342b;--board-rule-soft: #2a2820;--rail-red: #da291c;--ballpoint: #2545a8;--amber: #e8b64a;--pass-paper: #1e7a3c;--fail-paper: #b3261e;--pass-board: #4ade80;--fail-board: #ff6f67;--radius: 2px}body{--ground: var(--paper);--face: var(--paper-raised);--inset: var(--paper-sunk);--fg: var(--ink);--dim: var(--ink-dim);--faint: var(--ink-faint);--rule: var(--paper-rule);--rule-soft: var(--paper-rule-soft);--accent: var(--rail-red);--live: var(--ballpoint);--pass: var(--pass-paper);--fail: var(--fail-paper)}body.board{--ground: var(--board);--face: var(--board-raised);--inset: var(--board-sunk);--fg: var(--bone);--dim: var(--bone-dim);--faint: var(--bone-faint);--rule: var(--board-rule);--rule-soft: var(--board-rule-soft);--accent: var(--amber);--live: var(--amber);--pass: var(--pass-board);--fail: var(--fail-board)}*{box-sizing:border-box}html,body{margin:0;padding:0}body{background:var(--paper);color:var(--ink);font-family:var(--sans);font-size:16px;line-height:1.5;scrollbar-width:thin;scrollbar-color:var(--rule) transparent}body.board{background:var(--board);color:var(--bone)}#app{min-height:100vh}::selection{background:#f3d8d4;color:var(--ink)}body.board ::selection{background:var(--amber);color:var(--board)}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}::-webkit-scrollbar{width:10px;height:10px}::-webkit-scrollbar-thumb{background:var(--rule);border:3px solid var(--ground);border-radius:6px}::-webkit-scrollbar-track{background:transparent}a{color:var(--ballpoint);text-decoration:none}body.board a{color:var(--amber)}pre{margin:0;padding:12px 14px;background:var(--inset);border:1px solid var(--rule-soft);border-radius:var(--radius);overflow-x:auto;font-family:var(--mono);font-size:16px;line-height:1.55;color:var(--fg);white-space:pre-wrap;word-break:break-word}body.board .j-key{color:var(--bone-dim)}body.board .j-str{color:var(--bone)}body.board .j-bool{color:#d6cdae;font-style:italic}body.board .j-null{color:var(--fail)}.j-key,.j-str{color:var(--ink-dim)}.j-num{color:var(--ballpoint)}body.board .j-num{color:var(--amber)}.j-bool{color:var(--ink-dim);font-style:italic}.j-null{color:var(--fail)}.dim{color:var(--dim)}.faint{color:var(--faint)}.error-bar{margin:12px 24px;padding:10px 14px;border:1px solid var(--fail);background:var(--face);color:var(--fail);border-radius:var(--radius);font-size:16px}.empty-state{padding:72px 24px;text-align:center;color:var(--dim);font-size:16px}.md{font-size:16px;line-height:1.6;color:var(--fg)}.md h1{font-size:20px;margin:14px 0 8px}.md h2{font-size:18px;margin:14px 0 8px}.md h3,.md h4{font-size:17px;margin:12px 0 6px}.md h1:first-child,.md h2:first-child,.md h3:first-child{margin-top:0}.md p{margin:8px 0;white-space:pre-wrap}.md code{background:var(--inset);border:1px solid var(--rule-soft);border-radius:var(--radius);padding:1px 7px;font-family:var(--mono);font-size:16px}.md pre.md-code{margin:10px 0;white-space:pre}.md pre.md-code code{background:transparent;border:none;padding:0;font-size:16px}.md ul,.md ol{margin:8px 0;padding-left:28px}.md li{margin:3px 0}.md blockquote{margin:10px 0;padding:2px 0 2px 14px;border-left:1px solid var(--rule);color:var(--dim);white-space:pre-wrap}.md hr{border:none;border-top:1px solid var(--rule);margin:14px 0}@keyframes flap-in{0%{transform:rotateX(86deg)}to{transform:rotateX(0)}}@keyframes pulse{0%,to{opacity:1}50%{opacity:.35}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}}