@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.
- package/README.md +15 -5
- package/assets/skill/references/config.md +9 -5
- package/assets/skill/references/observability.md +57 -12
- package/assets/templates/ts-opencode.spf.config.yaml +54 -0
- package/dist/chains/index.js +1 -1
- package/dist/chains/simple_sdlc.d.ts +2 -2
- package/dist/chains/simple_sdlc.js +13 -13
- package/dist/chains/steps.d.ts +2 -2
- package/dist/chains/steps.js +35 -19
- package/dist/cli/commands/abort.d.ts +1 -1
- package/dist/cli/commands/abort.js +30 -3
- package/dist/cli/commands/doctor.js +109 -8
- package/dist/cli/commands/estimate.js +3 -3
- package/dist/cli/commands/events.js +4 -4
- package/dist/cli/commands/fanout.js +93 -21
- package/dist/cli/commands/loop.js +31 -32
- package/dist/cli/commands/migrate.js +8 -1
- package/dist/cli/commands/phases.js +2 -2
- package/dist/cli/commands/sessions.js +2 -2
- package/dist/cli/commands/trace.d.ts +28 -8
- package/dist/cli/commands/trace.js +28 -15
- package/dist/cli/commands/ui.js +15 -5
- package/dist/cli/commands/watch.js +27 -27
- package/dist/cli/index.js +3 -1
- package/dist/cli/interview.d.ts +1 -0
- package/dist/cli/interview.js +86 -4
- package/dist/core/agent_opencode.d.ts +247 -0
- package/dist/core/agent_opencode.js +590 -0
- package/dist/core/agents.d.ts +12 -12
- package/dist/core/agents.js +113 -46
- package/dist/core/console.d.ts +12 -12
- package/dist/core/console.js +25 -25
- package/dist/core/data_types.d.ts +126 -12
- package/dist/core/data_types.js +101 -4
- package/dist/core/fanout.d.ts +1 -1
- package/dist/core/fanout.js +1 -1
- package/dist/core/gates.js +14 -1
- package/dist/core/paths.d.ts +41 -4
- package/dist/core/paths.js +32 -3
- package/dist/core/quality.d.ts +7 -7
- package/dist/core/quality.js +16 -10
- package/dist/core/runner.d.ts +9 -3
- package/dist/core/runner.js +39 -27
- package/dist/core/session.d.ts +2 -2
- package/dist/core/session.js +39 -18
- package/dist/core/sqlite.d.ts +14 -7
- package/dist/core/sqlite.js +14 -7
- package/dist/core/trace_db.d.ts +118 -0
- package/dist/core/trace_db.js +278 -0
- package/dist/core/tracer.d.ts +64 -34
- package/dist/core/tracer.js +141 -69
- package/dist/core/watch.d.ts +4 -4
- package/dist/core/watch.js +2 -2
- package/dist/ui/server/app.js +10 -10
- package/dist/ui/server/db.d.ts +89 -21
- package/dist/ui/server/db.js +235 -99
- package/dist/ui/server/serve.d.ts +5 -1
- package/dist/ui/server/serve.js +4 -5
- package/package.json +1 -1
- package/web/assets/index-CQ3k1Y1-.css +1 -0
- package/web/assets/index-CU8tom6S.js +21 -0
- package/web/index.html +2 -2
- package/web/assets/index-CRujNW-1.js +0 -11
- package/web/assets/index-Cto6nuQL.css +0 -1
package/dist/ui/server/db.js
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
|
5
|
-
* the writers are the tracers of running ADW processes,
|
|
6
|
-
* straight through their
|
|
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 {
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
86
|
-
|
|
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 =
|
|
101
|
-
this.
|
|
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
|
|
125
|
-
|
|
126
|
-
.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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.
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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.
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
.
|
|
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
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
.
|
|
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
|
-
|
|
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;
|
package/dist/ui/server/serve.js
CHANGED
|
@@ -35,7 +35,7 @@ function openUrl(url) {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
export async function runUi(options) {
|
|
38
|
-
const db =
|
|
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
|
@@ -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}}
|