@coreplane/switchboard 1.213.0 → 1.215.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/assets/config/config.example.yaml +7 -1
  2. package/dist/assets/deploy/cloudflare/artifactsCopy.ts +180 -0
  3. package/dist/assets/deploy/cloudflare/ensure-bucket.mjs +71 -0
  4. package/dist/assets/deploy/cloudflare/package.json +1 -1
  5. package/dist/assets/deploy/cloudflare/worker.ts +19 -1
  6. package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +15 -0
  7. package/dist/assets/deploy/cloudflare-memory/worker.ts +568 -6
  8. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +11 -2
  9. package/dist/assets/deploy/profile.example.json +1 -1
  10. package/dist/assets/package-lock.json +3 -3
  11. package/dist/assets/package.json +1 -1
  12. package/dist/assets/source.json +3 -3
  13. package/dist/assets/src/agents/registry.ts +39 -14
  14. package/dist/assets/src/config/profile.ts +8 -5
  15. package/dist/assets/src/core/coordinator/contract.ts +6 -0
  16. package/dist/assets/src/core/runEvents.ts +18 -9
  17. package/dist/assets/src/core/runFriction.ts +9 -2
  18. package/dist/assets/src/core/runLedger/sessionLog.ts +207 -0
  19. package/dist/assets/src/core/runLedger/transcript.ts +187 -0
  20. package/dist/assets/src/core/runLedger/types.ts +25 -4
  21. package/dist/assets/src/core/runRecord.ts +62 -1
  22. package/dist/assets/src/core/ship/coordinator.ts +70 -33
  23. package/dist/assets/src/core/trace/workerTrace.ts +2 -0
  24. package/dist/assets/src/deploy/profile.ts +11 -0
  25. package/dist/assets/src/execution/residentInstanceId.ts +11 -3
  26. package/dist/assets/src/execution/residentText.ts +17 -2
  27. package/dist/assets/src/providers/types.ts +6 -0
  28. package/dist/cli.js +1689 -627
  29. 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:
@@ -434,6 +438,8 @@ workspaceDir: ./workspaces
434
438
  # retentionDays: 30 # days a finished run stays readable (1–365; default 30)
435
439
  # maxRuns: 5000 # newest runs kept (1–20000; default 5000)
436
440
  # maxBytes: 2147483648 # total stored bytes kept (16 MiB–8 GiB; default 2 GiB)
441
+ # sessionLogMaxBytes: 209715200 # the most bytes one thread-and-agent session log holds before its
442
+ # # oldest tool results are replaced by a marker (16 MiB–2 GiB; default 200 MiB)
437
443
  # includeContext: true # include the thread-context turns fed to the model in the
438
444
  # # run stream — live page and persisted record (default true)
439
445
  # store: worker # `worker` (default when `worker` is set) or `file` — an
@@ -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
- : await getContainer(env.SWITCHBOARD, INSTANCE).fetch(forwarded);
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