@coreplane/switchboard 1.229.0 → 1.231.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 +41 -14
- package/dist/assets/src/core/coordinator/driver.ts +4 -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/ship/contract.ts +35 -29
- package/dist/assets/src/core/ship/coordinator.ts +89 -4
- 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-D-v8am88.js +2 -0
- package/dist/assets/web/dist/assets/{ResidentDetailPage-CkVYktwT.js → ResidentDetailPage-Df6D2emM.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-BLanxf27.js → ResidentsIndexPage-B0yECe2z.js} +1 -1
- package/dist/assets/web/dist/assets/{RunRoutePage-U3nwL8Df.js → RunRoutePage-D4FG8JbC.js} +4 -4
- package/dist/assets/web/dist/assets/{RunsIndexPage-DrIVxmpl.js → RunsIndexPage-qxjCAVwQ.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-DpqubmIm.js → ScheduledPage-B0xQSZIS.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-BpD9MRge.js → StatusDot-D-h9p7Iv.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-BYv0WSrA.js → Tooltip-BuDnXjL7.js} +1 -1
- package/dist/assets/web/dist/assets/{dist-BZmA5qTt.js → dist-BjLBNLkl.js} +1 -1
- package/dist/assets/web/dist/assets/{main-C4GOEklV.js → main-BRrMt0Kr.js} +2 -2
- package/dist/cli.js +1895 -1039
- 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.231.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "switchboard",
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.231.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.231.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.231.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,
|
|
@@ -98,20 +117,23 @@ export interface CoordinatorInstance {
|
|
|
98
117
|
userName?: string;
|
|
99
118
|
channelId: string;
|
|
100
119
|
channelName?: string;
|
|
101
|
-
/** The requesting thread: where the card lives and where a
|
|
102
|
-
*
|
|
120
|
+
/** The requesting thread: where the card lives and where a generated plan's
|
|
121
|
+
* one unit runs; a seeded plan's units each open a thread of their own. */
|
|
103
122
|
threadKey: string;
|
|
104
123
|
sourceUrl?: string;
|
|
105
|
-
/** `owner/name`, and the head branch the pipeline works on (
|
|
106
|
-
*
|
|
124
|
+
/** `owner/name`, and the head branch the pipeline works on (the first unit's
|
|
125
|
+
* — each unit row names its own). */
|
|
107
126
|
repo: string;
|
|
108
127
|
branch: string;
|
|
109
128
|
/** The pull request's base branch, when the creator knew it. */
|
|
110
129
|
base?: string;
|
|
111
130
|
/** Epoch ms. */
|
|
112
131
|
createdAt: number;
|
|
113
|
-
/** The plan the instance runs
|
|
114
|
-
plan
|
|
132
|
+
/** The plan the instance runs: its id and, for a seeded plan, its path in
|
|
133
|
+
* the repository. A `plan` without a `path` is the generated one-unit plan a
|
|
134
|
+
* task request becomes — the mark that keeps its unit in the requesting
|
|
135
|
+
* thread (agent-ship item 16). */
|
|
136
|
+
plan?: { id: string; path?: string };
|
|
115
137
|
/** Who merges the units' pull requests: `runner` for a seeded plan (the
|
|
116
138
|
* `merge` step under `plan:merge`), `person` for a task. Written by the
|
|
117
139
|
* hand-off, answered by the plan route, checked at the merge door; absent
|
|
@@ -131,20 +153,21 @@ export interface CoordinatorInstance {
|
|
|
131
153
|
attempt?: number;
|
|
132
154
|
}
|
|
133
155
|
|
|
134
|
-
/** One unit of the plan an instance runs (a task string is a plan of
|
|
135
|
-
* `
|
|
136
|
-
* its thread, its pull request, the round boundaries the card
|
|
137
|
-
* ended. One row a person can read for "what happened to this
|
|
156
|
+
/** One unit of the plan an instance runs (a task string is a generated plan of
|
|
157
|
+
* one unit, `U1`): its branch, the units it waits on, and — as the runner
|
|
158
|
+
* reaches it — its thread, its pull request, the round boundaries the card
|
|
159
|
+
* drew and how it ended. One row a person can read for "what happened to this
|
|
160
|
+
* unit". */
|
|
138
161
|
export interface CoordinatorUnit {
|
|
139
162
|
instanceId: string;
|
|
140
|
-
/** `U<n>` as the plan spells it
|
|
163
|
+
/** `U<n>` as the plan spells it. */
|
|
141
164
|
unit: string;
|
|
142
165
|
slug: string;
|
|
143
166
|
title?: string;
|
|
144
|
-
/** `plan/<plan-id>/<unit-slug
|
|
167
|
+
/** `plan/<plan-id>/<unit-slug>` (a resume's is the pull request's own head branch). */
|
|
145
168
|
branch: string;
|
|
146
169
|
dependsOn: string[];
|
|
147
|
-
/** The unit's thread, once opened; a
|
|
170
|
+
/** The unit's thread, once opened; a generated plan's is the requesting thread from the start. */
|
|
148
171
|
threadKey?: string;
|
|
149
172
|
sourceUrl?: string;
|
|
150
173
|
/** The unit's review thread, opened once beside the unit's thread: every
|
|
@@ -195,7 +218,11 @@ export function isCoordinatorInstance(v: unknown): v is CoordinatorInstance {
|
|
|
195
218
|
if (typeof r.repo !== "string" || !REPO_SLUG.test(r.repo)) return false;
|
|
196
219
|
if (!isText(r.branch) || !isOptionalText(r.base)) return false;
|
|
197
220
|
if (!isFinite(r.createdAt)) return false;
|
|
198
|
-
if (
|
|
221
|
+
if (
|
|
222
|
+
r.plan !== undefined &&
|
|
223
|
+
!(isObject(r.plan) && isText(r.plan.id) && (r.plan.path === undefined || isText(r.plan.path, 1024)))
|
|
224
|
+
)
|
|
225
|
+
return false;
|
|
199
226
|
if (r.merge !== undefined && r.merge !== "runner" && r.merge !== "person") return false;
|
|
200
227
|
if (r.caps !== undefined && !(isObject(r.caps) && isFinite(r.caps.maxRounds) && isFinite(r.caps.maxMinutes)))
|
|
201
228
|
return false;
|
|
@@ -158,6 +158,8 @@ interface PlanFacts {
|
|
|
158
158
|
planId?: string;
|
|
159
159
|
/** Who merges, as the instance's field has it: the plan route answers it, `person` when absent. */
|
|
160
160
|
merge: "runner" | "person";
|
|
161
|
+
/** The instance's mark as the plan route answers it: a generated one-unit plan (a `plan` with no `path`). */
|
|
162
|
+
generated: boolean;
|
|
161
163
|
repo: string;
|
|
162
164
|
base: string;
|
|
163
165
|
caps: ShipCaps;
|
|
@@ -186,6 +188,7 @@ function readPlan(a: BotAnswer): PlanFacts {
|
|
|
186
188
|
return {
|
|
187
189
|
...(typeof b.planId === "string" ? { planId: b.planId } : {}),
|
|
188
190
|
merge: b.merge === "runner" ? "runner" : "person",
|
|
191
|
+
generated: b.generated === true,
|
|
189
192
|
repo: b.repo,
|
|
190
193
|
base: b.base,
|
|
191
194
|
caps: { maxRounds: b.caps.maxRounds, maxMinutes: b.caps.maxMinutes },
|
|
@@ -429,6 +432,7 @@ async function runUnit(
|
|
|
429
432
|
// seeded plan and `person` on a task, and the door re-checks it — the
|
|
430
433
|
// branch's name never decides.
|
|
431
434
|
merge: plan.merge,
|
|
435
|
+
generated: plan.generated,
|
|
432
436
|
...(resume !== undefined ? { resume } : {}),
|
|
433
437
|
},
|
|
434
438
|
start.at,
|
|
@@ -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. */
|