@coreplane/switchboard 1.254.1 → 1.256.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 (66) hide show
  1. package/dist/assets/.dockerignore +3 -0
  2. package/dist/assets/Dockerfile +12 -1
  3. package/dist/assets/config/config.example.yaml +6 -1
  4. package/dist/assets/deploy/cloudflare/worker.ts +39 -23
  5. package/dist/assets/deploy/cloudflare-memory/worker.ts +552 -5
  6. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +5 -0
  7. package/dist/assets/deploy/cloudflare-resident/Dockerfile +13 -1
  8. package/dist/assets/deploy/cloudflare-resident/drain.ts +55 -1
  9. package/dist/assets/deploy/cloudflare-resident/levels.ts +84 -0
  10. package/dist/assets/deploy/cloudflare-resident/prepare-commit-msg +17 -0
  11. package/dist/assets/deploy/cloudflare-resident/worker.ts +358 -42
  12. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +11 -1
  13. package/dist/assets/deploy/cloudflare-sandbox/prepare-commit-msg +17 -0
  14. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +21 -4
  15. package/dist/assets/deploy/hooks/prepare-commit-msg +17 -0
  16. package/dist/assets/deploy/secrets.manifest.json +12 -0
  17. package/dist/assets/package-lock.json +3 -3
  18. package/dist/assets/package.json +1 -1
  19. package/dist/assets/source.json +3 -3
  20. package/dist/assets/src/agents/registry.ts +52 -0
  21. package/dist/assets/src/core/budgets.ts +35 -2
  22. package/dist/assets/src/core/coordinator/contract.ts +6 -0
  23. package/dist/assets/src/core/coordinator/driver.ts +59 -6
  24. package/dist/assets/src/core/costs.ts +39 -16
  25. package/dist/assets/src/core/pipelineStanding.ts +62 -0
  26. package/dist/assets/src/core/plane/decide.ts +389 -22
  27. package/dist/assets/src/core/refusal.ts +3 -0
  28. package/dist/assets/src/core/reviewVerdict.ts +64 -2
  29. package/dist/assets/src/core/runEvents.ts +31 -12
  30. package/dist/assets/src/core/runLedger/sessionLog.ts +128 -0
  31. package/dist/assets/src/core/runLedger/types.ts +3 -0
  32. package/dist/assets/src/core/runRecord.ts +24 -7
  33. package/dist/assets/src/core/ship/contract.ts +20 -30
  34. package/dist/assets/src/core/ship/coordinator.ts +519 -126
  35. package/dist/assets/src/core/trace/workerTrace.ts +3 -0
  36. package/dist/assets/src/execution/sandboxErrors.ts +77 -6
  37. package/dist/assets/web/dist/.vite/manifest.json +61 -60
  38. package/dist/assets/web/dist/assets/CostsPage-BaeWnm-o.js +1 -0
  39. package/dist/assets/web/dist/assets/{DeliveryPage-ngPsO2to.js → DeliveryPage-BBAyLwPq.js} +1 -1
  40. package/dist/assets/web/dist/assets/{HomePage-DvxTHzPx.js → HomePage-BEiStFwW.js} +1 -1
  41. package/dist/assets/web/dist/assets/PendingTurnRow-DGINv9XT.js +1 -0
  42. package/dist/assets/web/dist/assets/{PlanePage-DpWfiX4C.js → PlanePage-JEj-lqgz.js} +1 -1
  43. package/dist/assets/web/dist/assets/{ResidentDetailPage-DG86v39Y.js → ResidentDetailPage-jYhEeeyu.js} +1 -1
  44. package/dist/assets/web/dist/assets/{ResidentsIndexPage-x6p689VH.js → ResidentsIndexPage-DvFmlV4U.js} +1 -1
  45. package/dist/assets/web/dist/assets/RunFoldRow-DvWzR1JQ.js +1 -0
  46. package/dist/assets/web/dist/assets/RunRoutePage-CvZ-TOT3.js +9 -0
  47. package/dist/assets/web/dist/assets/{RunsIndexPage-CuzFchcn.js → RunsIndexPage-JqVSDOok.js} +1 -1
  48. package/dist/assets/web/dist/assets/{ScheduledPage-BuLmfcbG.js → ScheduledPage-Viim-bus.js} +1 -1
  49. package/dist/assets/web/dist/assets/{SettingsPage-BujWkdU_.js → SettingsPage-CYUy8McC.js} +1 -1
  50. package/dist/assets/web/dist/assets/{StatusDot-C8Bc0pTX.js → StatusDot-Dv6UMaPy.js} +1 -1
  51. package/dist/assets/web/dist/assets/{Tooltip-BWwJx27K.js → Tooltip-Bd-5Rypv.js} +1 -1
  52. package/dist/assets/web/dist/assets/UnitRoutePage-B81wEnKN.js +1 -0
  53. package/dist/assets/web/dist/assets/budgets-c1eumrqD.js +1 -0
  54. package/dist/assets/web/dist/assets/{dist-CpnyQGOb.js → dist-Nl3uaxrP.js} +1 -1
  55. package/dist/assets/web/dist/assets/indexRow-DborJPFp.js +1 -0
  56. package/dist/assets/web/dist/assets/{main-Comxmwi4.js → main-DRxWlffc.js} +2 -2
  57. package/dist/assets/web/dist/assets/{sseReplay-IzTdD4-3.js → sseReplay-DE6wv1Ua.js} +6 -6
  58. package/dist/cli.js +4400 -3079
  59. package/package.json +1 -1
  60. package/dist/assets/web/dist/assets/CostsPage-BuKjw3nv.js +0 -1
  61. package/dist/assets/web/dist/assets/PendingTurnRow-ZYIRCCZ2.js +0 -1
  62. package/dist/assets/web/dist/assets/RunFoldRow-DG29LOTs.js +0 -1
  63. package/dist/assets/web/dist/assets/RunRoutePage-ysJBY8xQ.js +0 -9
  64. package/dist/assets/web/dist/assets/UnitRoutePage-B9kjA1AT.js +0 -1
  65. package/dist/assets/web/dist/assets/budgets-CbIyPAER.js +0 -1
  66. package/dist/assets/web/dist/assets/indexRow-DABQtONT.js +0 -1
@@ -58,6 +58,11 @@
58
58
  // deploy/cloudflare/wrangler.template.jsonc). The class is deployed with the
59
59
  // bot, which deploys after this Worker — so this binding lands one release
60
60
  // after the class did, never in the same one.
61
+ // The bot Worker, for the plane's effect push (record 0064, "Where it
62
+ // lives"): committed effects POST to the bot's bearer-gated /plane/effects,
63
+ // which forwards them to the container. A push that fails is not retried by
64
+ // a timer — the effect rides the next heartbeat or reclaim-sweep answer.
65
+ "services": [{ "binding": "BOT", "service": "{{bot.script}}" }],
61
66
  "workflows": [
62
67
  {
63
68
  "name": "{{bot.script}}-ship-coordinator",
@@ -22,8 +22,20 @@ FROM docker.io/cloudflare/sandbox:0.13.0-next.751.1
22
22
  # installed here — matching the resident system prompts and the tests that pin
23
23
  # that claim. No system credential.helper is configured; each worktree sets its
24
24
  # own per-attach helper.
25
+ # The fallback user.email stays OFF the GitHub domain (a noreply-shaped
26
+ # address would render as a GitHub account it is not); the real pairs ride
27
+ # each /exec body's env. The system-level core.hooksPath replaces repo-local
28
+ # .git/hooks entirely — deliberate: an untrusted checkout's own hooks never
29
+ # run here (a repo-local core.hooksPath still wins, git's ordinary
30
+ # precedence) — docs/reference/specs/execution.md item 5.
25
31
  RUN git config --system user.name "switchboard-resident" \
26
- && git config --system user.email "switchboard-resident@users.noreply.github.com"
32
+ && git config --system user.email "switchboard-resident@switchboard.invalid" \
33
+ && git config --system core.hooksPath /opt/switchboard/hooks
34
+
35
+ # The shared agent-trailer hook (deploy/hooks/prepare-commit-msg — this copy is
36
+ # byte-identical, held so by a test: the build context is this directory, so
37
+ # COPY cannot reach the canonical file).
38
+ COPY --chmod=0755 prepare-commit-msg /opt/switchboard/hooks/prepare-commit-msg
27
39
 
28
40
  # The container is FOUR vCPUs shared by up to 16 threads (wrangler.jsonc).
29
41
  # Node test runners size their worker pools from the CPU count the kernel
@@ -23,6 +23,17 @@ export interface DrainRecord {
23
23
  by: string;
24
24
  /** Why, in the words the attach refusal repeats. */
25
25
  reason: string;
26
+ /** The residents whose containers still run the pre-deploy image (issue
27
+ * 1931): the deploy's reconcile marks the Durable Objects but the platform
28
+ * replaces the container processes asynchronously, so "reconciled" is not
29
+ * "swapped" — each hold stands until that resident reports its running
30
+ * container on the deploy's image, and the fleet reopens on the LAST
31
+ * report, a fact, never a wait. Absent or empty: no swap outstanding. */
32
+ holds?: string[];
33
+ /** Whether `POST /undrain` already asked for the lift while holds stood:
34
+ * the record then clears itself on the last hold's report instead of
35
+ * waiting for a second lift. */
36
+ liftAsked?: boolean;
26
37
  }
27
38
 
28
39
  /** The longest a drain may run, and the default, from the one clock table
@@ -88,7 +99,50 @@ export function liveDrain(stored: unknown, now: number): DrainRecord | null {
88
99
  return null;
89
100
  const until = Date.parse(r.until);
90
101
  if (!Number.isFinite(until) || until <= now) return null;
91
- return { since: r.since, until: r.until, by: r.by, reason: r.reason };
102
+ const holds = Array.isArray(r.holds) ? r.holds.filter((h): h is string => typeof h === "string") : [];
103
+ return {
104
+ since: r.since,
105
+ until: r.until,
106
+ by: r.by,
107
+ reason: r.reason,
108
+ ...(holds.length > 0 ? { holds } : {}),
109
+ ...(r.liftAsked === true ? { liftAsked: true } : {}),
110
+ };
111
+ }
112
+
113
+ /** The record with the named residents held (issue 1931): the deploy's
114
+ * reconcile could not verify their running containers on the new image, so
115
+ * the fleet must not reopen onto them until each reports. Deduplicated;
116
+ * an empty set changes nothing. */
117
+ export function holdDrain(record: DrainRecord, resources: readonly string[]): DrainRecord {
118
+ const holds = [...new Set([...(record.holds ?? []), ...resources])];
119
+ if (holds.length === 0) return record;
120
+ return { ...record, holds };
121
+ }
122
+
123
+ /** `POST /undrain`'s decision over the stored record: with no holds the drain
124
+ * clears; with holds outstanding the fleet STAYS closed — the record keeps
125
+ * standing with `liftAsked`, so the last container's new-image report lifts
126
+ * it (a fact, never a timer; `until` remains the backstop for a report that
127
+ * never comes). */
128
+ export function liftDrain(record: DrainRecord): { cleared: true } | { cleared: false; record: DrainRecord } {
129
+ const holds = record.holds ?? [];
130
+ if (holds.length === 0) return { cleared: true };
131
+ return { cleared: false, record: { ...record, liftAsked: true } };
132
+ }
133
+
134
+ /** One resident's word that its running container is on the deploy's image:
135
+ * its hold drops; when it was the last hold and the lift was already asked,
136
+ * the drain lifts here — the reopen fires on the last container's report. */
137
+ export function reportImageCurrent(
138
+ record: DrainRecord,
139
+ resource: string,
140
+ ): { lifted: boolean; record: DrainRecord | null } {
141
+ const holds = (record.holds ?? []).filter((h) => h !== resource);
142
+ const next: DrainRecord = { ...record, ...(holds.length > 0 ? { holds } : {}) };
143
+ if (holds.length === 0) delete next.holds;
144
+ if (holds.length === 0 && record.liftAsked === true) return { lifted: true, record: null };
145
+ return { lifted: false, record: next };
92
146
  }
93
147
 
94
148
  /** The `/attach` answer while the fleet is drained: a 503 whose body carries
@@ -0,0 +1,84 @@
1
+ // The resident's level reports (record 0064; the orchestration plane's
2
+ // resident conditions) — the PURE half, kept free of the Sandbox SDK and DO
3
+ // storage so it runs under plain-Node vitest (levels.test.ts) like gc.ts,
4
+ // drain.ts and memoryGuard.ts. The Worker owns the readings (the user pool's
5
+ // allocation, the memory guard's last sample, its own incarnation) and feeds
6
+ // this module the numbers; every `/attach`, `/exec` and `/status` answer
7
+ // carries the document, and the bot forwards a change to the plane's
8
+ // `POST /plane/level`.
9
+ //
10
+ // Why an outbox and not a push: the resident never holds a credential for the
11
+ // state Worker, so it posts nothing itself — a crossing between calls is kept
12
+ // as a post and re-offered on every answer until a newer crossing of the same
13
+ // name supersedes it. The plane's level write is idempotent per (resident,
14
+ // name), so a post forwarded twice lands once.
15
+
16
+ import { MEMORY_SOFT_LIMIT_PCT } from "./memoryGuard.js";
17
+
18
+ /** Which side of its line a level reads: `above` closes the door (an exhausted
19
+ * pool, the gate's soft side), `below` opens it. */
20
+ export type LevelSide = "below" | "above";
21
+
22
+ /** One level post for the plane: the crossing (or a boot's re-statement) the
23
+ * bot forwards to `POST /plane/level`. */
24
+ export interface LevelPost {
25
+ /** `drain` is the registry's fleet-drain post; the residents post the other two. */
26
+ name: "seat" | "memory" | "drain";
27
+ side: LevelSide;
28
+ generation: string;
29
+ at: string;
30
+ }
31
+
32
+ /** The compact sides of one sample, persisted so the next sample can judge a
33
+ * crossing; `generation` names the incarnation the sample came from. */
34
+ export interface LevelSample {
35
+ seat: LevelSide;
36
+ memory: LevelSide;
37
+ generation: string;
38
+ }
39
+
40
+ /** The document every resident answer carries as `levels` (record 0064). */
41
+ export interface ResidentLevelsDoc {
42
+ seat: { side: LevelSide; used: number; total: number };
43
+ memory: { side: LevelSide; percent: number | null };
44
+ generation: string;
45
+ at: string;
46
+ /** The outbox: crossings not yet superseded, re-offered on every answer. */
47
+ posts: LevelPost[];
48
+ }
49
+
50
+ /** The seat's side: the pool is a hard count, so `above` is exactly "no free
51
+ * user" — the next attach or op would be refused `user-pool-exhausted`. */
52
+ export function seatSide(used: number, total: number): LevelSide {
53
+ return used >= total ? "above" : "below";
54
+ }
55
+
56
+ /** The memory side is the gate's soft line (resident-repos item 70): at or
57
+ * past `MEMORY_SOFT_LIMIT_PCT` a NEW attach is refused, so that is where the
58
+ * plane's `memory` condition sits. No reading, or no cap, gates nothing. */
59
+ export function memorySide(percent: number | null): LevelSide {
60
+ return percent !== null && percent >= MEMORY_SOFT_LIMIT_PCT ? "above" : "below";
61
+ }
62
+
63
+ /** The posts one fresh sample owes the plane: each name whose side crossed
64
+ * since the previous sample — and, with no previous sample or one from
65
+ * another generation (a boot, a replaced runtime), both names re-stated
66
+ * (record 0064: "a boot re-states it"), whatever their sides. */
67
+ export function levelPosts(prev: LevelSample | null, next: LevelSample, at: string): LevelPost[] {
68
+ const restate = prev === null || prev.generation !== next.generation;
69
+ const posts: LevelPost[] = [];
70
+ if (restate || prev.seat !== next.seat)
71
+ posts.push({ name: "seat", side: next.seat, generation: next.generation, at });
72
+ if (restate || prev.memory !== next.memory)
73
+ posts.push({ name: "memory", side: next.memory, generation: next.generation, at });
74
+ return posts;
75
+ }
76
+
77
+ /** Merge fresh posts into the outbox: a newer post of a name supersedes the
78
+ * older one — the plane only needs the current side, and the bot forwarding
79
+ * a superseded post would only be overwritten by the next. The outbox never
80
+ * grows past one post per name. */
81
+ export function mergeOutbox(outbox: LevelPost[], posts: LevelPost[]): LevelPost[] {
82
+ const superseded = new Set(posts.map((p) => p.name));
83
+ return [...outbox.filter((p) => !superseded.has(p.name)), ...posts];
84
+ }
@@ -0,0 +1,17 @@
1
+ #!/bin/sh
2
+ # The agent trailer (docs/reference/specs/execution.md): every commit made in a
3
+ # Switchboard image carries `Co-Authored-By: <bot pair>`, so the agent's hand
4
+ # stays visible even when the author is the requester. The pair is read from
5
+ # GIT_COMMITTER_NAME/GIT_COMMITTER_EMAIL at commit time — the bot fills them
6
+ # per exec — so the image stays installation-agnostic; without them the
7
+ # image's own git identity stands (its fallback address is off the GitHub
8
+ # domain, so it never renders as a GitHub account). Idempotent: a message that
9
+ # already carries this exact trailer is left unchanged; a foreign
10
+ # Co-Authored-By does not stop it (the identity rewrite scrubs those).
11
+ set -e
12
+ msg="$1"
13
+ name="${GIT_COMMITTER_NAME:-$(git config user.name || true)}"
14
+ email="${GIT_COMMITTER_EMAIL:-$(git config user.email || true)}"
15
+ [ -n "$name" ] && [ -n "$email" ] || exit 0
16
+ git interpret-trailers --in-place --if-exists addIfDifferent \
17
+ --trailer "Co-Authored-By: $name <$email>" "$msg"