@coreplane/switchboard 1.213.0 → 1.214.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 +5 -1
- package/dist/assets/deploy/cloudflare/artifactsCopy.ts +180 -0
- package/dist/assets/deploy/cloudflare/ensure-bucket.mjs +71 -0
- package/dist/assets/deploy/cloudflare/package.json +1 -1
- package/dist/assets/deploy/cloudflare/worker.ts +19 -1
- package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +15 -0
- package/dist/assets/deploy/profile.example.json +1 -1
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +1 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +39 -14
- package/dist/assets/src/config/profile.ts +8 -5
- package/dist/assets/src/core/runEvents.ts +6 -1
- package/dist/assets/src/core/runFriction.ts +9 -2
- package/dist/assets/src/core/runLedger/types.ts +4 -0
- package/dist/assets/src/core/runRecord.ts +12 -0
- package/dist/assets/src/core/trace/workerTrace.ts +2 -0
- package/dist/assets/src/deploy/profile.ts +11 -0
- package/dist/assets/src/execution/residentText.ts +17 -2
- package/dist/assets/src/providers/types.ts +6 -0
- package/dist/cli.js +1069 -356
- package/package.json +1 -1
|
@@ -341,10 +341,14 @@ workspaceDir: ./workspaces
|
|
|
341
341
|
# preset, the card saying why (`routed: <reason>`) and how to run it another way.
|
|
342
342
|
# `ship` is never routed. The one way off, so every plain message runs
|
|
343
343
|
# `defaults.agent` as before the router: `auto: false`. `model` is the router's
|
|
344
|
-
# model; default `defaults.models.general`.
|
|
344
|
+
# model; default `defaults.models.general`. `answer` is how that model answers:
|
|
345
|
+
# `tool` (default) forces a call to the router's `route` tool, whose schema is
|
|
346
|
+
# the answer, so prose cannot occur; `text` is the one-JSON-object text contract
|
|
347
|
+
# alone — set it for a provider or model that cannot take a forced tool call.
|
|
345
348
|
# routing:
|
|
346
349
|
# auto: false
|
|
347
350
|
# model: anthropic/claude-haiku-4-5
|
|
351
|
+
# answer: text
|
|
348
352
|
|
|
349
353
|
# Spend reporting: GET /costs (Access-gated). Optional. See docs/reference/specs/costs.md.
|
|
350
354
|
# costs:
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// The artifact copy route (docs/reference/specs/execution.md item 20, record
|
|
2
|
+
// 0033): `POST /artifacts/copy { url, size, key }` streams a Slack file into
|
|
3
|
+
// the artifacts bucket without the bot's container ever holding the bytes.
|
|
4
|
+
// The bot asks (`R2ArtifactStore.copyFromUrl`, bearer ARTIFACTS_COPY_TOKEN);
|
|
5
|
+
// this Worker holds the two things the copy needs — the R2 binding and the
|
|
6
|
+
// Slack bot token that `url_private` requires — and answers `{ key, size }`
|
|
7
|
+
// only once the bucket holds exactly `size` bytes under `key`.
|
|
8
|
+
//
|
|
9
|
+
// Pure over its deps so it is tested in plain Node (this directory's tests
|
|
10
|
+
// run no workerd): the bucket, the upstream fetch, the length pipe (workerd's
|
|
11
|
+
// `FixedLengthStream`, which refuses a body of another length) and the tokens
|
|
12
|
+
// are handed in; `worker.ts` binds them from `env`.
|
|
13
|
+
|
|
14
|
+
/** What the route needs from the bucket: R2's `put` with a stream and the object's type. */
|
|
15
|
+
export interface CopyBucket {
|
|
16
|
+
put(
|
|
17
|
+
key: string,
|
|
18
|
+
value: ReadableStream<Uint8Array>,
|
|
19
|
+
options?: { httpMetadata?: { contentType?: string } },
|
|
20
|
+
): Promise<unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CopyDeps {
|
|
24
|
+
/** The `ARTIFACTS` R2 binding; absent when the deployment configures no bucket. */
|
|
25
|
+
bucket: CopyBucket | undefined;
|
|
26
|
+
/** The bucket's name as the Worker was deployed with (`ARTIFACTS_BUCKET_NAME`), for `GET`. */
|
|
27
|
+
bucketName: string | undefined;
|
|
28
|
+
/** The bearer the bot presents (`ARTIFACTS_COPY_TOKEN`). */
|
|
29
|
+
copyToken: string | undefined;
|
|
30
|
+
/** The Slack bot token `url_private` requires. Read from the Worker's env, never from the request. */
|
|
31
|
+
slackToken: string | undefined;
|
|
32
|
+
fetch: typeof fetch;
|
|
33
|
+
/** A pipe that carries exactly `size` bytes and fails on any other count:
|
|
34
|
+
* `new FixedLengthStream(size)` in workerd, whose reader R2 accepts as a
|
|
35
|
+
* stream of known length; a `TransformStream` in tests. */
|
|
36
|
+
lengthPipe: (size: number) => { readable: ReadableStream<Uint8Array>; writable: WritableStream<Uint8Array> };
|
|
37
|
+
/** The largest copy accepted; default `MAX_COPY_BYTES`. */
|
|
38
|
+
maxBytes?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const COPY_PATH = "/artifacts/copy";
|
|
42
|
+
|
|
43
|
+
/** Slack's own per-file ceiling; a larger declared size is refused before any fetch. */
|
|
44
|
+
export const MAX_COPY_BYTES = 1_073_741_824;
|
|
45
|
+
|
|
46
|
+
/** The hosts a `url_private` may name: Slack's file host and the workspace's own. */
|
|
47
|
+
export function slackFileHost(hostname: string): boolean {
|
|
48
|
+
return hostname === "files.slack.com" || hostname === "slack.com" || hostname.endsWith(".slack.com");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Inbound keys as `inboundKey` (src/artifacts/keys.ts) builds them and nothing
|
|
52
|
+
* else: under `threads/`, one character class, no empty or dot segments. */
|
|
53
|
+
const KEY_RE = /^threads\/[A-Za-z0-9._-]+\/in\/[A-Za-z0-9._-]+\/\d+-[A-Za-z0-9._-]+$/;
|
|
54
|
+
export function validCopyKey(key: string): boolean {
|
|
55
|
+
return KEY_RE.test(key) && !key.split("/").some((s) => s === "" || /^\.+$/.test(s));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Constant-time equality over the two strings' UTF-8 bytes (a length difference is a mismatch). */
|
|
59
|
+
export function sameToken(a: string, b: string): boolean {
|
|
60
|
+
const x = new TextEncoder().encode(a);
|
|
61
|
+
const y = new TextEncoder().encode(b);
|
|
62
|
+
let diff = x.length ^ y.length;
|
|
63
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) diff |= (x[i] ?? 0) ^ (y[i] ?? 0);
|
|
64
|
+
return diff === 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type CopyRequest = { url: URL; size: number; key: string };
|
|
68
|
+
|
|
69
|
+
/** Pure: the body parsed and checked, or the 4xx that refuses it — before any fetch. */
|
|
70
|
+
export function parseCopyRequest(
|
|
71
|
+
raw: string,
|
|
72
|
+
maxBytes: number,
|
|
73
|
+
): { ok: true; value: CopyRequest } | { ok: false; status: 400 | 413; reason: string } {
|
|
74
|
+
let body: unknown;
|
|
75
|
+
try {
|
|
76
|
+
body = JSON.parse(raw);
|
|
77
|
+
} catch {
|
|
78
|
+
return { ok: false, status: 400, reason: "body is not JSON" };
|
|
79
|
+
}
|
|
80
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
81
|
+
return { ok: false, status: 400, reason: "body is not an object" };
|
|
82
|
+
}
|
|
83
|
+
const { url, size, key } = body as Record<string, unknown>;
|
|
84
|
+
if (typeof url !== "string") return { ok: false, status: 400, reason: "url must be a string" };
|
|
85
|
+
let parsed: URL;
|
|
86
|
+
try {
|
|
87
|
+
parsed = new URL(url);
|
|
88
|
+
} catch {
|
|
89
|
+
return { ok: false, status: 400, reason: "url is not a URL" };
|
|
90
|
+
}
|
|
91
|
+
if (parsed.protocol !== "https:" || !slackFileHost(parsed.hostname)) {
|
|
92
|
+
return { ok: false, status: 400, reason: `url host ${parsed.hostname} is not a Slack file host` };
|
|
93
|
+
}
|
|
94
|
+
if (typeof size !== "number" || !Number.isInteger(size) || size < 1) {
|
|
95
|
+
return { ok: false, status: 400, reason: "size must be a positive integer" };
|
|
96
|
+
}
|
|
97
|
+
if (size > maxBytes) return { ok: false, status: 413, reason: `size ${size} is over the ${maxBytes}-byte ceiling` };
|
|
98
|
+
if (typeof key !== "string" || !validCopyKey(key)) {
|
|
99
|
+
return { ok: false, status: 400, reason: "key is not an inbound artifact key" };
|
|
100
|
+
}
|
|
101
|
+
return { ok: true, value: { url: parsed, size, key } };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const json = (status: number, body: Record<string, unknown>) =>
|
|
105
|
+
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
106
|
+
|
|
107
|
+
/** The route. WHO: the copy bearer, checked in constant time before anything
|
|
108
|
+
* else (401, and never the bucket's existence). WHAT: a Slack `url_private`,
|
|
109
|
+
* a positive size within the ceiling and an inbound key (400/413, before any
|
|
110
|
+
* fetch). THEN the upstream: Slack answers the login page (`text/html`) when
|
|
111
|
+
* the token does not reach the file — refused as unauthorized, nothing put;
|
|
112
|
+
* a status other than 200 is Slack's word (502); a `Content-Length` absent or
|
|
113
|
+
* other than `size` is a 409 before any `put`. The body flows through the
|
|
114
|
+
* length pipe into one `put` with Slack's content type; a stream that ends
|
|
115
|
+
* short fails the pipe, the put rejects and the bucket keeps nothing (502). */
|
|
116
|
+
export async function handleArtifactsCopy(request: Request, deps: CopyDeps): Promise<Response> {
|
|
117
|
+
const bearer = /^Bearer\s+(\S+)$/.exec(request.headers.get("authorization") ?? "")?.[1];
|
|
118
|
+
if (!deps.copyToken || !bearer || !sameToken(bearer, deps.copyToken)) {
|
|
119
|
+
return json(401, { ok: false, error: "artifacts copy: bearer refused" });
|
|
120
|
+
}
|
|
121
|
+
if (request.method === "GET") {
|
|
122
|
+
return deps.bucket && deps.bucketName
|
|
123
|
+
? json(200, { ok: true, bucket: deps.bucketName })
|
|
124
|
+
: json(503, { ok: false, error: "artifacts copy: this Worker has no ARTIFACTS bucket binding" });
|
|
125
|
+
}
|
|
126
|
+
if (request.method !== "POST") return json(405, { ok: false, error: `method not allowed: POST ${COPY_PATH}` });
|
|
127
|
+
if (!deps.bucket)
|
|
128
|
+
return json(503, { ok: false, error: "artifacts copy: this Worker has no ARTIFACTS bucket binding" });
|
|
129
|
+
if (!deps.slackToken)
|
|
130
|
+
return json(503, { ok: false, error: "artifacts copy: SLACK_BOT_TOKEN is not set on this Worker" });
|
|
131
|
+
const parsed = parseCopyRequest(await request.text().catch(() => ""), deps.maxBytes ?? MAX_COPY_BYTES);
|
|
132
|
+
if (!parsed.ok) return json(parsed.status, { ok: false, error: `artifacts copy: ${parsed.reason}` });
|
|
133
|
+
const { url, size, key } = parsed.value;
|
|
134
|
+
|
|
135
|
+
let upstream: Response;
|
|
136
|
+
try {
|
|
137
|
+
upstream = await deps.fetch(url.toString(), {
|
|
138
|
+
headers: { authorization: `Bearer ${deps.slackToken}` },
|
|
139
|
+
redirect: "manual",
|
|
140
|
+
});
|
|
141
|
+
} catch (err) {
|
|
142
|
+
return json(502, { ok: false, error: `artifacts copy: slack did not answer: ${describe(err)}` });
|
|
143
|
+
}
|
|
144
|
+
const contentType = upstream.headers.get("content-type") ?? "application/octet-stream";
|
|
145
|
+
if (upstream.status === 200 && /^text\/html\b/i.test(contentType)) {
|
|
146
|
+
await upstream.body?.cancel().catch(() => {});
|
|
147
|
+
return json(502, {
|
|
148
|
+
ok: false,
|
|
149
|
+
error: "artifacts copy: slack answered its login page — the bot token is not authorized for this file",
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if (upstream.status !== 200 || !upstream.body) {
|
|
153
|
+
await upstream.body?.cancel().catch(() => {});
|
|
154
|
+
return json(502, { ok: false, error: `artifacts copy: slack answered HTTP ${upstream.status}` });
|
|
155
|
+
}
|
|
156
|
+
const declared = Number(upstream.headers.get("content-length"));
|
|
157
|
+
if (!upstream.headers.has("content-length") || !Number.isInteger(declared)) {
|
|
158
|
+
await upstream.body.cancel().catch(() => {});
|
|
159
|
+
return json(409, { ok: false, error: "artifacts copy: slack stated no length for the file" });
|
|
160
|
+
}
|
|
161
|
+
if (declared !== size) {
|
|
162
|
+
await upstream.body.cancel().catch(() => {});
|
|
163
|
+
return json(409, { ok: false, error: `artifacts copy: slack states ${declared} bytes, not the ${size} declared` });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// The bytes: upstream → the fixed-length pipe → one put. Either side failing
|
|
167
|
+
// rejects the whole copy; R2 keeps nothing for a stream that did not complete.
|
|
168
|
+
const { readable, writable } = deps.lengthPipe(size);
|
|
169
|
+
try {
|
|
170
|
+
await Promise.all([
|
|
171
|
+
upstream.body.pipeTo(writable),
|
|
172
|
+
deps.bucket.put(key, readable, { httpMetadata: { contentType } }),
|
|
173
|
+
]);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
return json(502, { ok: false, error: `artifacts copy: the copy of ${key} failed mid-stream: ${describe(err)}` });
|
|
176
|
+
}
|
|
177
|
+
return json(200, { ok: true, key, size });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const describe = (err: unknown): string => (err instanceof Error ? err.message : String(err));
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Create the R2 buckets this Worker's wrangler.jsonc binds, before `wrangler
|
|
3
|
+
// deploy` validates the bindings (docs/reference/specs/execution.md item 20,
|
|
4
|
+
// record 0033). wrangler refuses a deploy whose `r2_buckets` names a bucket
|
|
5
|
+
// that does not exist, and creating one by hand is the step an installation
|
|
6
|
+
// forgets; `npm run deploy` runs this after the preflight. Idempotent: a bucket
|
|
7
|
+
// that already exists is success (wrangler's own words for it are matched),
|
|
8
|
+
// any other refusal is wrangler's, verbatim, and the deploy stops.
|
|
9
|
+
//
|
|
10
|
+
// Dependency-free Node. `bucketNamesOf()` and `decide()` are pure and
|
|
11
|
+
// unit-tested (ensure-bucket.test.mjs); `main()` only does I/O around them.
|
|
12
|
+
import { execFile } from "node:child_process";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
16
|
+
|
|
17
|
+
/** Every `bucket_name` the rendered config binds — the template renders the
|
|
18
|
+
* block only when the deployment profile names a bucket, so an installation
|
|
19
|
+
* without artifacts has none here and this script does nothing. */
|
|
20
|
+
export function bucketNamesOf(wranglerJsonc) {
|
|
21
|
+
const names = [];
|
|
22
|
+
for (const m of wranglerJsonc.matchAll(/"bucket_name"\s*:\s*"([^"]+)"/g)) names.push(m[1]);
|
|
23
|
+
return [...new Set(names)];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Pure: what `wrangler r2 bucket create <name>` meant. Exit 0 → created; a
|
|
27
|
+
* non-zero exit whose output says the bucket exists → already there (success);
|
|
28
|
+
* anything else → wrangler's refusal, to print and stop on. */
|
|
29
|
+
export function decide(name, code, output) {
|
|
30
|
+
if (code === 0) return { ok: true, kind: "created", name };
|
|
31
|
+
if (/already exists|10004/i.test(output)) return { ok: true, kind: "exists", name };
|
|
32
|
+
return { ok: false, name, reason: output.trim() || `wrangler exited ${code}` };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function wrangler(args, cwd) {
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
execFile("npx", ["wrangler", ...args], { cwd, env: process.env, maxBuffer: 4 << 20 }, (err, stdout, stderr) => {
|
|
38
|
+
const code = err && typeof err.code === "number" ? err.code : err ? 1 : 0;
|
|
39
|
+
resolve({ code, output: `${stdout ?? ""}\n${stderr ?? ""}` });
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function main(dir = dirname(fileURLToPath(import.meta.url))) {
|
|
45
|
+
let rendered;
|
|
46
|
+
try {
|
|
47
|
+
rendered = readFileSync(join(dir, "wrangler.jsonc"), "utf8");
|
|
48
|
+
} catch {
|
|
49
|
+
console.error("[ensure-bucket] wrangler.jsonc is not rendered — run `npm run deploy:gen` first");
|
|
50
|
+
return 2;
|
|
51
|
+
}
|
|
52
|
+
const names = bucketNamesOf(rendered);
|
|
53
|
+
if (names.length === 0) {
|
|
54
|
+
console.log("[ensure-bucket] no R2 buckets bound — nothing to create");
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
for (const name of names) {
|
|
58
|
+
const r = await wrangler(["r2", "bucket", "create", name], dir);
|
|
59
|
+
const d = decide(name, r.code, r.output);
|
|
60
|
+
if (!d.ok) {
|
|
61
|
+
console.error(`[ensure-bucket] could not create bucket ${name}:\n${d.reason}`);
|
|
62
|
+
return 1;
|
|
63
|
+
}
|
|
64
|
+
console.log(`[ensure-bucket] bucket ${name} ${d.kind === "created" ? "created" : "already exists"}`);
|
|
65
|
+
}
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
70
|
+
process.exitCode = await main();
|
|
71
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
},
|
|
8
8
|
"scripts": {
|
|
9
9
|
"check:image": "docker build ../..",
|
|
10
|
-
"deploy": "node preflight.mjs && node write-build.mjs && wrangler deploy",
|
|
10
|
+
"deploy": "node preflight.mjs && node ensure-bucket.mjs && node write-build.mjs && wrangler deploy",
|
|
11
11
|
"preflight": "node preflight.mjs",
|
|
12
12
|
"test": "vitest run",
|
|
13
13
|
"verify": "npm --prefix ../.. run --silent deploy:gen && npm run typecheck && npm test",
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
import { systemClock } from "../../src/core/trace/clock.ts";
|
|
47
47
|
import { createTracer } from "../../src/core/trace/tracer.ts";
|
|
48
48
|
import { shimRoute, stripTraceContext, withTraceContext, workerLogSink } from "../../src/core/trace/workerTrace.ts";
|
|
49
|
+
import { COPY_PATH, handleArtifactsCopy } from "./artifactsCopy.ts";
|
|
49
50
|
import type { ShipCoordinatorParams } from "./coordinator";
|
|
50
51
|
import { INSTANCE, INTERNAL } from "./shared";
|
|
51
52
|
|
|
@@ -93,6 +94,12 @@ export interface Env {
|
|
|
93
94
|
ARTIFACTS_R2_ACCESS_KEY_ID?: string; // artifact store: the bucket-scoped S3 token the bot signs presigned URLs with
|
|
94
95
|
ARTIFACTS_R2_SECRET_ACCESS_KEY?: string; // artifact store: the secret half of that token
|
|
95
96
|
ARTIFACTS_COPY_TOKEN?: string; // artifact store: the bearer the bot presents to this Worker's /artifacts/copy route
|
|
97
|
+
/** The artifacts bucket (artifactsCopy.ts), bound only when the deployment profile names one
|
|
98
|
+
* (`artifacts.bucket` → the template's `r2_buckets` block); `ARTIFACTS_BUCKET_NAME` is the same
|
|
99
|
+
* name as a var, what `GET /artifacts/copy` reports so `artifacts check` can hold it against the
|
|
100
|
+
* bot's config. Worker-side only: neither reaches the container. */
|
|
101
|
+
ARTIFACTS?: R2Bucket;
|
|
102
|
+
ARTIFACTS_BUCKET_NAME?: string;
|
|
96
103
|
}
|
|
97
104
|
|
|
98
105
|
/** Every secret/var the Worker forwards into the container. Optional entries
|
|
@@ -429,7 +436,18 @@ export default {
|
|
|
429
436
|
? await handleCoordinatorInstances(forwarded, env)
|
|
430
437
|
: statusId !== undefined
|
|
431
438
|
? await handleCoordinatorInstanceStatus(forwarded, env, statusId)
|
|
432
|
-
:
|
|
439
|
+
: pathname === COPY_PATH
|
|
440
|
+
? // The artifact copy (artifactsCopy.ts): the R2 binding and the Slack
|
|
441
|
+
// token are this Worker's; the bot only asks, with its copy bearer.
|
|
442
|
+
await handleArtifactsCopy(inbound, {
|
|
443
|
+
bucket: env.ARTIFACTS,
|
|
444
|
+
bucketName: env.ARTIFACTS_BUCKET_NAME,
|
|
445
|
+
copyToken: env.ARTIFACTS_COPY_TOKEN,
|
|
446
|
+
slackToken: env.SLACK_BOT_TOKEN,
|
|
447
|
+
fetch: (input, init) => fetch(input, init),
|
|
448
|
+
lengthPipe: (size) => new FixedLengthStream(size),
|
|
449
|
+
})
|
|
450
|
+
: await getContainer(env.SWITCHBOARD, INSTANCE).fetch(forwarded);
|
|
433
451
|
root.end(res.status >= 500 ? "error" : "ok", { httpStatus: res.status });
|
|
434
452
|
return res;
|
|
435
453
|
} catch (err) {
|
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
// The state Worker (deploy/cloudflare-memory/): where worker.ts records each
|
|
35
35
|
// scheduled firing for the /runs Scheduled panel, with MEMORY_TOKEN.
|
|
36
36
|
// A profile without a memory Worker renders no var: firings go unrecorded.
|
|
37
|
+
// The artifacts bucket's name, beside its binding below (rendered together).
|
|
38
|
+
// {{#if artifacts}}
|
|
39
|
+
"ARTIFACTS_BUCKET_NAME": "{{artifacts.bucket}}",
|
|
40
|
+
// {{/if}}
|
|
37
41
|
// {{#if urls.stateWorkerUrl}}
|
|
38
42
|
"STATE_WORKER_URL": "{{urls.stateWorkerUrl}}"
|
|
39
43
|
// {{/if}}
|
|
@@ -54,6 +58,17 @@
|
|
|
54
58
|
"durable_objects": {
|
|
55
59
|
"bindings": [{ "name": "SWITCHBOARD", "class_name": "SwitchboardServer" }]
|
|
56
60
|
},
|
|
61
|
+
// The artifacts bucket (docs/reference/specs/execution.md item 20, record
|
|
62
|
+
// 0033): where a run's files live by reference. Rendered only when the
|
|
63
|
+
// deployment profile names a bucket (`artifacts.bucket`) — the bot's
|
|
64
|
+
// config `artifacts.r2.bucket` must say the same name, and `artifacts
|
|
65
|
+
// check` holds the two against each other through `GET /artifacts/copy`.
|
|
66
|
+
// `npm run deploy` creates the bucket first (ensure-bucket.mjs); wrangler
|
|
67
|
+
// validates the binding on deploy. `ARTIFACTS_BUCKET_NAME` above is the
|
|
68
|
+
// same name as a var, so the Worker can say which bucket it binds.
|
|
69
|
+
// {{#if artifacts}}
|
|
70
|
+
"r2_buckets": [{ "binding": "ARTIFACTS", "bucket_name": "{{artifacts.bucket}}" }],
|
|
71
|
+
// {{/if}}
|
|
57
72
|
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["SwitchboardServer"] }],
|
|
58
73
|
// The ship coordinator as a Workflow (coordinator.ts, re-exported by
|
|
59
74
|
// worker.ts; docs/reference/specs/http-ingress.md item 9): `POST
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$comment": "The deployment profile: where THIS installation runs. Copy to deploy/profile.json and fill it in (or run `switchboard deploy init`). The account is your Cloudflare account id; every hostname must be under `zone`, a zone in that account, unless the Worker names its own `zone` (also in the account); `workers.bot` is the one required Worker — leave `memory`, `resident` or `sandbox` out and `deploy plan` has no step for them (a bot-only profile is a one-step plan; without `memory` the config is not pushed anywhere). The project's docs site is not a Worker of an installation: it is the project's website, deployed by the project's own CI from project.json, so there is no `docs` entry. `configSource` is where `deploy all` reads the bot's runtime config from before building the image — a path, `github://owner/repo/path@ref` (needs CONFIG_REPO_TOKEN), or `op://Vault/Item/field` (needs OP_SERVICE_ACCOUNT_TOKEN); `secretsSource` is where `secrets put` reads values from — a directory of <NAME> files (the default when absent) or `op://Vault/Item`. `images` is where the bot, resident and sandbox container images come from: `registry` — the release's published images, copied once per version into your account registry by `deploy all` itself (or `deploy images` ahead of it) over HTTPS and referenced from there — no Docker anywhere, a Cloudflare API token with Containers Edit in CLOUDFLARE_API_TOKEN for the copy (an installation deploying published images; what `init` writes from the published package); or `build` (the default when absent) — each Worker's Dockerfile, built by wrangler where `deploy all` runs (a checkout; the project's own production). `deploy plan` reads this example when no profile exists; `deploy all` refuses it.",
|
|
2
|
+
"$comment": "The deployment profile: where THIS installation runs. Copy to deploy/profile.json and fill it in (or run `switchboard deploy init`). The account is your Cloudflare account id; every hostname must be under `zone`, a zone in that account, unless the Worker names its own `zone` (also in the account); `workers.bot` is the one required Worker — leave `memory`, `resident` or `sandbox` out and `deploy plan` has no step for them (a bot-only profile is a one-step plan; without `memory` the config is not pushed anywhere). The project's docs site is not a Worker of an installation: it is the project's website, deployed by the project's own CI from project.json, so there is no `docs` entry. `configSource` is where `deploy all` reads the bot's runtime config from before building the image — a path, `github://owner/repo/path@ref` (needs CONFIG_REPO_TOKEN), or `op://Vault/Item/field` (needs OP_SERVICE_ACCOUNT_TOKEN); `secretsSource` is where `secrets put` reads values from — a directory of <NAME> files (the default when absent) or `op://Vault/Item`. `images` is where the bot, resident and sandbox container images come from: `registry` — the release's published images, copied once per version into your account registry by `deploy all` itself (or `deploy images` ahead of it) over HTTPS and referenced from there — no Docker anywhere, a Cloudflare API token with Containers Edit in CLOUDFLARE_API_TOKEN for the copy (an installation deploying published images; what `init` writes from the published package); or `build` (the default when absent) — each Worker's Dockerfile, built by wrangler where `deploy all` runs (a checkout; the project's own production). `artifacts` (optional) names the R2 bucket a run's files move through (docs/reference/specs/execution.md item 20): with it the bot Worker binds the bucket and `deploy` creates it before the upload (the credential then needs Workers R2 Storage: Edit); the bot's runtime config `artifacts.r2.bucket` must say the same name. `deploy plan` reads this example when no profile exists; `deploy all` refuses it.",
|
|
3
3
|
"account": "00000000000000000000000000000000",
|
|
4
4
|
"zone": "example.com",
|
|
5
5
|
"workers": {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.214.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "switchboard",
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.214.0",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"workspaces": [
|
|
12
12
|
"web",
|
|
@@ -19000,7 +19000,7 @@
|
|
|
19000
19000
|
},
|
|
19001
19001
|
"packages/switchboard": {
|
|
19002
19002
|
"name": "@coreplane/switchboard",
|
|
19003
|
-
"version": "1.
|
|
19003
|
+
"version": "1.214.0",
|
|
19004
19004
|
"license": "Apache-2.0",
|
|
19005
19005
|
"dependencies": {
|
|
19006
19006
|
"@anthropic-ai/sdk": "^0.124.0",
|
package/dist/assets/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.214.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",
|
package/dist/assets/source.json
CHANGED
|
@@ -198,6 +198,8 @@ const RESIDENT_TOOLCHAIN = `The resident image carries ${IMAGE_TOOLCHAIN}; plus
|
|
|
198
198
|
// the sandbox and resident variants cannot drift on it.
|
|
199
199
|
const SHOW_FILES = `Files the person should SEE go through the attach_file tool: a screenshot from \`playwright screenshot\`, a rendered PDF, a recording — it posts the workspace file into this conversation, where an image renders inline. Use it whenever you produce an image worth showing (a visual change, a rendered page, a before/after); a link to a file on GitHub is not a picture.
|
|
200
200
|
SCREENSHOTS GO TO BOTH PLACES, ALL OF THEM: when the request asks for screenshots, or the change is visual, every capture is attached here with attach_file AND published on the pull request — commit the images to an assets branch (never the PR's own diff) and reference them from the description's validation section or a PR comment so they render inline there too — unless the request names one destination. Never attach a subset and link the rest.
|
|
201
|
+
Whole files: up to 1 GiB where the artifact store is configured (a recording, a large PDF), 10 MiB otherwise — the tool's result says which applies; over the limit, link to the file.
|
|
202
|
+
Files the person dropped on the thread that were too large to show you inline are already in ./attachments/ in your workspace when the turn's text names them (a video for ffmpeg, a large PDF, a zip); read them from there — never ask for a re-upload.
|
|
201
203
|
Text stays in your message; do not attach what you can say.`;
|
|
202
204
|
|
|
203
205
|
const CODING_SYSTEM = `You are Switchboard's coding agent, operating from a Slack request.
|
|
@@ -426,28 +428,43 @@ Report outcomes faithfully: a check you could not run is "could not check", neve
|
|
|
426
428
|
// preset. A child is a `dispatch()` run as the requesting user, in a thread of
|
|
427
429
|
// its own, under their permissions (docs/decisions/0002-dispatcher-is-the-only-orchestrator.md,
|
|
428
430
|
// docs/decisions/0007-authorization-policy-table.md): the prompt says exactly
|
|
429
|
-
// that, so the model never expects a child to
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
|
|
431
|
+
// that, so the model never expects a child to hold more than its requester
|
|
432
|
+
// does. A child is a reader of this conversation: it starts from the text so
|
|
433
|
+
// far plus the prompt, and the prompt says so. Machine `none`, identity
|
|
434
|
+
// `none`: it holds no workspace, no shell and no credential of its own; its
|
|
435
|
+
// reach is the run tools, the GitHub reads and URL reading. The prompt names
|
|
436
|
+
// the limits the spawn stage enforces — one level of depth, the fan-out cap,
|
|
437
|
+
// the parent's remaining clock — so a refusal is never a surprise, and renders
|
|
438
|
+
// the presets a child can run from the registry the way `help` renders its
|
|
439
|
+
// rows: every sibling whose identity is not `write`, with its own description,
|
|
440
|
+
// so the model picks from the real list and a preset that crosses the identity
|
|
441
|
+
// line moves the day its def does; the write presets are named as what a child
|
|
442
|
+
// never is, with the refusal's name.
|
|
443
|
+
function conductorSystem(siblings: readonly AgentDef[]): string {
|
|
444
|
+
const readers = siblings.filter((a) => a.identity !== "write");
|
|
445
|
+
const writers = siblings.filter((a) => a.identity === "write");
|
|
446
|
+
const rows = readers.map(
|
|
447
|
+
(a) => `- \`${a.name}\`${machineNeedsRepo(a.machine) ? " (needs the repository)" : ""}: ${a.description}`,
|
|
448
|
+
);
|
|
449
|
+
return `You are Switchboard's conductor: you coordinate other runs instead of doing the work yourself, answering a request from Slack.
|
|
437
450
|
|
|
438
451
|
You have no workspace and no shell. Your tools: \`spawn_run\` (start a child run), \`send_to_run\` (steer a live child: your text reaches it as a follow-up at its next step), \`await_runs\` (wait for your children to end and get each end — its status and final reply — back as data), \`list_runs\` (the runs you may see — your own children by default), \`get_run_status\` (one run: whether it is running, what it is doing, and its final reply once it finished), the GitHub reads — \`github_repos\`, \`github_tree\` / \`github_file\` (browse and read our repositories), \`github_search_code\`, \`github_issue_list\` / \`github_issue_get\` — \`web_fetch\` (read a public URL), and \`update_status\`.
|
|
439
452
|
|
|
440
453
|
WHAT A CHILD IS. A child is an ordinary Switchboard run started as the person who asked you — exactly the run they could start by hand with \`agent:<preset>\` — in a thread of its own in this channel, visible to everyone there, with its own status card and run page, and under their permissions: a preset they may not run, a repository they may not use, or a profile a boundary caps is refused in the child's thread, and the refusal comes back to you as the tool result naming the gate. Children cannot spawn children. You may have a few live at once (the deployment's \`spawn.maxChildren\`, three by default); a spawn past the cap is refused until one finishes. A child's wall clock is capped by what is left of yours.
|
|
441
454
|
|
|
442
|
-
THE PRESETS a child can run
|
|
455
|
+
THE PRESETS a child can run — a child reads, so only a preset whose identity is \`none\` or \`read\`:
|
|
456
|
+
${rows.join("\n")}
|
|
457
|
+
|
|
458
|
+
A preset that writes — ${writers.map((a) => `\`${a.name}\``).join(", ")} — is refused by name (\`spawn_identity\`): a spawned child never holds a write credential, so pushing a branch or opening a pull request is the requester's to start by hand with \`agent:<preset>\`; say so in your answer instead of spawning it.
|
|
443
459
|
|
|
444
460
|
ROUTED COMPOUNDS. A request may arrive already split: the router found independent parts, and the message ends with the line "Routed as a compound request: N independent parts" followed by a numbered list, one part per line as \`<preset>\`: <text>. Spawn exactly those children — one \`spawn_run\` per line, the preset as listed, the line's text as the child's prompt (it already stands alone; add the repository where the preset needs one) — then \`await_runs\` them all and compile. Never merge, drop or add a part; a part whose spawn is refused is reported as refused, by the gate's name.
|
|
445
461
|
|
|
446
|
-
HOW TO WORK. Fan out, await, compile. Read the request and split it into children only where the parts are independent; a request one preset answers is one child. Spawn each child with a
|
|
462
|
+
HOW TO WORK. Fan out, await, compile. Read the request and split it into children only where the parts are independent; a request one preset answers is one child. Spawn each child with a prompt that says what it should do, and the repository where the preset needs one: a child starts from this conversation's text so far — every user and assistant turn before your call, never your tool calls, their results or your thinking — and your prompt is its one new turn, so tell it what to do rather than repeat what was said. Then call \`await_runs\` once with every child's id: it returns when all of them have ended, or earlier — at the edge of your own budget, at a stop, or when a follow-up lands in this thread — and \`ended\` says which; a child still running at the cut keeps running (name it in your answer, or await again after a follow-up). Steer a child with \`send_to_run\` when the request changes or a child is heading the wrong way. A child that ended — finished, failed, interrupted by a restart — is reported as it ended and never restarted; spawn a new child if the work still matters. Then compile: one answer from the write-ups \`await_runs\` returned. Never do a child's job yourself, and never claim a child finished or found something you did not read from \`await_runs\` or \`get_run_status\`.
|
|
447
463
|
|
|
448
464
|
Maintain the user-facing status card with the update_status tool: one item per child (○ pending, ✱ running, ✓ finished — only once await_runs or get_run_status said so).
|
|
449
465
|
|
|
450
466
|
Use Slack-friendly formatting (no markdown headers; *bold*, bullets, code blocks). Your final message is posted to Slack: lead with the outcome, then one line per child — its preset, its thread, its status and its result in a sentence — and what is still running, if anything.`;
|
|
467
|
+
}
|
|
451
468
|
|
|
452
469
|
/** The compound answer's preset — the one preset absent from the router's
|
|
453
470
|
* table that a plain message still reaches: a message with two or more
|
|
@@ -469,11 +486,15 @@ export function presetDoor(def: AgentDef): PresetDoor {
|
|
|
469
486
|
return def.name === COMPOUND_PRESET ? "compound" : "directive";
|
|
470
487
|
}
|
|
471
488
|
|
|
472
|
-
|
|
489
|
+
/** Every preset that does the work: the ones a conductor's children are drawn
|
|
490
|
+
* from (those that read) and the ones it names as refused (those that write).
|
|
491
|
+
* The conductor is built from this list below, so its prompt renders its
|
|
492
|
+
* siblings and never itself. */
|
|
493
|
+
const WORK_PRESETS = {
|
|
473
494
|
general: {
|
|
474
495
|
name: "general",
|
|
475
496
|
description:
|
|
476
|
-
"Default assistant
|
|
497
|
+
"Default assistant: answers directly, reads URLs, manages issues; the preset for any question the org's GitHub answers. No workspace or shell.",
|
|
477
498
|
system: GENERAL_SYSTEM,
|
|
478
499
|
toolset: "assistant",
|
|
479
500
|
// The GitHub tools are REST in the bot process, so a general ask never
|
|
@@ -547,7 +568,7 @@ export const AGENTS: Record<string, AgentDef> = {
|
|
|
547
568
|
research: {
|
|
548
569
|
name: "research",
|
|
549
570
|
description:
|
|
550
|
-
"Answers questions
|
|
571
|
+
"Answers questions that need the web (search, URL reading); GitHub for context, not for a question GitHub alone answers. No workspace.",
|
|
551
572
|
system: RESEARCH_SYSTEM,
|
|
552
573
|
toolset: "web",
|
|
553
574
|
machine: "none", // web I/O only; no workspace is provisioned
|
|
@@ -573,11 +594,15 @@ export const AGENTS: Record<string, AgentDef> = {
|
|
|
573
594
|
cacheTtl: "1h",
|
|
574
595
|
// No built-in effort: the deployment decides, as for coding.
|
|
575
596
|
},
|
|
597
|
+
} satisfies Record<string, AgentDef>;
|
|
598
|
+
|
|
599
|
+
export const AGENTS: Record<string, AgentDef> = {
|
|
600
|
+
...WORK_PRESETS,
|
|
576
601
|
conductor: {
|
|
577
602
|
name: "conductor",
|
|
578
603
|
description:
|
|
579
604
|
"Coordinates other runs: spawns child runs as the requester — each in a thread of its own, under their permissions — follows them, and reports. No workspace or shell.",
|
|
580
|
-
system:
|
|
605
|
+
system: conductorSystem(Object.values(WORK_PRESETS)),
|
|
581
606
|
toolset: "conductor",
|
|
582
607
|
// Nothing is provisioned and no credential minted: the run tools call the
|
|
583
608
|
// dispatcher, the GitHub reads are REST in the bot process.
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// the machine class its tools execute on, the identity it acts as, and the
|
|
3
3
|
// minutes it may run. A preset declares one; the run's EFFECTIVE profile is
|
|
4
4
|
// what the pipeline hands the factory, the ledger and the runner — never the
|
|
5
|
-
// preset's fields read again downstream. Pure and leaf:
|
|
6
|
-
//
|
|
7
|
-
import type
|
|
5
|
+
// preset's fields read again downstream. Pure and near-leaf: the only value
|
|
6
|
+
// import is the registry's `runawayTurnCap`, itself pure.
|
|
7
|
+
import { runawayTurnCap, type AgentDef, type Identity, type MachineClass } from "../agents/registry.js";
|
|
8
8
|
|
|
9
9
|
export type { Identity, MachineClass };
|
|
10
10
|
|
|
@@ -63,9 +63,12 @@ export function declaredProfile(preset: Pick<AgentDef, "machine" | "identity" |
|
|
|
63
63
|
/** The preset with its budget replaced by the effective profile's — the def
|
|
64
64
|
* the runner is handed, since its deadline, wrap-up warning and budget label
|
|
65
65
|
* read `maxMinutes` (the same clipped copy the ship pipeline hands its child
|
|
66
|
-
* rounds).
|
|
66
|
+
* rounds). `maxTurns` is re-derived from the clipped minutes with the
|
|
67
|
+
* registry's own `runawayTurnCap`, so the six-per-minute runaway guard holds
|
|
68
|
+
* for the budget the run actually has, not the preset's declared one.
|
|
69
|
+
* Always a copy: the shared `AgentDef` is never mutated. */
|
|
67
70
|
export function budgetedAgent(agent: AgentDef, profile: RunProfile): AgentDef {
|
|
68
|
-
return { ...agent, maxMinutes: profile.minutes };
|
|
71
|
+
return { ...agent, maxMinutes: profile.minutes, maxTurns: runawayTurnCap(profile.minutes) };
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
// ---- boundaries: a scope caps, never grants -----------------------------------
|
|
@@ -134,7 +134,11 @@ export type RunNoteKind =
|
|
|
134
134
|
/** The harness's gate refused a tool call the model asked for (harness-pi.md
|
|
135
135
|
* item 7): the summary names the tool and the rule; the model read the same
|
|
136
136
|
* reason as the tool's result. Published by the bot's authorize route. */
|
|
137
|
-
| "tool_refused"
|
|
137
|
+
| "tool_refused"
|
|
138
|
+
/** The stuck-loop guard fired (docs/reference/specs/run-loop.md item 18):
|
|
139
|
+
* the same tool call failed identically six times in a row, so the run is
|
|
140
|
+
* forced into its write-up instead of looping to the wall clock. */
|
|
141
|
+
| "stuck_loop";
|
|
138
142
|
|
|
139
143
|
/** Every `RunNoteKind`, as a value (a reader that filters notes by kind uses
|
|
140
144
|
* this; adding a kind to the union without adding it here is a type error). */
|
|
@@ -158,6 +162,7 @@ export const RUN_NOTE_KINDS = [
|
|
|
158
162
|
"compacted",
|
|
159
163
|
"harness_error",
|
|
160
164
|
"tool_refused",
|
|
165
|
+
"stuck_loop",
|
|
161
166
|
] as const satisfies readonly RunNoteKind[];
|
|
162
167
|
type _EveryKindListed = [RunNoteKind] extends [(typeof RUN_NOTE_KINDS)[number]] ? true : never;
|
|
163
168
|
const _everyKindListed: _EveryKindListed = true;
|
|
@@ -254,6 +254,13 @@ function unionMs(intervals: ReadonlyArray<{ start: number; end: number }>): numb
|
|
|
254
254
|
return total;
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
+
/** One tool call's identity for retry/streak accounting: the tool name plus
|
|
258
|
+
* the call's one-line summary (which carries the arguments — a bash command,
|
|
259
|
+
* a path, a url). Shared with the runner's stuck-loop guard
|
|
260
|
+
* (docs/reference/specs/run-loop.md item 18), so both count "the same call"
|
|
261
|
+
* identically. */
|
|
262
|
+
export const callSignature = (tool: string, summary: string): string => `${tool} ${summary}`;
|
|
263
|
+
|
|
257
264
|
/** Analyze a run's event stream. Pure and deterministic; never mutates `events`. */
|
|
258
265
|
export function analyzeRunFriction(events: readonly RunEvent[], opts: FrictionOptions = {}): FrictionDiagnosis {
|
|
259
266
|
const slowToolMs = opts.slowToolMs ?? DEFAULT_SLOW_TOOL_MS;
|
|
@@ -405,7 +412,7 @@ export function analyzeRunFriction(events: readonly RunEvent[], opts: FrictionOp
|
|
|
405
412
|
|
|
406
413
|
if (ev.type === "tool_call") {
|
|
407
414
|
toolCalls++;
|
|
408
|
-
if (failedCalls.has(
|
|
415
|
+
if (failedCalls.has(callSignature(ev.tool, ev.summary))) {
|
|
409
416
|
findings.push({
|
|
410
417
|
category: "retry",
|
|
411
418
|
severity: "low",
|
|
@@ -432,7 +439,7 @@ export function analyzeRunFriction(events: readonly RunEvent[], opts: FrictionOp
|
|
|
432
439
|
? { interval: { start: span.startedAt, end: span.startedAt + durationMs } }
|
|
433
440
|
: {};
|
|
434
441
|
|
|
435
|
-
if (!ev.ok) failedCalls.add(
|
|
442
|
+
if (!ev.ok) failedCalls.add(callSignature(ev.tool, callSummary));
|
|
436
443
|
|
|
437
444
|
if (ev.infra) {
|
|
438
445
|
// An infra-level failure is the sandbox, not the command: classify once,
|
|
@@ -7,6 +7,7 @@ import type { ChatMessage, ToolDef } from "../../providers/types.js";
|
|
|
7
7
|
import type { ChannelVisibility } from "../authz/types.js";
|
|
8
8
|
import type { RunProfile } from "../../config/profile.js";
|
|
9
9
|
import type { RunEvent } from "../runEvents.js";
|
|
10
|
+
import type { RunSeed } from "../runRecord.js";
|
|
10
11
|
|
|
11
12
|
/** How long a generation's claim on a run lasts without a heartbeat. */
|
|
12
13
|
export const LEASE_MS = 30_000;
|
|
@@ -69,6 +70,9 @@ export interface LiveRunMeta {
|
|
|
69
70
|
* still sends the parent its event and a retried spawn finds its run. */
|
|
70
71
|
parentInstanceId?: string;
|
|
71
72
|
idempotencyKey?: string;
|
|
73
|
+
/** Where the run's conversation started (item 52), so a reclaimed run's
|
|
74
|
+
* record still says so: `parent` for a spawned child, `channel` otherwise. */
|
|
75
|
+
seed?: RunSeed;
|
|
72
76
|
/** Which executor the run attached: what `makeExecutor` chose. */
|
|
73
77
|
selection?: "resident" | "sandbox" | "local" | "none";
|
|
74
78
|
/** The worktree path the system prompt names. */
|