@coreplane/switchboard 1.205.0 → 1.206.1
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/Dockerfile +6 -3
- package/dist/assets/config/config.example.yaml +24 -1
- package/dist/assets/deploy/cloudflare/coordinator.ts +86 -0
- package/dist/assets/deploy/cloudflare/shared.ts +9 -0
- package/dist/assets/deploy/cloudflare/worker.ts +142 -6
- package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +9 -0
- package/dist/assets/deploy/cloudflare-memory/worker.ts +266 -18
- package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +15 -0
- package/dist/assets/deploy/cloudflare-resident/Dockerfile +89 -3
- package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +81 -10
- package/dist/assets/deploy/secrets.manifest.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 +58 -7
- package/dist/assets/src/config/profile.ts +34 -5
- package/dist/assets/src/core/authz/policy.ts +15 -0
- package/dist/assets/src/core/coordinator/contract.ts +254 -0
- package/dist/assets/src/core/coordinator/driver.ts +504 -0
- package/dist/assets/src/core/coordinator/instancesRoute.ts +181 -0
- package/dist/assets/src/core/delivery.ts +69 -15
- package/dist/assets/src/core/deliverySnapshotStore.ts +84 -24
- package/dist/assets/src/core/reviewVerdict.ts +296 -0
- package/dist/assets/src/core/reviewedHead.ts +77 -0
- package/dist/assets/src/core/runEvents.ts +12 -3
- package/dist/assets/src/core/runLedger/decisions.ts +2 -1
- package/dist/assets/src/core/runLedger/types.ts +17 -1
- package/dist/assets/src/core/runRecord.ts +56 -0
- package/dist/assets/src/core/ship/coordinator.ts +1058 -0
- package/dist/assets/web/dist/.vite/manifest.json +20 -20
- package/dist/assets/web/dist/assets/DeliveryPage-CvlWP7Eq.js +1 -0
- package/dist/assets/web/dist/assets/{ResidentDetailPage-Bcasasjb.js → ResidentDetailPage-D3P21yeI.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-BJwyrphn.js → ResidentsIndexPage-DOYqnZ1q.js} +1 -1
- package/dist/assets/web/dist/assets/{RunRoutePage-6eStApqP.js → RunRoutePage-OmvrvPXY.js} +4 -4
- package/dist/assets/web/dist/assets/{RunsIndexPage-CWrkv7v8.js → RunsIndexPage-DWbSQtL4.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-BX2py1X3.js → ScheduledPage-CPKfJ4mR.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-CwCK84JN.js → StatusDot-COr8jTyM.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-CFC88_-Z.js → Tooltip-fOqTZkNT.js} +1 -1
- package/dist/assets/web/dist/assets/{dist-BoLiHpua.js → dist-BVjAWgkb.js} +1 -1
- package/dist/assets/web/dist/assets/main-CuENKPdD.css +1 -0
- package/dist/assets/web/dist/assets/{main-tYcFk9Dc.js → main-DZbJaqUb.js} +2 -2
- package/dist/cli.js +6056 -3225
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/DeliveryPage-Cx7kQC_e.js +0 -1
- package/dist/assets/web/dist/assets/main-i3ZNDRLK.css +0 -1
package/dist/assets/Dockerfile
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
# Switchboard — single-process Slack bot (Socket Mode, no inbound port needed).
|
|
2
2
|
# git + gh are installed because the coding/review agents shell out to them.
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
# Node at the exact tag every image in this repository shares (the execution
|
|
5
|
+
# images copy theirs from the same one); its major is .nvmrc's, and
|
|
6
|
+
# src/deploy/imageNode.test.ts holds both.
|
|
7
|
+
FROM node:24.21.0-slim AS build
|
|
5
8
|
WORKDIR /app
|
|
6
9
|
# One lockfile covers every workspace. npm needs each workspace's manifest on
|
|
7
10
|
# disk to resolve the tree, so the manifests are copied before the install;
|
|
@@ -28,7 +31,7 @@ RUN npm run build
|
|
|
28
31
|
# Runtime dependencies alone: the bot's production dependencies, no dev tools,
|
|
29
32
|
# no web toolchain — a clean install rather than a prune, so nothing hoisted
|
|
30
33
|
# for the build survives into the image.
|
|
31
|
-
FROM node:24-slim AS deps
|
|
34
|
+
FROM node:24.21.0-slim AS deps
|
|
32
35
|
WORKDIR /app
|
|
33
36
|
COPY package.json package-lock.json ./
|
|
34
37
|
COPY web/package.json ./web/
|
|
@@ -53,7 +56,7 @@ RUN npm ci --omit=dev --workspaces=false --include-workspace-root
|
|
|
53
56
|
FROM docker.io/library/golang:1.27.1-bookworm AS meat
|
|
54
57
|
RUN CGO_ENABLED=0 go install meat.dev/cmd/meat@f39f41dfe7b5b37a12b35fdfbaecc7e779855bd3
|
|
55
58
|
|
|
56
|
-
FROM node:24-slim
|
|
59
|
+
FROM node:24.21.0-slim
|
|
57
60
|
RUN apt-get update \
|
|
58
61
|
&& apt-get install -y --no-install-recommends git curl ca-certificates \
|
|
59
62
|
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
|
@@ -109,7 +109,7 @@ defaults:
|
|
|
109
109
|
# bypassed via directives or config scopes. Names must be registered agents /
|
|
110
110
|
# owner/name slugs, or the load fails.
|
|
111
111
|
# restrict:
|
|
112
|
-
# agents: [coding]
|
|
112
|
+
# agents: [coding, conductor] # conductor: fan-out closed unless granted (docs/reference/specs/agent-conductor.md)
|
|
113
113
|
# repos: [acme/api]
|
|
114
114
|
#
|
|
115
115
|
# `grants`: what ANY actor holds, in one shape, keyed by its platform-namespaced
|
|
@@ -157,6 +157,16 @@ defaults:
|
|
|
157
157
|
# http:cron: # the identity the shim fires schedules as
|
|
158
158
|
# actions: [friction:read, friction:write, repo:write]
|
|
159
159
|
# channels: all # the weekly pass analyzes the whole fleet
|
|
160
|
+
# http:coordinator: # the ship coordinator's bearer (its own
|
|
161
|
+
# actions: [coordinator:step, plan:merge] # SWITCHBOARD_INGRESS_TOKENS entry, subject
|
|
162
|
+
# # "coordinator", rotated apart from cron):
|
|
163
|
+
# # coordinator:step is the one grant the
|
|
164
|
+
# # coordinator's steps need — the child a spawn
|
|
165
|
+
# # starts is authorized as the requesting user,
|
|
166
|
+
# # never as this identity; plan:merge lets the
|
|
167
|
+
# # plan runner merge a plan branch's pull request
|
|
168
|
+
# # once the review approved at its head and CI is
|
|
169
|
+
# # green — leave it out and every merge is a person's
|
|
160
170
|
# schedule:self-improvement: # the weekly cron's own actor (src/core/schedules.ts
|
|
161
171
|
# actions: [friction:write] # declares its grants; an entry here REPLACES them)
|
|
162
172
|
# channels: all # analyzes the whole fleet, not one channel
|
|
@@ -278,6 +288,19 @@ workspaceDir: ./workspaces
|
|
|
278
288
|
# ship:
|
|
279
289
|
# maxRounds: 3 # review rounds per pipeline
|
|
280
290
|
# maxMinutes: 120 # the ship preset's wall-clock budget in minutes (the registry's default)
|
|
291
|
+
# coordinator: false # true hands every agent:ship request to the plan runner — the ShipCoordinator
|
|
292
|
+
# # Workflow in the bot Worker — instead of the in-process round loop; needs the
|
|
293
|
+
# # `coordinator` ingress entry, its grant, run history on the state Worker and
|
|
294
|
+
# # PUBLIC_BASE_URL (see docs/how-to/turn-features-on-and-off.md)
|
|
295
|
+
|
|
296
|
+
# The fan-out cap a spawning run meets (docs/reference/specs/agent-conductor.md).
|
|
297
|
+
# `agent:conductor` starts child runs as the person who asked — each an
|
|
298
|
+
# ordinary run in a thread of its own, under that person's permissions — and
|
|
299
|
+
# at most `maxChildren` of one run's children are live at once: a spawn past
|
|
300
|
+
# the cap is refused by name until a child finishes. A child cannot spawn.
|
|
301
|
+
# Default shown; an integer >= 1.
|
|
302
|
+
# spawn:
|
|
303
|
+
# maxChildren: 3
|
|
281
304
|
|
|
282
305
|
# Spend reporting: GET /costs (Access-gated). Optional. See docs/reference/specs/costs.md.
|
|
283
306
|
# costs:
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// The ship coordinator as a Workflow (docs/decisions/0029-durable-objects-store-workflows-schedule.md,
|
|
2
|
+
// docs/decisions/0031-the-coordinator-runs-a-plan-not-a-pull-request.md;
|
|
3
|
+
// docs/reference/specs/http-ingress.md item 9): the `ShipCoordinator`
|
|
4
|
+
// entrypoint the Workflows binding names, declared here and re-exported by
|
|
5
|
+
// worker.ts (the binding resolves `class_name` against the entry module, the
|
|
6
|
+
// way the resident Worker's refresh cycle is split). `run()` is the plan
|
|
7
|
+
// runner's driver (`src/core/coordinator/driver.ts`) over the platform's step
|
|
8
|
+
// primitives and one bot client: every step is a `POST /admin/coordinator/<step>`
|
|
9
|
+
// into the container, presented with the `coordinator` bearer from the token
|
|
10
|
+
// map, and every fact and every child run is the bot's to produce. The instance
|
|
11
|
+
// id is the plan's (`plan-<plan-id>`, or the ship record's for a task): the
|
|
12
|
+
// bot wrote the instance and its unit rows before it created the instance, and
|
|
13
|
+
// the driver's first step reads them back — nothing rides in the params.
|
|
14
|
+
//
|
|
15
|
+
// What a coordinator may hold is bounded here by type and in coordinator.test.ts
|
|
16
|
+
// by a scan: the container binding (its loopback into the bot) and the token
|
|
17
|
+
// map (the `coordinator` entry it presents to the bot's steps) — no model,
|
|
18
|
+
// Slack or GitHub credential, and no fetch but the container binding's.
|
|
19
|
+
import { getContainer } from "@cloudflare/containers";
|
|
20
|
+
import { WorkflowEntrypoint, type WorkflowEvent, type WorkflowStep } from "cloudflare:workers";
|
|
21
|
+
import { NonRetryableError } from "cloudflare:workflows";
|
|
22
|
+
import { COORDINATOR_IDENTITY, COORDINATOR_STEP_PATH_PREFIX } from "../../src/core/coordinator/contract.ts";
|
|
23
|
+
import {
|
|
24
|
+
readBotAnswer,
|
|
25
|
+
runPlan,
|
|
26
|
+
type CoordinatorBot,
|
|
27
|
+
type PlanRunSummary,
|
|
28
|
+
type StepRunner,
|
|
29
|
+
} from "../../src/core/coordinator/driver.ts";
|
|
30
|
+
import { parseIngressTokenMap, tokenForSubject } from "../../src/core/ingressTokens.ts";
|
|
31
|
+
import { INSTANCE, INTERNAL } from "./shared";
|
|
32
|
+
import type { Env } from "./worker";
|
|
33
|
+
|
|
34
|
+
/** The env slice a coordinator instance may see. Everything else the shim
|
|
35
|
+
* holds for the container is out of the coordinator's reach by type. */
|
|
36
|
+
export type CoordinatorEnv = Pick<Env, "SWITCHBOARD" | "SWITCHBOARD_INGRESS_TOKENS">;
|
|
37
|
+
|
|
38
|
+
/** What an instance is created with: nothing the driver reads — the instance
|
|
39
|
+
* id names the plan, and the bot's rows are the input (the coordinator's
|
|
40
|
+
* contract: ids only, never a task's text or a thread's contents). */
|
|
41
|
+
export type ShipCoordinatorParams = Record<string, unknown>;
|
|
42
|
+
|
|
43
|
+
/** The bot behind the container binding: the reply as the wire carried it.
|
|
44
|
+
* The transport's failures throw for the step's retry; the door's own refusal
|
|
45
|
+
* — a 401/403 the bot did not stamp, a bearer it does not admit — is final,
|
|
46
|
+
* since no retry changes a token map. */
|
|
47
|
+
function containerBot(env: CoordinatorEnv): CoordinatorBot {
|
|
48
|
+
return {
|
|
49
|
+
async step(route, body) {
|
|
50
|
+
const bearer = tokenForSubject(parseIngressTokenMap(env.SWITCHBOARD_INGRESS_TOKENS).tokens, COORDINATOR_IDENTITY);
|
|
51
|
+
if (bearer === undefined)
|
|
52
|
+
throw new NonRetryableError(
|
|
53
|
+
`the token map has no single \`${COORDINATOR_IDENTITY}\` entry — the coordinator cannot present itself to the bot`,
|
|
54
|
+
);
|
|
55
|
+
const res = await getContainer(env.SWITCHBOARD, INSTANCE).fetch(
|
|
56
|
+
new Request(`${INTERNAL}${COORDINATOR_STEP_PATH_PREFIX}${route}`, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json" },
|
|
59
|
+
body: JSON.stringify(body),
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
const text = await res.text().catch(() => "");
|
|
63
|
+
if (res.status === 401 || res.status === 403) {
|
|
64
|
+
const read = readBotAnswer(res.status, text);
|
|
65
|
+
if (!read.ok) throw new NonRetryableError(`the bot refused the coordinator bearer on ${route}: ${read.reason}`);
|
|
66
|
+
}
|
|
67
|
+
return { status: res.status, text };
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The platform's step as the driver types it. Every stored step output is a
|
|
73
|
+
* bot answer — a JSON object — so the platform's serializable bound holds. */
|
|
74
|
+
function workflowSteps(step: WorkflowStep): StepRunner {
|
|
75
|
+
return {
|
|
76
|
+
do: (name, config, callback) => step.do(name, config, callback),
|
|
77
|
+
sleep: (name, ms) => step.sleep(name, ms),
|
|
78
|
+
waitForEvent: (name, options) => step.waitForEvent(name, options),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class ShipCoordinator extends WorkflowEntrypoint<CoordinatorEnv, ShipCoordinatorParams> {
|
|
83
|
+
async run(event: Readonly<WorkflowEvent<ShipCoordinatorParams>>, step: WorkflowStep): Promise<PlanRunSummary> {
|
|
84
|
+
return runPlan(workflowSteps(step), containerBot(this.env), event.instanceId);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// What the shim's entry and its Workflow both address: the one container
|
|
2
|
+
// instance the shim runs (a singleton Durable Object) and the internal origin
|
|
3
|
+
// every request into it is written against — the container ignores the host,
|
|
4
|
+
// the origin only has to be a URL. Declared apart from worker.ts so
|
|
5
|
+
// coordinator.ts reaches them without importing the entry (the binding
|
|
6
|
+
// resolves the Workflow's class against worker.ts, which re-exports it: a value
|
|
7
|
+
// import the other way would be a cycle).
|
|
8
|
+
export const INSTANCE = "singleton";
|
|
9
|
+
export const INTERNAL = "https://switchboard-keepalive.internal";
|
|
@@ -12,6 +12,18 @@
|
|
|
12
12
|
import { Container, getContainer } from "@cloudflare/containers";
|
|
13
13
|
import { parseHealthz } from "../../src/deploy/liveGate.ts";
|
|
14
14
|
import {
|
|
15
|
+
COORDINATOR_AUTHORIZE_PATH,
|
|
16
|
+
COORDINATOR_INSTANCES_PATH,
|
|
17
|
+
createInstanceResponse,
|
|
18
|
+
instanceStatusResponse,
|
|
19
|
+
isInstanceNotFound,
|
|
20
|
+
parseInstanceStatusPath,
|
|
21
|
+
parseCreateInstanceRequest,
|
|
22
|
+
parseSubjectAuthorization,
|
|
23
|
+
type CreateInstanceOutcome,
|
|
24
|
+
} from "../../src/core/coordinator/instancesRoute.ts";
|
|
25
|
+
import {
|
|
26
|
+
authenticateIngressBearer,
|
|
15
27
|
authenticateRestart,
|
|
16
28
|
decideRestart,
|
|
17
29
|
parseRestartAuthorization,
|
|
@@ -34,6 +46,13 @@ import {
|
|
|
34
46
|
import { systemClock } from "../../src/core/trace/clock.ts";
|
|
35
47
|
import { createTracer } from "../../src/core/trace/tracer.ts";
|
|
36
48
|
import { shimRoute, stripTraceContext, withTraceContext, workerLogSink } from "../../src/core/trace/workerTrace.ts";
|
|
49
|
+
import type { ShipCoordinatorParams } from "./coordinator";
|
|
50
|
+
import { INSTANCE, INTERNAL } from "./shared";
|
|
51
|
+
|
|
52
|
+
/** The ship coordinator's Workflow entrypoint is declared in coordinator.ts;
|
|
53
|
+
* the Workflows binding resolves its `class_name` against this module
|
|
54
|
+
* (wrangler.template.jsonc), so the entry exports it under that name. */
|
|
55
|
+
export { ShipCoordinator } from "./coordinator";
|
|
37
56
|
|
|
38
57
|
// The Worker's own spans (docs/reference/specs/tracing.md item 22): one `bot-shim.fetch`
|
|
39
58
|
// root per routed request and one `cron.<schedule>` root per fired schedule,
|
|
@@ -42,8 +61,11 @@ import { shimRoute, stripTraceContext, withTraceContext, workerLogSink } from ".
|
|
|
42
61
|
const tracer = createTracer({ clock: systemClock });
|
|
43
62
|
const traceSinks = [workerLogSink((line) => console.log(line))];
|
|
44
63
|
|
|
45
|
-
interface Env {
|
|
64
|
+
export interface Env {
|
|
46
65
|
SWITCHBOARD: DurableObjectNamespace<SwitchboardServer>;
|
|
66
|
+
/** The ship coordinator (coordinator.ts): `POST /admin/coordinator/instances`
|
|
67
|
+
* creates its instances; the state Worker's finish sends them `run-finished-<runId>`. */
|
|
68
|
+
SHIP_COORDINATOR: Workflow<ShipCoordinatorParams>;
|
|
47
69
|
// secrets (wrangler secret put ...)
|
|
48
70
|
SLACK_BOT_TOKEN: string;
|
|
49
71
|
SLACK_APP_TOKEN: string;
|
|
@@ -117,9 +139,6 @@ function containerEnv(env: Env): Record<string, string> {
|
|
|
117
139
|
return vars;
|
|
118
140
|
}
|
|
119
141
|
|
|
120
|
-
const INSTANCE = "singleton";
|
|
121
|
-
const INTERNAL = "https://switchboard-keepalive.internal";
|
|
122
|
-
|
|
123
142
|
export class SwitchboardServer extends Container<Env> {
|
|
124
143
|
defaultPort = 8080; // the bot's health endpoint (PORT=8080 in the image)
|
|
125
144
|
// Never let this scale to zero: the Slack websocket must stay connected and
|
|
@@ -260,6 +279,116 @@ async function handleAdminRestart(request: Request, env: Env): Promise<Response>
|
|
|
260
279
|
return json(res.status, res.body);
|
|
261
280
|
}
|
|
262
281
|
|
|
282
|
+
/** `POST /admin/coordinator/instances` — the one route that creates a ship
|
|
283
|
+
* coordinator instance (docs/reference/specs/http-ingress.md item 9), the
|
|
284
|
+
* Workflow being this Worker's. WHO: the bearer must be an entry of
|
|
285
|
+
* SWITCHBOARD_INGRESS_TOKENS (401/503 here, an unknown bearer never touches
|
|
286
|
+
* the container). WHETHER: the bot's config — the shim relays the bearer to
|
|
287
|
+
* `POST /admin/coordinator/authorize`, where the policy table decides
|
|
288
|
+
* `coordinator:step`, and creates only on a 200. Body `{ id, params? }`. */
|
|
289
|
+
async function handleCoordinatorInstances(request: Request, env: Env): Promise<Response> {
|
|
290
|
+
const json = (status: number, body: Record<string, unknown>) =>
|
|
291
|
+
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
292
|
+
if (request.method !== "POST")
|
|
293
|
+
return json(405, { ok: false, error: `method not allowed: POST ${COORDINATOR_INSTANCES_PATH}` });
|
|
294
|
+
const authorization = request.headers.get("authorization") ?? undefined;
|
|
295
|
+
const authn = authenticateIngressBearer(authorization, env.SWITCHBOARD_INGRESS_TOKENS, "coordinator");
|
|
296
|
+
if (!authn.ok) {
|
|
297
|
+
console.warn(`[coordinator] instances ${authn.status} — ${authn.reason}`);
|
|
298
|
+
return json(authn.status, { ok: false, error: authn.reason });
|
|
299
|
+
}
|
|
300
|
+
const parsed = parseCreateInstanceRequest(await request.text().catch(() => ""));
|
|
301
|
+
if (!parsed.ok) return json(400, { ok: false, error: parsed.reason });
|
|
302
|
+
let answer: Response;
|
|
303
|
+
try {
|
|
304
|
+
answer = await getContainer(env.SWITCHBOARD, INSTANCE).fetch(
|
|
305
|
+
new Request(`${INTERNAL}${COORDINATOR_AUTHORIZE_PATH}`, {
|
|
306
|
+
method: "POST",
|
|
307
|
+
headers: { authorization: authorization ?? "" },
|
|
308
|
+
}),
|
|
309
|
+
);
|
|
310
|
+
} catch (err) {
|
|
311
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
312
|
+
console.error(`[coordinator] instances ${parsed.id}: the bot could not be asked: ${reason}`);
|
|
313
|
+
return json(503, { ok: false, error: `coordinator disabled: the bot could not be asked (${reason})` });
|
|
314
|
+
}
|
|
315
|
+
const auth = parseSubjectAuthorization(answer.status, await answer.text().catch(() => ""));
|
|
316
|
+
if (!auth.ok) {
|
|
317
|
+
console.warn(`[coordinator] instances ${auth.status} — ${auth.reason}`);
|
|
318
|
+
return json(auth.status, { ok: false, error: auth.reason });
|
|
319
|
+
}
|
|
320
|
+
let outcome: CreateInstanceOutcome;
|
|
321
|
+
try {
|
|
322
|
+
await env.SHIP_COORDINATOR.create({ id: parsed.id, params: parsed.params });
|
|
323
|
+
outcome = { kind: "created", id: parsed.id };
|
|
324
|
+
} catch (err) {
|
|
325
|
+
// The engine refuses a taken id; read the existing instance back to say
|
|
326
|
+
// so. Anything else — the engine unreachable, a malformed param — is a
|
|
327
|
+
// failure by reason.
|
|
328
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
329
|
+
try {
|
|
330
|
+
const existing = await (await env.SHIP_COORDINATOR.get(parsed.id)).status();
|
|
331
|
+
outcome = { kind: "duplicate", id: parsed.id, status: existing.status };
|
|
332
|
+
} catch {
|
|
333
|
+
outcome = { kind: "failed", id: parsed.id, reason };
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
console.log(`[coordinator] ${auth.subject} → instance ${parsed.id}: ${outcome.kind}`);
|
|
337
|
+
const res = createInstanceResponse(outcome);
|
|
338
|
+
return json(res.status, res.body);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** `GET /admin/coordinator/instances/<id>` — the platform's status of one
|
|
342
|
+
* instance (docs/reference/specs/http-ingress.md item 9), read by the bot
|
|
343
|
+
* before a plan is re-issued: still running, ended, or never created (the
|
|
344
|
+
* leftover of a create that failed). The same door as the create: the bearer
|
|
345
|
+
* in the map, the bot's `authorize` answer. The engine's own `instance.not_found`
|
|
346
|
+
* is 404; any other failure is 502 by reason — never read as absence, because a
|
|
347
|
+
* wrong absence would let a re-issue write over a live runner's records. */
|
|
348
|
+
async function handleCoordinatorInstanceStatus(request: Request, env: Env, id: string): Promise<Response> {
|
|
349
|
+
const json = (status: number, body: Record<string, unknown>) =>
|
|
350
|
+
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
|
351
|
+
if (request.method !== "GET")
|
|
352
|
+
return json(405, {
|
|
353
|
+
ok: false,
|
|
354
|
+
error: `method not allowed: ${request.method} — ${COORDINATOR_INSTANCES_PATH}/<id> answers GET only`,
|
|
355
|
+
});
|
|
356
|
+
const authorization = request.headers.get("authorization") ?? undefined;
|
|
357
|
+
const authn = authenticateIngressBearer(authorization, env.SWITCHBOARD_INGRESS_TOKENS, "coordinator");
|
|
358
|
+
if (!authn.ok) {
|
|
359
|
+
console.warn(`[coordinator] instance status ${authn.status} — ${authn.reason}`);
|
|
360
|
+
return json(authn.status, { ok: false, error: authn.reason });
|
|
361
|
+
}
|
|
362
|
+
let answer: Response;
|
|
363
|
+
try {
|
|
364
|
+
answer = await getContainer(env.SWITCHBOARD, INSTANCE).fetch(
|
|
365
|
+
new Request(`${INTERNAL}${COORDINATOR_AUTHORIZE_PATH}`, {
|
|
366
|
+
method: "POST",
|
|
367
|
+
headers: { authorization: authorization ?? "" },
|
|
368
|
+
}),
|
|
369
|
+
);
|
|
370
|
+
} catch (err) {
|
|
371
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
372
|
+
return json(503, { ok: false, error: `coordinator disabled: the bot could not be asked (${reason})` });
|
|
373
|
+
}
|
|
374
|
+
const auth = parseSubjectAuthorization(answer.status, await answer.text().catch(() => ""));
|
|
375
|
+
if (!auth.ok) return json(auth.status, { ok: false, error: auth.reason });
|
|
376
|
+
let outcome: Parameters<typeof instanceStatusResponse>[0];
|
|
377
|
+
try {
|
|
378
|
+
const status = await (await env.SHIP_COORDINATOR.get(id)).status();
|
|
379
|
+
outcome = { kind: "status", id, status: status.status };
|
|
380
|
+
} catch (err) {
|
|
381
|
+
// Absence is only the engine's own word for it — the `instance.not_found`
|
|
382
|
+
// code the Workflows binding throws for an id it has never seen. Any other
|
|
383
|
+
// failure, whatever its text says, is a failure by reason: a wrong absence
|
|
384
|
+
// would let a re-issue write over a live runner's records.
|
|
385
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
386
|
+
outcome = isInstanceNotFound(reason) ? { kind: "absent", id } : { kind: "failed", id, reason };
|
|
387
|
+
}
|
|
388
|
+
const res = instanceStatusResponse(outcome);
|
|
389
|
+
return json(res.status, res.body);
|
|
390
|
+
}
|
|
391
|
+
|
|
263
392
|
/** Record a firing on the state Worker's ScheduleDO (the /runs Scheduled panel
|
|
264
393
|
* reads it). Best-effort: a failure here is a log line — the run itself (if
|
|
265
394
|
* any) already happened and is its own record. */
|
|
@@ -283,11 +412,18 @@ export default {
|
|
|
283
412
|
const root = tracer.start("bot-shim.fetch", { sinks: traceSinks, attrs: { route } });
|
|
284
413
|
try {
|
|
285
414
|
const forwarded = withTraceContext(inbound, root);
|
|
286
|
-
// The
|
|
415
|
+
// The routes the Worker answers itself — the restart, the coordinator's
|
|
416
|
+
// instance creation and an instance's status, all over bindings only this
|
|
417
|
+
// Worker holds; everything else is the container's.
|
|
418
|
+
const statusId = parseInstanceStatusPath(pathname);
|
|
287
419
|
const res =
|
|
288
420
|
pathname === "/admin/restart"
|
|
289
421
|
? await handleAdminRestart(forwarded, env)
|
|
290
|
-
:
|
|
422
|
+
: pathname === COORDINATOR_INSTANCES_PATH
|
|
423
|
+
? await handleCoordinatorInstances(forwarded, env)
|
|
424
|
+
: statusId !== undefined
|
|
425
|
+
? await handleCoordinatorInstanceStatus(forwarded, env, statusId)
|
|
426
|
+
: await getContainer(env.SWITCHBOARD, INSTANCE).fetch(forwarded);
|
|
291
427
|
root.end(res.status >= 500 ? "error" : "ok", { httpStatus: res.status });
|
|
292
428
|
return res;
|
|
293
429
|
} catch (err) {
|
|
@@ -55,6 +55,15 @@
|
|
|
55
55
|
"bindings": [{ "name": "SWITCHBOARD", "class_name": "SwitchboardServer" }]
|
|
56
56
|
},
|
|
57
57
|
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["SwitchboardServer"] }],
|
|
58
|
+
// The ship coordinator as a Workflow (coordinator.ts, re-exported by
|
|
59
|
+
// worker.ts; docs/reference/specs/http-ingress.md item 9): `POST
|
|
60
|
+
// /admin/coordinator/instances` creates its instances and the state Worker's
|
|
61
|
+
// finish sends them `run finished:<runId>` through its own cross-script
|
|
62
|
+
// binding to this class (deploy/cloudflare-memory/wrangler.template.jsonc).
|
|
63
|
+
// Same-script here, so this Worker deploys on its own.
|
|
64
|
+
"workflows": [
|
|
65
|
+
{ "name": "{{script}}-ship-coordinator", "binding": "SHIP_COORDINATOR", "class_name": "ShipCoordinator" }
|
|
66
|
+
],
|
|
58
67
|
// Every cron here IS a schedule in src/core/schedules.ts (the registry; a unit
|
|
59
68
|
// test fails when the two sets differ) and worker.ts looks each firing up there.
|
|
60
69
|
// "* * * * *": keep-alive (/healthz) — also what RESTARTS the container after a
|