@coreplane/switchboard 1.229.0 → 1.230.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/dist/assets/config/config.example.yaml +10 -0
- package/dist/assets/deploy/cloudflare-memory/worker.ts +56 -29
- package/dist/assets/deploy/cloudflare-resident/worker.ts +39 -20
- package/dist/assets/package-lock.json +6 -3
- package/dist/assets/package.json +2 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/core/authz/actor.ts +24 -6
- package/dist/assets/src/core/coordinator/contract.ts +19 -0
- package/dist/assets/src/core/runEvents.ts +22 -5
- package/dist/assets/src/core/runFriction.ts +4 -3
- package/dist/assets/src/core/runLedger/sessionLog.ts +13 -0
- package/dist/assets/src/core/runRecord.ts +33 -0
- package/dist/assets/src/core/trace/attrs.ts +3 -0
- package/dist/assets/src/core/trace/streamSpans.ts +4 -1
- package/dist/assets/src/execution/residentRebind.ts +27 -4
- package/dist/assets/web/dist/.vite/manifest.json +19 -19
- package/dist/assets/web/dist/assets/CostsPage-B08bdD-1.js +2 -0
- package/dist/assets/web/dist/assets/{ResidentDetailPage-CkVYktwT.js → ResidentDetailPage-DYxDruS0.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-BLanxf27.js → ResidentsIndexPage-DIBRBmuq.js} +1 -1
- package/dist/assets/web/dist/assets/{RunRoutePage-U3nwL8Df.js → RunRoutePage-B4s8hJlW.js} +4 -4
- package/dist/assets/web/dist/assets/{RunsIndexPage-DrIVxmpl.js → RunsIndexPage-CaW07KOf.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-DpqubmIm.js → ScheduledPage-Cr7269s9.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-BpD9MRge.js → StatusDot-lCLi16zY.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-BYv0WSrA.js → Tooltip-DKMPwNyy.js} +1 -1
- package/dist/assets/web/dist/assets/{dist-BZmA5qTt.js → dist-DRkTB2rt.js} +1 -1
- package/dist/assets/web/dist/assets/{main-C4GOEklV.js → main-CXKOiCOK.js} +2 -2
- package/dist/cli.js +1341 -635
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/CostsPage-DQg30mHr.js +0 -2
|
@@ -363,6 +363,16 @@ workspaceDir: ./workspaces
|
|
|
363
363
|
# model: anthropic/claude-haiku-4-5
|
|
364
364
|
# answer: text
|
|
365
365
|
|
|
366
|
+
# Linked threads (docs/decisions/0037-a-linked-thread-is-quoted-not-joined.md;
|
|
367
|
+
# docs/reference/specs/routing-and-config.md item 22). Off by default. On, a
|
|
368
|
+
# permalink in a request to a thread in another PUBLIC channel the bot is a
|
|
369
|
+
# member of is quoted onto the request turn as an untrusted block — text only,
|
|
370
|
+
# at most three per request, never a private channel from elsewhere, never a
|
|
371
|
+
# DM or a Slack Connect channel — and the run's record and page show what was
|
|
372
|
+
# quoted. A bot older than 1.228.0 refuses a config that carries this key.
|
|
373
|
+
# references:
|
|
374
|
+
# enabled: true
|
|
375
|
+
|
|
366
376
|
# Spend reporting: GET /costs (Access-gated). Optional. See docs/reference/specs/costs.md.
|
|
367
377
|
# costs:
|
|
368
378
|
# cloudflareAccountId: <32-hex account id>
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
planSessionTrim,
|
|
52
52
|
roleOfStoredRow,
|
|
53
53
|
rowKind,
|
|
54
|
+
SEARCH_MAX_HITS,
|
|
54
55
|
sessionsToDrop,
|
|
55
56
|
tailCut,
|
|
56
57
|
textOfStoredRow,
|
|
@@ -148,7 +149,7 @@ const traceSinks = [workerLogSink((line) => console.log(line))];
|
|
|
148
149
|
// keeps the 512 KB one.
|
|
149
150
|
// POST /runs/put {storeKey, record, policy?, policyUpdatedAt?} → {ok, retained, stored, rewritten}
|
|
150
151
|
// POST /runs/get {storeKey, id} → {record: RunRecord | null} (unknown/expired: null, 200)
|
|
151
|
-
// POST /runs/list {storeKey, limit?, before?, beforeId?, sinceMs?, agent?, channel?, threadKey?}
|
|
152
|
+
// POST /runs/list {storeKey, limit?, before?, beforeId?, sinceMs?, agent?, channel?, threadKey?, parentRunId?}
|
|
152
153
|
// → {items: RunListItem[], nextBefore?: {finishedAt, id}} (cursor = the last row's list key)
|
|
153
154
|
// POST /runs/events {storeKey, id, afterSeq?, limit?} → {events: (RunEvent & {seq})[] | null, nextAfterSeq?}
|
|
154
155
|
// (`events: null` when the run is unknown or hidden by retention; `seq` is the registry's stamp)
|
|
@@ -225,7 +226,6 @@ const MAX_MATCH_TOKENS = 24;
|
|
|
225
226
|
/** A `recall` query's size and the most hits one answers (session-log item 10):
|
|
226
227
|
* a query is a few words, and the tool's default is five. */
|
|
227
228
|
const MAX_SEARCH_QUERY_BYTES = 1_024;
|
|
228
|
-
const MAX_SEARCH_HITS = 50;
|
|
229
229
|
/** Request body ceiling, checked against Content-Length before parsing. A full
|
|
230
230
|
* batch (50 × 4000-char texts + keywords + envelope) fits comfortably. */
|
|
231
231
|
const MAX_BODY_BYTES = 512 * 1024;
|
|
@@ -1152,31 +1152,16 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
1152
1152
|
value TEXT NOT NULL
|
|
1153
1153
|
);
|
|
1154
1154
|
`);
|
|
1155
|
-
|
|
1156
|
-
//
|
|
1157
|
-
//
|
|
1158
|
-
//
|
|
1159
|
-
// `channel_visibility IN`, `user_id =`), each ordered like the page.
|
|
1160
|
-
const columns = new Set(
|
|
1161
|
-
this.sql
|
|
1162
|
-
.exec<{ name: string }>(`PRAGMA table_info(runs)`)
|
|
1163
|
-
.toArray()
|
|
1164
|
-
.map((c) => c.name),
|
|
1165
|
-
);
|
|
1166
|
-
if (!columns.has("channel_visibility"))
|
|
1167
|
-
this.sql.exec(`ALTER TABLE runs ADD COLUMN channel_visibility TEXT NOT NULL DEFAULT 'unknown'`);
|
|
1168
|
-
// The session a run was a range of (session-log item 7), so the sweep can
|
|
1169
|
-
// tell which sessions still have a kept run; null for a record without one.
|
|
1170
|
-
if (!columns.has("session_key")) this.sql.exec(`ALTER TABLE runs ADD COLUMN session_key TEXT`);
|
|
1171
|
-
// What the run cost in tokens (costs.md, cost by user): the record's `usage`
|
|
1172
|
-
// as JSON; NULL for a record written before the field existed, until the
|
|
1173
|
-
// by-user aggregate fills it in from the run's stored events.
|
|
1174
|
-
if (!columns.has("usage_json")) this.sql.exec(`ALTER TABLE runs ADD COLUMN usage_json TEXT`);
|
|
1155
|
+
this.migrateRunsTable();
|
|
1156
|
+
// The indexes the visibility predicate's leaves walk (`channel_id IN`,
|
|
1157
|
+
// `channel_visibility IN`, `user_id =`), each ordered like the page; the
|
|
1158
|
+
// session the sweep asks about; the parent a children listing filters on.
|
|
1175
1159
|
this.sql.exec(`
|
|
1176
1160
|
CREATE INDEX IF NOT EXISTS runs_channel_finished ON runs(channel_id, finished_at DESC, run_id DESC);
|
|
1177
1161
|
CREATE INDEX IF NOT EXISTS runs_visibility_finished ON runs(channel_visibility, finished_at DESC, run_id DESC);
|
|
1178
1162
|
CREATE INDEX IF NOT EXISTS runs_user_finished ON runs(user_id, finished_at DESC, run_id DESC);
|
|
1179
1163
|
CREATE INDEX IF NOT EXISTS runs_session ON runs(session_key);
|
|
1164
|
+
CREATE INDEX IF NOT EXISTS runs_parent ON runs(parent_run_id, finished_at DESC, run_id DESC);
|
|
1180
1165
|
`);
|
|
1181
1166
|
// The sessions registry (session-log item 7): every session log a run of
|
|
1182
1167
|
// this store claimed, with its thread — the sweep cannot enumerate the
|
|
@@ -1333,6 +1318,37 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
1333
1318
|
return r ? rowToLive(r) : undefined;
|
|
1334
1319
|
}
|
|
1335
1320
|
|
|
1321
|
+
/** The `runs` table's column migrations, run at every construction and
|
|
1322
|
+
* idempotent: a table created before a column existed gains it, with the
|
|
1323
|
+
* value a row written back then should read. The run-visibility stamp: a
|
|
1324
|
+
* run written before the stamp is `unknown`, never public. The session a
|
|
1325
|
+
* run was a range of (session-log item 7), so the sweep can tell which
|
|
1326
|
+
* sessions still have a kept run; null for a record without one. What the
|
|
1327
|
+
* run cost in tokens (costs.md, cost by user), NULL until the by-user
|
|
1328
|
+
* aggregate fills it from the run's stored events. The parent a child names
|
|
1329
|
+
* (run-history item 46), the column a children listing filters on: the
|
|
1330
|
+
* record already carries it in `summary_json`, so existing rows are filled
|
|
1331
|
+
* from there once, and every later `put` writes it beside the row. */
|
|
1332
|
+
migrateRunsTable(): void {
|
|
1333
|
+
const columns = new Set(
|
|
1334
|
+
this.sql
|
|
1335
|
+
.exec<{ name: string }>(`PRAGMA table_info(runs)`)
|
|
1336
|
+
.toArray()
|
|
1337
|
+
.map((c) => c.name),
|
|
1338
|
+
);
|
|
1339
|
+
if (!columns.has("channel_visibility"))
|
|
1340
|
+
this.sql.exec(`ALTER TABLE runs ADD COLUMN channel_visibility TEXT NOT NULL DEFAULT 'unknown'`);
|
|
1341
|
+
if (!columns.has("session_key")) this.sql.exec(`ALTER TABLE runs ADD COLUMN session_key TEXT`);
|
|
1342
|
+
if (!columns.has("usage_json")) this.sql.exec(`ALTER TABLE runs ADD COLUMN usage_json TEXT`);
|
|
1343
|
+
if (!columns.has("parent_run_id")) {
|
|
1344
|
+
this.sql.exec(`ALTER TABLE runs ADD COLUMN parent_run_id TEXT`);
|
|
1345
|
+
this.sql.exec(
|
|
1346
|
+
`UPDATE runs SET parent_run_id = json_extract(summary_json, '$.parentRunId')
|
|
1347
|
+
WHERE json_type(summary_json, '$.parentRunId') = 'text'`,
|
|
1348
|
+
);
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1336
1352
|
private liveByThread(threadKey: string): LiveRunRow | undefined {
|
|
1337
1353
|
const r = this.sql.exec<LiveRow>(`SELECT * FROM live_runs WHERE thread_key = ?`, threadKey).toArray()[0];
|
|
1338
1354
|
return r ? rowToLive(r) : undefined;
|
|
@@ -1832,8 +1848,8 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
1832
1848
|
);
|
|
1833
1849
|
this.sql.exec(
|
|
1834
1850
|
`INSERT INTO runs (run_id, label, agent, model, channel_id, user_id, thread_key, channel_visibility, repo, started_at, finished_at, stored_at, status,
|
|
1835
|
-
event_count, stored_event_count, truncated, bytes, diagnosis_json, summary_json, session_key, usage_json)
|
|
1836
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1851
|
+
event_count, stored_event_count, truncated, bytes, diagnosis_json, summary_json, session_key, usage_json, parent_run_id)
|
|
1852
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1837
1853
|
ON CONFLICT(run_id) DO UPDATE SET
|
|
1838
1854
|
label = excluded.label, agent = excluded.agent, model = excluded.model, channel_id = excluded.channel_id,
|
|
1839
1855
|
user_id = excluded.user_id, thread_key = excluded.thread_key, channel_visibility = excluded.channel_visibility,
|
|
@@ -1842,7 +1858,8 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
1842
1858
|
event_count = excluded.event_count, stored_event_count = excluded.stored_event_count, truncated = excluded.truncated,
|
|
1843
1859
|
bytes = excluded.bytes, diagnosis_json = excluded.diagnosis_json, summary_json = excluded.summary_json,
|
|
1844
1860
|
session_key = excluded.session_key,
|
|
1845
|
-
usage_json = COALESCE(excluded.usage_json, runs.usage_json)
|
|
1861
|
+
usage_json = COALESCE(excluded.usage_json, runs.usage_json),
|
|
1862
|
+
parent_run_id = excluded.parent_run_id`,
|
|
1846
1863
|
stored.id,
|
|
1847
1864
|
stored.label ?? null,
|
|
1848
1865
|
stored.agent ?? null,
|
|
@@ -1864,6 +1881,7 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
1864
1881
|
JSON.stringify(summary),
|
|
1865
1882
|
stored.session?.key ?? null,
|
|
1866
1883
|
stored.usage ? JSON.stringify(stored.usage) : null,
|
|
1884
|
+
stored.parentRunId ?? null,
|
|
1867
1885
|
);
|
|
1868
1886
|
// The session's registry row learns its newest finish (session-log item
|
|
1869
1887
|
// 7); a record that reaches the store without a claim (the plain put
|
|
@@ -2199,6 +2217,10 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2199
2217
|
where.push(`thread_key = ?`);
|
|
2200
2218
|
params.push(q.threadKey);
|
|
2201
2219
|
}
|
|
2220
|
+
if (q.parentRunId !== undefined) {
|
|
2221
|
+
where.push(`parent_run_id = ?`);
|
|
2222
|
+
params.push(q.parentRunId);
|
|
2223
|
+
}
|
|
2202
2224
|
if (q.visibleTo !== undefined && q.visibleTo.kind !== "all") where.push(visibilitySql(q.visibleTo, params));
|
|
2203
2225
|
const select = `SELECT run_id, agent, channel_id, finished_at, bytes, event_count, summary_json FROM runs WHERE ${where.join(" AND ")} ORDER BY finished_at DESC, run_id DESC`;
|
|
2204
2226
|
// `LIMIT` holds on the over-bound path too: the kept set is the newest
|
|
@@ -2441,6 +2463,11 @@ function parseRunList(body: unknown): Validated<{ storeKey: string; query: RunLi
|
|
|
2441
2463
|
return invalid(`${field} must be a string of at most ${MAX_KEY_CHARS} characters`);
|
|
2442
2464
|
query[field] = v;
|
|
2443
2465
|
}
|
|
2466
|
+
if (b.parentRunId !== undefined) {
|
|
2467
|
+
const id = parseRunId(b.parentRunId);
|
|
2468
|
+
if (!id.ok) return invalid("parentRunId must match ^[A-Za-z0-9_-]{1,64}$");
|
|
2469
|
+
query.parentRunId = id.value;
|
|
2470
|
+
}
|
|
2444
2471
|
if (b.visibleTo !== undefined) {
|
|
2445
2472
|
// A malformed filter is a 400, never "all": the bot degrades to live rows
|
|
2446
2473
|
// rather than the DO widening what an actor may see.
|
|
@@ -2453,8 +2480,8 @@ function parseRunList(body: unknown): Validated<{ storeKey: string; query: RunLi
|
|
|
2453
2480
|
}
|
|
2454
2481
|
|
|
2455
2482
|
/** Parameters the page query binds before any filter: the cursor pair (3) and the age floor (1),
|
|
2456
|
-
* plus `agent`, `channel`, `threadKey`, and the LIMIT at most — the headroom `visibleTo` must fit under. */
|
|
2457
|
-
const RUN_LIST_BASE_PARAMETERS =
|
|
2483
|
+
* plus `agent`, `channel`, `threadKey`, `parentRunId`, and the LIMIT at most — the headroom `visibleTo` must fit under. */
|
|
2484
|
+
const RUN_LIST_BASE_PARAMETERS = 9;
|
|
2458
2485
|
|
|
2459
2486
|
/** How many `?` a filter binds (one per id, one per user). */
|
|
2460
2487
|
function boundParameters(f: RunVisibilityFilter): number {
|
|
@@ -3400,8 +3427,8 @@ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<
|
|
|
3400
3427
|
)
|
|
3401
3428
|
return json({ error: `query must be a non-empty string of at most ${MAX_SEARCH_QUERY_BYTES} bytes` }, 400);
|
|
3402
3429
|
const limit = b.limit;
|
|
3403
|
-
if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit >
|
|
3404
|
-
return json({ error: `limit must be an integer in 1..${
|
|
3430
|
+
if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > SEARCH_MAX_HITS)
|
|
3431
|
+
return json({ error: `limit must be an integer in 1..${SEARCH_MAX_HITS}` }, 400);
|
|
3405
3432
|
const hits = await stub.search(b.query, limit);
|
|
3406
3433
|
const gaps =
|
|
3407
3434
|
hits.length > 1
|
|
@@ -1110,7 +1110,9 @@ const NO_REF_HINT_REASON: RefHintReason = { ownPr: null, refByDefault: false };
|
|
|
1110
1110
|
type RebindOutcome =
|
|
1111
1111
|
| { kind: "none"; binding: ThreadBinding }
|
|
1112
1112
|
| { kind: "refuse"; refused: RebindRefused }
|
|
1113
|
-
|
|
1113
|
+
/** `note`: the move was the record alone — a dirty tree already on the
|
|
1114
|
+
* branch, no checkout run — in the verdict's words, for the log line. */
|
|
1115
|
+
| { kind: "rebound"; moved: ThreadBinding; rebound: Rebound; note?: string };
|
|
1114
1116
|
|
|
1115
1117
|
/** Result of one /op test/build execution. `ok` is the command's
|
|
1116
1118
|
* verdict — a failing test run is a RESULT with ok:false, never an error. */
|
|
@@ -4654,29 +4656,44 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
4654
4656
|
);
|
|
4655
4657
|
tree.readable = status.exitCode === 0;
|
|
4656
4658
|
if (tree.readable) tree.dirty = status.stdout.trim() !== "";
|
|
4659
|
+
if (tree.dirty) {
|
|
4660
|
+
// A dirty tree is refused unless its HEAD is already the branch
|
|
4661
|
+
// (the run made it here and left an edit after pushing): the
|
|
4662
|
+
// one fact that tells the two apart, measured only when it
|
|
4663
|
+
// decides anything.
|
|
4664
|
+
const head = await this.threadRun(
|
|
4665
|
+
current.user,
|
|
4666
|
+
wt,
|
|
4667
|
+
"git rev-parse --abbrev-ref HEAD",
|
|
4668
|
+
DEFAULT_EXEC_TIMEOUT_MS,
|
|
4669
|
+
);
|
|
4670
|
+
if (head.exitCode === 0) tree.head = head.stdout.trim();
|
|
4671
|
+
}
|
|
4657
4672
|
}
|
|
4658
4673
|
// Judged on the re-read plan: it carries whether the branch is the
|
|
4659
4674
|
// thread's own, which the pre-lock plan lacks when it was a recreate.
|
|
4660
4675
|
const verdict = rebindVerdict(again, tree);
|
|
4661
4676
|
if (verdict.kind === "recreate") return this.recreateAtOwnBranch(current, again, fetchToken);
|
|
4662
4677
|
if (verdict.kind !== "rebind") return verdict;
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
|
|
4666
|
-
|
|
4667
|
-
|
|
4668
|
-
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
|
|
4678
|
+
if (verdict.checkout) {
|
|
4679
|
+
const startedAt = systemClock();
|
|
4680
|
+
const checkout = await this.threadRun(
|
|
4681
|
+
current.user,
|
|
4682
|
+
wt,
|
|
4683
|
+
`git checkout --quiet ${shellQuote(again.to)}`,
|
|
4684
|
+
DEFAULT_EXEC_TIMEOUT_MS,
|
|
4685
|
+
);
|
|
4686
|
+
this.stepTrace.getStore()?.record("rebind-checkout", {
|
|
4687
|
+
startedAt,
|
|
4688
|
+
endedAt: systemClock(),
|
|
4689
|
+
exitCode: checkout.exitCode,
|
|
4690
|
+
timedOut: checkout.timedOut,
|
|
4691
|
+
});
|
|
4692
|
+
if (checkout.exitCode !== 0) {
|
|
4693
|
+
const detail =
|
|
4694
|
+
checkout.stderr.trim().split("\n")[0]?.slice(0, 200) || `git checkout exited ${checkout.exitCode}`;
|
|
4695
|
+
return { kind: "refuse", refused: rebindRefused(again, "checkout-failed", detail) };
|
|
4696
|
+
}
|
|
4680
4697
|
}
|
|
4681
4698
|
const rebound: Rebound = {
|
|
4682
4699
|
from: current.ref,
|
|
@@ -4686,7 +4703,9 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
4686
4703
|
};
|
|
4687
4704
|
const moved: ThreadBinding = { ...current, ref: again.to, rebound };
|
|
4688
4705
|
await this.ctx.storage.put(key, moved);
|
|
4689
|
-
return
|
|
4706
|
+
return verdict.checkout
|
|
4707
|
+
? { kind: "rebound", moved, rebound }
|
|
4708
|
+
: { kind: "rebound", moved, rebound, note: verdict.note };
|
|
4690
4709
|
}, ATTACH_MUTEX_WAIT_MS)
|
|
4691
4710
|
).value;
|
|
4692
4711
|
} catch (err) {
|
|
@@ -4705,7 +4724,7 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
4705
4724
|
}
|
|
4706
4725
|
const { moved, rebound } = outcome;
|
|
4707
4726
|
console.log(
|
|
4708
|
-
`attach ${prior.threadKey}: rebound ${rebound.from} → ${rebound.to} (the thread's own pull request #${rebound.pr})`,
|
|
4727
|
+
`attach ${prior.threadKey}: rebound ${rebound.from} → ${rebound.to} (the thread's own pull request #${rebound.pr})${outcome.note ? ` — ${outcome.note}` : ""}`,
|
|
4709
4728
|
);
|
|
4710
4729
|
return { binding: moved, rebound };
|
|
4711
4730
|
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.230.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "switchboard",
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.230.0",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"workspaces": [
|
|
12
12
|
"web",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"eslint-plugin-vue": "^10.11.0",
|
|
41
41
|
"globals": "^17.12.0",
|
|
42
42
|
"prettier": "^3.9.6",
|
|
43
|
+
"semver": "^7.8.5",
|
|
43
44
|
"tsx": "^4.23.13",
|
|
44
45
|
"typescript": "^5.9.3",
|
|
45
46
|
"typescript-eslint": "^8.70.0",
|
|
@@ -652,6 +653,8 @@
|
|
|
652
653
|
},
|
|
653
654
|
"deploy/cloudflare-memory/node_modules/vitest": {
|
|
654
655
|
"version": "4.1.11",
|
|
656
|
+
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
|
|
657
|
+
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
|
|
655
658
|
"dev": true,
|
|
656
659
|
"license": "MIT",
|
|
657
660
|
"dependencies": {
|
|
@@ -20029,7 +20032,7 @@
|
|
|
20029
20032
|
},
|
|
20030
20033
|
"packages/switchboard": {
|
|
20031
20034
|
"name": "@coreplane/switchboard",
|
|
20032
|
-
"version": "1.
|
|
20035
|
+
"version": "1.230.0",
|
|
20033
20036
|
"license": "Apache-2.0",
|
|
20034
20037
|
"dependencies": {
|
|
20035
20038
|
"@earendil-works/pi-ai": "0.85.1",
|
package/dist/assets/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.230.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -97,6 +97,7 @@
|
|
|
97
97
|
"eslint-plugin-vue": "^10.11.0",
|
|
98
98
|
"globals": "^17.12.0",
|
|
99
99
|
"prettier": "^3.9.6",
|
|
100
|
+
"semver": "^7.8.5",
|
|
100
101
|
"tsx": "^4.23.13",
|
|
101
102
|
"typescript": "^5.9.3",
|
|
102
103
|
"typescript-eslint": "^8.70.0",
|
package/dist/assets/source.json
CHANGED
|
@@ -89,12 +89,30 @@ const CHAT_SURFACES: Readonly<Record<string, ActorSurface>> = {
|
|
|
89
89
|
* A namespace this module does not know stays a `user` with the id as given —
|
|
90
90
|
* its grants are whatever config names for that id, never a guess. */
|
|
91
91
|
export function resolveChatActor(
|
|
92
|
-
msg: { userId: string; channelId: string; threadKey: string },
|
|
92
|
+
msg: { userId: string; channelId: string; threadKey: string; postedBy?: string },
|
|
93
93
|
grantsFor: GrantsLookup,
|
|
94
94
|
): Actor {
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
95
|
+
const person = resolveNamespacedActor(msg.userId, msg, grantsFor);
|
|
96
|
+
if (msg.postedBy === undefined) return person;
|
|
97
|
+
// A request an app posted for a person (slack-channel.md item 13): the
|
|
98
|
+
// message text named the person, and text is forgeable, so the person's
|
|
99
|
+
// grants alone must never govern. The actor is the app, acting on the
|
|
100
|
+
// person's behalf — `effectiveGrants` is the intersection, so the run holds
|
|
101
|
+
// no more than the app holds (the surface baseline, plus whatever config
|
|
102
|
+
// grants that app id by name) and no more than the person holds. Identity
|
|
103
|
+
// (`userId`, the record, the costs page) is still the person's.
|
|
104
|
+
const app = resolveNamespacedActor(msg.postedBy, msg, grantsFor);
|
|
105
|
+
return { ...app, kind: "agent", onBehalfOf: person };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function resolveNamespacedActor(
|
|
109
|
+
userId: string,
|
|
110
|
+
origin: { channelId: string; threadKey: string },
|
|
111
|
+
grantsFor: GrantsLookup,
|
|
112
|
+
): Actor {
|
|
113
|
+
const colon = userId.indexOf(":");
|
|
114
|
+
const surface = colon > 0 ? CHAT_SURFACES[userId.slice(0, colon)] : undefined;
|
|
115
|
+
const at = { channelId: origin.channelId, threadKey: origin.threadKey };
|
|
116
|
+
if (surface === undefined) return { kind: "user", id: userId, grants: grantsFor(userId), origin: at };
|
|
117
|
+
return resolveActor({ surface, subjectId: userId.slice(colon + 1), ...at }, grantsFor);
|
|
100
118
|
}
|
|
@@ -36,6 +36,25 @@ export function idempotencyKeyFor(parentInstanceId: string, step: string): strin
|
|
|
36
36
|
return `${parentInstanceId}:${step}`;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/** A unit's id as the plan spells it (`U16`) or `task` — the `unit` field of a unit row. */
|
|
40
|
+
export const UNIT_PATTERN = /^[A-Za-z0-9_-]{1,32}$/;
|
|
41
|
+
/** `<instanceId>:<unit>` — the one name a unit has outside its instance: the
|
|
42
|
+
* prefix every child's idempotency key carries before its `/<round>/<kind>`
|
|
43
|
+
* step, so a unit is addressed by the same words its runs are stamped with.
|
|
44
|
+
* An instance id has no colon, so the first colon splits the two halves. */
|
|
45
|
+
export const UNIT_KEY_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9_-]{0,99}:[A-Za-z0-9_-]{1,32}$/;
|
|
46
|
+
|
|
47
|
+
export function unitKeyOf(unit: { instanceId: string; unit: string }): string {
|
|
48
|
+
return `${unit.instanceId}:${unit.unit}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The two halves of a unit key, or undefined for anything that is not one. */
|
|
52
|
+
export function parseUnitKey(key: string): { instanceId: string; unit: string } | undefined {
|
|
53
|
+
if (!UNIT_KEY_PATTERN.test(key)) return undefined;
|
|
54
|
+
const at = key.indexOf(":");
|
|
55
|
+
return { instanceId: key.slice(0, at), unit: key.slice(at + 1) };
|
|
56
|
+
}
|
|
57
|
+
|
|
39
58
|
/** The event a child's terminal record sends its parent: the type carries the
|
|
40
59
|
* run id, so each `waitForEvent` matches its own child and a duplicate is
|
|
41
60
|
* buffered harmlessly. An event type is the platform's alphabet — letters,
|
|
@@ -76,11 +76,13 @@ export type RunNoteKind =
|
|
|
76
76
|
* wait had refused; on pi the container is provisioned before pi starts, so
|
|
77
77
|
* the note is a record fact from before the loop's deletion. */
|
|
78
78
|
| "fleet_busy"
|
|
79
|
-
/** The
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
79
|
+
/** The container the run's pi ran in was replaced under the live run
|
|
80
|
+
* (docs/reference/specs/harness-pi.md item 16; the resident's roll,
|
|
81
|
+
* resident-repos.md item 65): the harness settled the call in flight with
|
|
82
|
+
* the restart note, the summary names both containers, and the run ends
|
|
83
|
+
* `interrupted` for a restart from its request. On a record from before the
|
|
84
|
+
* native loop's deletion the note says that loop's settlement instead: the
|
|
85
|
+
* executor waited for the wake and the run went on. */
|
|
84
86
|
| "sandbox_restarted"
|
|
85
87
|
| "stop_requested"
|
|
86
88
|
| "stopped"
|
|
@@ -114,6 +116,11 @@ export type RunNoteKind =
|
|
|
114
116
|
* bounded extra model turn to submit it (docs/reference/specs/pr-description.md
|
|
115
117
|
* item 5). Published by the dispatcher before that turn. */
|
|
116
118
|
| "description_turn"
|
|
119
|
+
/** A review run's loop ended on a pull request without `submit_verdict`, and
|
|
120
|
+
* the same run is being given one bounded extra model turn to call it
|
|
121
|
+
* (docs/reference/specs/agent-review.md item 5; verdictTurn.ts). Published by
|
|
122
|
+
* the dispatcher before that turn. */
|
|
123
|
+
| "verdict_turn"
|
|
117
124
|
/** The run is on a cold per-thread sandbox instead of a warm resident, and
|
|
118
125
|
* the summary says why — the resident attach failed (its steps so far are
|
|
119
126
|
* grafted under the attach span), the resident was unreachable or not
|
|
@@ -156,6 +163,14 @@ export type RunNoteKind =
|
|
|
156
163
|
* know — named, so a pi bump is visible in the first run's record. Published
|
|
157
164
|
* by the pi bridge. */
|
|
158
165
|
| "harness_error"
|
|
166
|
+
/** The model provider refused the run's call under its usage policy — the
|
|
167
|
+
* stop reason its wire names for a classifier's refusal, never the words
|
|
168
|
+
* (harness-pi.md item 6): the summary carries the provider's explanation
|
|
169
|
+
* for the run page; the run fails by name, its record says
|
|
170
|
+
* `failure: policy_refusal` (run-history.md item 57), the thread reads one
|
|
171
|
+
* sentence on how to go on, and the session's next seed leaves the refused
|
|
172
|
+
* request out (session-log.md item 9). Published by the pi harness. */
|
|
173
|
+
| "policy_refusal"
|
|
159
174
|
/** The harness's gate refused a tool call the model asked for (harness-pi.md
|
|
160
175
|
* item 7): the summary names the tool and the rule; the model read the same
|
|
161
176
|
* reason as the tool's result. Published by the bot's authorize route. */
|
|
@@ -183,12 +198,14 @@ export const RUN_NOTE_KINDS = [
|
|
|
183
198
|
"resumed",
|
|
184
199
|
"seed",
|
|
185
200
|
"description_turn",
|
|
201
|
+
"verdict_turn",
|
|
186
202
|
"cold_sandbox",
|
|
187
203
|
"rebind_refused",
|
|
188
204
|
"pr_not_opened",
|
|
189
205
|
"review_not_posted",
|
|
190
206
|
"compacted",
|
|
191
207
|
"harness_error",
|
|
208
|
+
"policy_refusal",
|
|
192
209
|
"tool_refused",
|
|
193
210
|
"stuck_loop",
|
|
194
211
|
] as const satisfies readonly RunNoteKind[];
|
|
@@ -564,9 +564,10 @@ export function analyzeRunFriction(events: readonly RunEvent[], opts: FrictionOp
|
|
|
564
564
|
});
|
|
565
565
|
return;
|
|
566
566
|
case "sandbox_restarted":
|
|
567
|
-
// The container rolled under the run
|
|
568
|
-
//
|
|
569
|
-
//
|
|
567
|
+
// The container rolled under the run: on pi the run ends here and its
|
|
568
|
+
// request starts over (harness-pi item 16); on the deleted native loop
|
|
569
|
+
// it went on after the wake. Either way the roll's cost is friction the
|
|
570
|
+
// deploy window owns.
|
|
570
571
|
findings.push({
|
|
571
572
|
category: "infra_failure",
|
|
572
573
|
severity: "medium",
|
|
@@ -15,6 +15,19 @@ export { isRunSession, SESSION_KEY_PATTERN, type RunSession } from "../runRecord
|
|
|
15
15
|
/** The byte policy's default: `RetentionPolicy.sessionLogMaxBytes`. */
|
|
16
16
|
export const DEFAULT_SESSION_LOG_MAX_BYTES = DEFAULT_RETENTION_POLICY.sessionLogMaxBytes;
|
|
17
17
|
|
|
18
|
+
/** How much of a hit's text a search answers with (item 10): one line, at most this many characters. */
|
|
19
|
+
export const SNIPPET_CHARS = 300;
|
|
20
|
+
/** The most hits one search answers — the object's cap, `recall`'s and the search route's alike. */
|
|
21
|
+
export const SEARCH_MAX_HITS = 50;
|
|
22
|
+
|
|
23
|
+
/** A hit's text as one line of at most `SNIPPET_CHARS` — what `recall` and
|
|
24
|
+
* the session search route answer beside the turn, so a reader sees where
|
|
25
|
+
* the words fell without the turn's whole body. */
|
|
26
|
+
export function snippetOf(text: string): string {
|
|
27
|
+
const line = text.replace(/\s+/g, " ").trim();
|
|
28
|
+
return line.length > SNIPPET_CHARS ? `${line.slice(0, SNIPPET_CHARS - 1)}…` : line;
|
|
29
|
+
}
|
|
30
|
+
|
|
18
31
|
/** The object's name: the thread and the agent, the pair record 0034 calls a
|
|
19
32
|
* session. A run without a resolved agent keys on a dash so the name still
|
|
20
33
|
* has both halves. */
|
|
@@ -69,7 +69,12 @@ export interface RunRecord {
|
|
|
69
69
|
model?: string;
|
|
70
70
|
/** Platform-namespaced ids (AGENTS.md invariant 4). */
|
|
71
71
|
channelId: string;
|
|
72
|
+
/** The person the run was for — the message's sender, or the person an app
|
|
73
|
+
* relayed it for (slack-channel.md item 13); `slack:bot:<id>` only when no
|
|
74
|
+
* person could be found behind an app's post. */
|
|
72
75
|
userId: string;
|
|
76
|
+
/** The app that posted the request for `userId`, by display name, when it was not their own message. */
|
|
77
|
+
relayedBy?: string;
|
|
73
78
|
threadKey: string;
|
|
74
79
|
/** How the run's channel may travel (authorization): stamped at dispatch
|
|
75
80
|
* from the `ChannelDirectory`, read by `member-of` (a `public` run is
|
|
@@ -101,6 +106,11 @@ export interface RunRecord {
|
|
|
101
106
|
stepCount?: number;
|
|
102
107
|
schema?: number;
|
|
103
108
|
status: RunStatus;
|
|
109
|
+
/** The failure by name, when a `failed` run has one (item 57):
|
|
110
|
+
* `policy_refusal`, the provider refused the run's model call under its
|
|
111
|
+
* usage policy. Absent on a run that did not fail, on one that failed for
|
|
112
|
+
* a reason without a name here, and on records written before the field. */
|
|
113
|
+
failure?: RunFailure;
|
|
104
114
|
/** Events the run published in total — unchanged by truncation. */
|
|
105
115
|
eventCount: number;
|
|
106
116
|
/** Events actually present in `events` (= `events.length`). */
|
|
@@ -293,6 +303,23 @@ function isRunPullRequestShape(v: unknown): v is RunPullRequest {
|
|
|
293
303
|
);
|
|
294
304
|
}
|
|
295
305
|
|
|
306
|
+
/** Why a `failed` run failed, when the failure has a name a reader acts on
|
|
307
|
+
* (item 57). `policy_refusal`: the model provider refused the run's call
|
|
308
|
+
* under its usage policy — the stop reason its wire names, never the
|
|
309
|
+
* explanation's words — so the session's next seed leaves the refused
|
|
310
|
+
* request out of its tail (docs/reference/specs/session-log.md item 9). A
|
|
311
|
+
* failure without a name here leaves the record without the field. */
|
|
312
|
+
export const RUN_FAILURE_KINDS = ["policy_refusal"] as const;
|
|
313
|
+
export type RunFailureKind = (typeof RUN_FAILURE_KINDS)[number];
|
|
314
|
+
export interface RunFailure {
|
|
315
|
+
kind: RunFailureKind;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function isRunFailure(v: unknown): v is RunFailure {
|
|
319
|
+
if (typeof v !== "object" || v === null) return false;
|
|
320
|
+
return RUN_FAILURE_KINDS.includes((v as Record<string, unknown>).kind as RunFailureKind);
|
|
321
|
+
}
|
|
322
|
+
|
|
296
323
|
/** The three places a run's conversation can start (item 52): the thread's
|
|
297
324
|
* channel history, a spawning parent's text turns, or the tail of its own
|
|
298
325
|
* session's log (docs/reference/specs/session-log.md item 9). */
|
|
@@ -426,6 +453,9 @@ export interface RunListOptions {
|
|
|
426
453
|
/** One thread's runs (`slack:C0123:1712.34`), newest first — the read behind
|
|
427
454
|
* a thread's lineage and a child's thread-aware rows (agent-conductor item 10). */
|
|
428
455
|
threadKey?: string;
|
|
456
|
+
/** The runs one run spawned or that continue a thread it opened
|
|
457
|
+
* (`RunRecord.parentRunId`, item 46) — a conductor's children as one listing. */
|
|
458
|
+
parentRunId?: string;
|
|
429
459
|
/** What the ACTOR may see (authorization): the store predicate compiled
|
|
430
460
|
* from the policy, pushed down so no surface loads rows and filters after.
|
|
431
461
|
* Absent = no visibility constraint — only a caller that has already decided
|
|
@@ -719,6 +749,8 @@ export function isRunRecord(v: unknown): v is RunRecord {
|
|
|
719
749
|
if (r.seed !== undefined && !RUN_SEEDS.includes(r.seed as RunSeed)) return false;
|
|
720
750
|
// The run's place in its session's log (item 53), or absent.
|
|
721
751
|
if (r.session !== undefined && !isRunSession(r.session)) return false;
|
|
752
|
+
// The failure by name (item 57): one of the named kinds, or absent.
|
|
753
|
+
if (r.failure !== undefined && !isRunFailure(r.failure)) return false;
|
|
722
754
|
if (r.usage !== undefined && !isRunUsage(r.usage)) return false;
|
|
723
755
|
// A coordinator's child (item 48): the instance id in the platform's alphabet
|
|
724
756
|
// and the key `<instance>:<step>` — both or neither; one alone is no tag.
|
|
@@ -734,6 +766,7 @@ export function isRunRecord(v: unknown): v is RunRecord {
|
|
|
734
766
|
)
|
|
735
767
|
return false;
|
|
736
768
|
if (typeof r.channelId !== "string" || typeof r.userId !== "string" || typeof r.threadKey !== "string") return false;
|
|
769
|
+
if (r.relayedBy !== undefined && typeof r.relayedBy !== "string") return false;
|
|
737
770
|
// Absent on records written before the stamp existed (read as `unknown`); present → a known value.
|
|
738
771
|
if (r.channelVisibility !== undefined && !CHANNEL_VISIBILITIES.includes(r.channelVisibility as ChannelVisibility))
|
|
739
772
|
return false;
|
|
@@ -22,6 +22,8 @@ export interface AttrDomain {
|
|
|
22
22
|
caughtUp: boolean;
|
|
23
23
|
files: number;
|
|
24
24
|
dedupe: "fresh" | "duplicate";
|
|
25
|
+
/** How the requester was found (slack-channel.md item 13): the sender, the relay footer's thread, the thread's parent, or the app itself. */
|
|
26
|
+
requester: "message" | "relay-footer" | "thread-parent" | "bot";
|
|
25
27
|
// dispatch.* / run.* / post.*
|
|
26
28
|
outcome: string;
|
|
27
29
|
count: number;
|
|
@@ -151,6 +153,7 @@ const ATTR_TYPE: Record<SpanAttrKey, "string" | "number" | "boolean"> = {
|
|
|
151
153
|
caughtUp: "boolean",
|
|
152
154
|
files: "number",
|
|
153
155
|
dedupe: "string",
|
|
156
|
+
requester: "string",
|
|
154
157
|
outcome: "string",
|
|
155
158
|
count: "number",
|
|
156
159
|
backend: "string",
|
|
@@ -44,6 +44,7 @@ export const STREAMED_SPANS = [
|
|
|
44
44
|
"run.reading_diff",
|
|
45
45
|
"run.settle_reviewed_head",
|
|
46
46
|
"run.description_turn",
|
|
47
|
+
"run.verdict_turn",
|
|
47
48
|
"run.observe_workspace",
|
|
48
49
|
"run.pr_post_step",
|
|
49
50
|
"run.review_post_step",
|
|
@@ -105,6 +106,7 @@ const UNCOUNTED: ReadonlySet<string> = new Set([
|
|
|
105
106
|
"ship.round",
|
|
106
107
|
"run.settle_reviewed_head",
|
|
107
108
|
"run.description_turn",
|
|
109
|
+
"run.verdict_turn",
|
|
108
110
|
"post.card_close",
|
|
109
111
|
"post.reply",
|
|
110
112
|
]);
|
|
@@ -148,11 +150,12 @@ export const PARENTS: Readonly<Record<string, readonly string[]>> = {
|
|
|
148
150
|
"dispatch.ship_preflight": ["request"],
|
|
149
151
|
"dispatch.ledger_claim": ["request"],
|
|
150
152
|
"dispatch.route": ["request"],
|
|
151
|
-
"run.agent": ["request", "ship.round", "run.settle_reviewed_head", "run.description_turn"],
|
|
153
|
+
"run.agent": ["request", "ship.round", "run.settle_reviewed_head", "run.description_turn", "run.verdict_turn"],
|
|
152
154
|
"run.command": ["request"],
|
|
153
155
|
"run.reading_diff": ["request"],
|
|
154
156
|
"run.settle_reviewed_head": ["request", "ship.round"],
|
|
155
157
|
"run.description_turn": ["request", "ship.round"],
|
|
158
|
+
"run.verdict_turn": ["request", "ship.round"],
|
|
156
159
|
"run.observe_workspace": ["request", "ship.round"],
|
|
157
160
|
"run.pr_post_step": ["request", "ship.round"],
|
|
158
161
|
"run.review_post_step": ["request", "ship.round"],
|