@coreplane/switchboard 1.259.0 → 1.260.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/config/config.example.yaml +17 -24
- package/dist/assets/deploy/cloudflare/worker.ts +17 -56
- package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +6 -0
- package/dist/assets/deploy/cloudflare-memory/worker.ts +178 -27
- package/dist/assets/deploy/cloudflare-resident/worker.ts +74 -52
- package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +6 -3
- package/dist/assets/deploy/profile.example.json +2 -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 +3 -5
- package/dist/assets/src/core/authz/policy.ts +5 -0
- package/dist/assets/src/core/budgets.ts +19 -6
- package/dist/assets/src/core/coordinator/contract.ts +93 -5
- package/dist/assets/src/core/coordinator/driver.ts +395 -128
- package/dist/assets/src/core/memory/types.ts +5 -0
- package/dist/assets/src/core/pipelineStanding.ts +13 -4
- package/dist/assets/src/core/plane/decide.ts +53 -12
- package/dist/assets/src/core/provider.ts +5 -0
- package/dist/assets/src/core/runEvents.ts +22 -15
- package/dist/assets/src/core/runLedger/types.ts +3 -3
- package/dist/assets/src/core/runRecord.ts +64 -11
- package/dist/assets/src/core/ship/coordinator.ts +202 -53
- package/dist/assets/src/core/ship/renewal.ts +23 -8
- package/dist/assets/src/core/types.ts +11 -0
- package/dist/assets/src/deploy/liveGate.ts +8 -0
- package/dist/assets/src/deploy/profile.ts +8 -0
- package/dist/assets/src/deploy/restart.ts +49 -35
- package/dist/assets/web/dist/.vite/manifest.json +67 -67
- package/dist/assets/web/dist/assets/{DeliveryPage-DadntjSp.js → DeliveryPage-62qCSLvk.js} +1 -1
- package/dist/assets/web/dist/assets/HomePage-Dk3HRBc3.js +1 -0
- package/dist/assets/web/dist/assets/{PendingTurnRow-DBPMLVsm.js → PendingTurnRow-CeTGOXUy.js} +1 -1
- package/dist/assets/web/dist/assets/{PlanePage-DLusMGQ0.js → PlanePage-RD0M6O6W.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentDetailPage-CPnHnrxy.js → ResidentDetailPage-Dl2KId95.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-C9hASwHL.js → ResidentsIndexPage-DBy43I1R.js} +1 -1
- package/dist/assets/web/dist/assets/RunFoldRow-CwHDL-Mo.js +1 -0
- package/dist/assets/web/dist/assets/{RunRoutePage-o1xzkTGZ.js → RunRoutePage-Du1HqLZs.js} +4 -4
- package/dist/assets/web/dist/assets/RunsIndexPage-BZphy3Hr.js +1 -0
- package/dist/assets/web/dist/assets/{ScheduledPage-BIdfC0V3.js → ScheduledPage-B7jh898t.js} +1 -1
- package/dist/assets/web/dist/assets/{SettingsPage-CnqWAjl3.js → SettingsPage-3uLEsiiE.js} +1 -1
- package/dist/assets/web/dist/assets/{SilentTurn-DGNg0sIZ.js → SilentTurn-CKxSxnQC.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-D_D_iJS8.js → StatusDot-x7vcK0iE.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-DVFmM7on.js → Tooltip-wVXCWhMp.js} +1 -1
- package/dist/assets/web/dist/assets/{UnitRoutePage-7fAXFoB_.js → UnitRoutePage-V5BXK7FH.js} +1 -1
- package/dist/assets/web/dist/assets/budgets-KNNkT4PZ.js +1 -0
- package/dist/assets/web/dist/assets/{dist-C1VX-_mm.js → dist-C-XGnvM4.js} +1 -1
- package/dist/assets/web/dist/assets/{indexRow-BO_GIdF6.js → indexRow-Dxg7C8s9.js} +1 -1
- package/dist/assets/web/dist/assets/{main-Cl7xHG34.js → main-DAL--hyj.js} +2 -2
- package/dist/assets/web/dist/assets/sseReplay-BqgOggyQ.js +11 -0
- package/dist/cli.js +9136 -8197
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/HomePage-BEEpTnVT.js +0 -1
- package/dist/assets/web/dist/assets/RunFoldRow-OPe7Z0Cz.js +0 -1
- package/dist/assets/web/dist/assets/RunsIndexPage-0YAClYQh.js +0 -1
- package/dist/assets/web/dist/assets/budgets-BRLm6oID.js +0 -1
- package/dist/assets/web/dist/assets/sseReplay-BjlKmQsY.js +0 -11
|
@@ -97,7 +97,8 @@ defaults:
|
|
|
97
97
|
# thinks per turn; lower = much faster turns). Same layering as models —
|
|
98
98
|
# per-channel/user `effort` / `efforts.<agent>` and a per-request `effort:`
|
|
99
99
|
# directive override these; unset → the agent's built-in effort, else the
|
|
100
|
-
# provider default. Skipped for models without effort support.
|
|
100
|
+
# provider default. Skipped for models without effort support. The `general`
|
|
101
|
+
# entry also controls the operator turn on `defaults.models.general`.
|
|
101
102
|
# efforts:
|
|
102
103
|
# coding: medium
|
|
103
104
|
# How much the bot says about its own doing (docs/reference/specs/routing-and-config.md
|
|
@@ -171,6 +172,7 @@ defaults:
|
|
|
171
172
|
# "config set channel ..." are stored in data/overrides.json and win over these.
|
|
172
173
|
# channels:
|
|
173
174
|
# slack:C012345:
|
|
175
|
+
# repo: acme/api # default repository for repo-bound asks in this channel
|
|
174
176
|
# agent: review
|
|
175
177
|
# models:
|
|
176
178
|
# review: anthropic/claude-opus-5
|
|
@@ -368,7 +370,10 @@ workspaceDir: ./workspaces
|
|
|
368
370
|
# # records (soft delete, status `evicted`) in the same write.
|
|
369
371
|
# model: anthropic/claude-haiku-4-5
|
|
370
372
|
# # <provider>/<model> for the reflection pass — pick a
|
|
371
|
-
# # cheap
|
|
373
|
+
# # cheap level. Absent → the run's own resolved model.
|
|
374
|
+
# effort: low # how hard the reflection model thinks per distillation —
|
|
375
|
+
# # low | medium | high | xhigh | max, applied through the
|
|
376
|
+
# # model card; unset → the model's own default.
|
|
372
377
|
|
|
373
378
|
# Self-improvement proposals (docs/reference/specs/run-friction.md). Every finished run's friction
|
|
374
379
|
# diagnosis (docs/reference/specs/run-friction.md) is stored with its run record, so the
|
|
@@ -479,26 +484,11 @@ workspaceDir: ./workspaces
|
|
|
479
484
|
# spawn:
|
|
480
485
|
# maxChildren: 3
|
|
481
486
|
|
|
482
|
-
# The
|
|
483
|
-
#
|
|
484
|
-
#
|
|
485
|
-
#
|
|
486
|
-
# `ship` is never routed. The one way off, so every plain message runs
|
|
487
|
-
# `defaults.agent` as before the router: `auto: false`. `model` is the router's
|
|
488
|
-
# model; default `defaults.models.general`. `answer` is how that model answers:
|
|
489
|
-
# `tool` (default) forces a call to the router's `route` tool, whose schema is
|
|
490
|
-
# the answer, so prose cannot occur; `text` is the one-JSON-object text contract
|
|
491
|
-
# alone — set it for a provider or model that cannot take a forced tool call.
|
|
492
|
-
# `operator` is the one door's flag (routing-and-config item 29): `on`
|
|
493
|
-
# (default) — the operator's decision is what runs; `shadow` — one operator
|
|
494
|
-
# turn per admitted chat event, ahead of stage A, its decision written beside
|
|
495
|
-
# the routed request in the run store and nothing run from it; `off` — the
|
|
496
|
-
# rollback lever: it turns the operator back off after a bad day, not a choice
|
|
497
|
-
# to make up front. `shadow` and `on` run even under `auto: false`.
|
|
487
|
+
# The one door's mode (routing-and-config item 29): `on` (default) — the
|
|
488
|
+
# operator's typed decision is what runs; `shadow` — one operator turn per
|
|
489
|
+
# admitted chat event, its decision recorded and nothing run from it; `off` —
|
|
490
|
+
# the rollback lever. The retired readers' router has no model or effort key.
|
|
498
491
|
# routing:
|
|
499
|
-
# auto: false
|
|
500
|
-
# model: anthropic/claude-haiku-4-5
|
|
501
|
-
# answer: text
|
|
502
492
|
# operator: off
|
|
503
493
|
|
|
504
494
|
# The thread-reply intake gate (docs/decisions/0058-a-thread-reply-is-read-before-it-is-answered-intake-decides-whether-the-bot-was-addressed.md;
|
|
@@ -512,14 +502,17 @@ workspaceDir: ./workspaces
|
|
|
512
502
|
# model call) or `always` (today's answer-every-reply path, byte for byte).
|
|
513
503
|
# Also settable per channel and per user: `intake: { threadReplies: <mode> }`
|
|
514
504
|
# on a `channels.<id>` or `users.<id>` scope, user over channel over here.
|
|
515
|
-
# `model` is the verdict's model; default `
|
|
516
|
-
#
|
|
517
|
-
# through the
|
|
505
|
+
# `model` is the verdict's model; default `defaults.models.general`. `effort`
|
|
506
|
+
# is how hard that model thinks per verdict — low | medium | high | xhigh |
|
|
507
|
+
# max, applied through the model card; unset → the model's own default. A
|
|
508
|
+
# mention, a DM and a top-level post never pass through the gate in any mode.
|
|
509
|
+
# A model whose operator card (`providers.<p>.
|
|
518
510
|
# models.<id>.answers`) declares it answers neither a forced tool call nor the
|
|
519
511
|
# text contract is refused at load when the default mode is `classify`.
|
|
520
512
|
# intake:
|
|
521
513
|
# threadReplies: classify
|
|
522
514
|
# model: anthropic/claude-haiku-4-5
|
|
515
|
+
# effort: low
|
|
523
516
|
|
|
524
517
|
# The orchestration plane (docs/decisions/0064-the-plane-owns-every-runs-state-a-refusal-becomes-a-queue-position-an-ending-is-judged-by-the-ledger-that-saw-it-and-a-release-is-a-quiet-window-a-person-closes.md;
|
|
525
518
|
# docs/reference/specs/routing-and-config.md item 31; orchestration-plane.md).
|
|
@@ -28,14 +28,10 @@ import {
|
|
|
28
28
|
import {
|
|
29
29
|
authenticateIngressBearer,
|
|
30
30
|
authenticateRestart,
|
|
31
|
+
authorizeRestartDeployer,
|
|
31
32
|
decideRestart,
|
|
32
|
-
parseRestartAuthorization,
|
|
33
33
|
parseRestartRequest,
|
|
34
|
-
RESTART_AUTHORIZE_PATH,
|
|
35
|
-
RESTART_SUBJECT_HEADER,
|
|
36
34
|
restartResponse,
|
|
37
|
-
stripRestartSubject,
|
|
38
|
-
type RestartAuth,
|
|
39
35
|
type RestartOutcome,
|
|
40
36
|
} from "../../src/deploy/restart.ts";
|
|
41
37
|
import {
|
|
@@ -89,7 +85,8 @@ export interface Env {
|
|
|
89
85
|
ACCESS_TEAM_DOMAIN?: string; // live-view SSO gate: Cloudflare Access team domain (JWKS + iss)
|
|
90
86
|
ACCESS_AUD?: string; // live-view SSO gate: Cloudflare Access application AUD tag
|
|
91
87
|
DASHBOARD_TOKEN?: string; // dashboard auth `token` strategy: the bearer (the default env name; config may name another)
|
|
92
|
-
SWITCHBOARD_INGRESS_TOKENS?: string; // enables HTTP /ingress + MCP /mcp (JSON token→identity map); the `cron` entry is what scheduled runs present
|
|
88
|
+
SWITCHBOARD_INGRESS_TOKENS?: string; // enables HTTP /ingress + MCP /mcp (JSON token→identity map); the `cron` entry is what scheduled runs present
|
|
89
|
+
SWITCHBOARD_RESTART_DEPLOYER?: string; // deployment-profile grant: the token-map subject allowed to POST /admin/restart; Worker-only, never runtime config
|
|
93
90
|
BRAVE_SEARCH_API_KEY?: string; // web_search backend (Brave); web_fetch works without it
|
|
94
91
|
GITHUB_WEBHOOK_SECRET?: string; // check-run intake: signs POST /webhooks/github; absent, the intake answers 503 disabled
|
|
95
92
|
CF_ANALYTICS_TOKEN?: string; // costs dash: Cloudflare API token, Account Analytics:Read only
|
|
@@ -211,37 +208,6 @@ export class SwitchboardServer extends Container<Env> {
|
|
|
211
208
|
return this.restartRunning(opts);
|
|
212
209
|
}
|
|
213
210
|
|
|
214
|
-
/**
|
|
215
|
-
* `deploy restart`, authorized: the Worker knows WHO the bearer is (the token
|
|
216
|
-
* map); WHETHER that identity may restart is the bot's config (`grants` —
|
|
217
|
-
* authorization.md item 9), which only the container holds. So ask it —
|
|
218
|
-
* `POST /admin/restart/authorize` with the authenticated subject in
|
|
219
|
-
* `RESTART_SUBJECT_HEADER` (never the bearer: the container may still hold the
|
|
220
|
-
* token map from before a rotation) — and stop only on a 200. A container that
|
|
221
|
-
* is not running is started first (the bot must answer): if the bearer is
|
|
222
|
-
* allowed, that start already put the current env live, so nothing is
|
|
223
|
-
* stopped and the outcome is `not-running`, exactly as before; if not, the
|
|
224
|
-
* refusal is relayed and the started container simply keeps serving.
|
|
225
|
-
*/
|
|
226
|
-
async restartAuthorized(
|
|
227
|
-
subject: string,
|
|
228
|
-
opts: { force: boolean },
|
|
229
|
-
): Promise<{ auth: RestartAuth; outcome?: RestartOutcome }> {
|
|
230
|
-
const wasRunning = this.ctx.container?.running === true;
|
|
231
|
-
await this.startBot();
|
|
232
|
-
const answer = await this.containerFetch(
|
|
233
|
-
new Request(`${INTERNAL}${RESTART_AUTHORIZE_PATH}`, {
|
|
234
|
-
method: "POST",
|
|
235
|
-
headers: { [RESTART_SUBJECT_HEADER]: subject },
|
|
236
|
-
}),
|
|
237
|
-
this.defaultPort,
|
|
238
|
-
);
|
|
239
|
-
const auth = parseRestartAuthorization(answer.status, await answer.text().catch(() => ""));
|
|
240
|
-
if (!auth.ok) return { auth };
|
|
241
|
-
if (!wasRunning) return { auth, outcome: { kind: "not-running" } };
|
|
242
|
-
return { auth, outcome: await this.restartRunning(opts) };
|
|
243
|
-
}
|
|
244
|
-
|
|
245
211
|
/** The stop itself, for a running container: the preflight's refusal rules over `/healthz`, then SIGTERM. */
|
|
246
212
|
private async restartRunning(opts: { force: boolean }): Promise<RestartOutcome> {
|
|
247
213
|
const health = await this.containerFetch(new Request(`${INTERNAL}/healthz`), this.defaultPort);
|
|
@@ -265,12 +231,12 @@ export class SwitchboardServer extends Container<Env> {
|
|
|
265
231
|
}
|
|
266
232
|
}
|
|
267
233
|
|
|
268
|
-
/** `POST /admin/restart` — the operator surface behind `deploy restart
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
234
|
+
/** `POST /admin/restart` — the operator surface behind `deploy restart`.
|
|
235
|
+
* The Worker authenticates the bearer against its token map, then authorizes
|
|
236
|
+
* that subject against SWITCHBOARD_RESTART_DEPLOYER, rendered from the
|
|
237
|
+
* deployment profile (or set directly as a Worker var). Runtime config is
|
|
238
|
+
* never consulted: this route must remain usable when that document is what
|
|
239
|
+
* the restart is repairing.
|
|
274
240
|
* Body `{ "force": true }` bypasses the fail-closed refusals (no JSON body,
|
|
275
241
|
* impossible `inFlight`); runs in flight or a drain warn and never refuse. */
|
|
276
242
|
async function handleAdminRestart(request: Request, env: Env): Promise<Response> {
|
|
@@ -283,14 +249,16 @@ async function handleAdminRestart(request: Request, env: Env): Promise<Response>
|
|
|
283
249
|
console.warn(`[restart] ${authn.status} — ${authn.reason}`);
|
|
284
250
|
return json(authn.status, { ok: false, error: authn.reason });
|
|
285
251
|
}
|
|
252
|
+
const auth = authorizeRestartDeployer(authn.identity.subject, env.SWITCHBOARD_RESTART_DEPLOYER);
|
|
253
|
+
if (!auth.ok) {
|
|
254
|
+
console.warn(`[restart] ${auth.status} — ${auth.reason}`);
|
|
255
|
+
return json(auth.status, { ok: false, error: auth.reason });
|
|
256
|
+
}
|
|
286
257
|
const parsed = parseRestartRequest(await request.text().catch(() => ""));
|
|
287
258
|
if (!parsed.ok) return json(400, { ok: false, error: parsed.reason });
|
|
288
|
-
let
|
|
289
|
-
let outcome: RestartOutcome | undefined;
|
|
259
|
+
let outcome: RestartOutcome;
|
|
290
260
|
try {
|
|
291
|
-
|
|
292
|
-
force: parsed.force,
|
|
293
|
-
}));
|
|
261
|
+
outcome = await getContainer(env.SWITCHBOARD, INSTANCE).restart({ force: parsed.force });
|
|
294
262
|
} catch (err) {
|
|
295
263
|
// The container's /healthz probe or the DO call threw (container mid-transition,
|
|
296
264
|
// port not answering): fail closed in the route's own JSON shape so the CLI reads
|
|
@@ -299,11 +267,6 @@ async function handleAdminRestart(request: Request, env: Env): Promise<Response>
|
|
|
299
267
|
console.error(`[restart] ${authn.identity.subject} → error before stop: ${reason}`);
|
|
300
268
|
return json(500, { ok: false, error: `restart failed before stopping anything: ${reason}` });
|
|
301
269
|
}
|
|
302
|
-
if (!auth.ok || outcome === undefined) {
|
|
303
|
-
const refusal = auth.ok ? { status: 503 as const, reason: "restart disabled: no outcome" } : auth;
|
|
304
|
-
console.warn(`[restart] ${refusal.status} — ${refusal.reason}`);
|
|
305
|
-
return json(refusal.status, { ok: false, error: refusal.reason });
|
|
306
|
-
}
|
|
307
270
|
console.log(
|
|
308
271
|
`[restart] ${auth.subject} → ${outcome.kind}${outcome.kind === "refused" ? `: ${outcome.problems.join("; ")}` : ""}`,
|
|
309
272
|
);
|
|
@@ -494,9 +457,7 @@ export default {
|
|
|
494
457
|
// caller sent is stripped, and what the container sees carries this
|
|
495
458
|
// Worker's own root. A static asset or the live view's SSE stream gets no
|
|
496
459
|
// root; a refusal's line is dropped by the sink's filter.
|
|
497
|
-
|
|
498
|
-
// authorize call below); a caller cannot be allowed to speak it.
|
|
499
|
-
const inbound = stripRestartSubject(stripTraceContext(request));
|
|
460
|
+
const inbound = stripTraceContext(request);
|
|
500
461
|
const route = shimRoute(pathname);
|
|
501
462
|
if (route === undefined) return withLength(await getContainer(env.SWITCHBOARD, INSTANCE).fetch(inbound));
|
|
502
463
|
const root = tracer.start("bot-shim.fetch", { sinks: traceSinks, attrs: { route } });
|
|
@@ -31,6 +31,12 @@
|
|
|
31
31
|
"ACCESS_TEAM_DOMAIN": "{{access.teamDomain}}",
|
|
32
32
|
"ACCESS_AUD": "{{access.aud}}",
|
|
33
33
|
// {{/if}}
|
|
34
|
+
// The restart route's deployment-side grant. The Worker authenticates a
|
|
35
|
+
// bearer through SWITCHBOARD_INGRESS_TOKENS and compares its subject here;
|
|
36
|
+
// it never asks the runtime config it may be restarting to repair.
|
|
37
|
+
// {{#if restart}}
|
|
38
|
+
"SWITCHBOARD_RESTART_DEPLOYER": "{{restart.deployer}}",
|
|
39
|
+
// {{/if}}
|
|
34
40
|
// The state Worker (deploy/cloudflare-memory/): where worker.ts records each
|
|
35
41
|
// scheduled firing for the /runs Scheduled panel, with MEMORY_TOKEN.
|
|
36
42
|
// A profile without a memory Worker renders no var: firings go unrecorded.
|
|
@@ -109,13 +109,16 @@ import {
|
|
|
109
109
|
isCoordinatorInstance,
|
|
110
110
|
isCoordinatorUnit,
|
|
111
111
|
isThreadEvent,
|
|
112
|
+
isUnitWakeAnswer,
|
|
112
113
|
sendChildSignal,
|
|
113
114
|
sendRunFinished,
|
|
115
|
+
STEP_NAME_PATTERN,
|
|
114
116
|
UNIT_PATTERN,
|
|
115
117
|
type CoordinatorInstance,
|
|
116
118
|
type CoordinatorUnit,
|
|
117
119
|
type RunFinishedSend,
|
|
118
120
|
type ThreadEvent,
|
|
121
|
+
type UnitWakeAnswer,
|
|
119
122
|
} from "../../src/core/coordinator/contract.ts";
|
|
120
123
|
import {
|
|
121
124
|
GEN_PATTERN,
|
|
@@ -1848,10 +1851,27 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
1848
1851
|
queuedAt: r.queued_at,
|
|
1849
1852
|
state: r.state as PlaneQueueRow["state"],
|
|
1850
1853
|
}));
|
|
1851
|
-
const
|
|
1852
|
-
.exec<{
|
|
1853
|
-
|
|
1854
|
-
|
|
1854
|
+
const liveRows = this.sql
|
|
1855
|
+
.exec<{ run_id: string; thread_key: string; meta_json: string }>(
|
|
1856
|
+
`SELECT run_id, thread_key, meta_json FROM live_runs WHERE run_id IS NOT ?`,
|
|
1857
|
+
excludeRunId ?? null,
|
|
1858
|
+
)
|
|
1859
|
+
.toArray();
|
|
1860
|
+
const liveThreads = liveRows.map((r) => r.thread_key);
|
|
1861
|
+
const liveRuns = Object.fromEntries(
|
|
1862
|
+
liveRows.flatMap((r) => {
|
|
1863
|
+
const meta = JSON.parse(r.meta_json) as Record<string, unknown>;
|
|
1864
|
+
return typeof meta.channelId === "string"
|
|
1865
|
+
? [[r.run_id, { channelId: meta.channelId, threadKey: r.thread_key }] as const]
|
|
1866
|
+
: [];
|
|
1867
|
+
}),
|
|
1868
|
+
);
|
|
1869
|
+
const inboxSeqs = Object.fromEntries(
|
|
1870
|
+
this.sql
|
|
1871
|
+
.exec<{ run_id: string; seq: number }>(`SELECT run_id, MAX(seq) AS seq FROM run_inbox GROUP BY run_id`)
|
|
1872
|
+
.toArray()
|
|
1873
|
+
.map((r) => [r.run_id, r.seq]),
|
|
1874
|
+
);
|
|
1855
1875
|
const levels = this.sql
|
|
1856
1876
|
.exec<{ resident: string; name: string; side: string; reported_at: number; generation: string }>(
|
|
1857
1877
|
`SELECT * FROM plane_levels`,
|
|
@@ -1864,7 +1884,7 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
1864
1884
|
reportedAt: r.reported_at,
|
|
1865
1885
|
generation: r.generation,
|
|
1866
1886
|
}));
|
|
1867
|
-
return { queue, liveThreads, reservations, openWindows, levels };
|
|
1887
|
+
return { queue, liveThreads, liveRuns, inboxSeqs, reservations, openWindows, levels };
|
|
1868
1888
|
}
|
|
1869
1889
|
|
|
1870
1890
|
/** The decider's writes, applied inside the same `transactionSync` that read
|
|
@@ -1971,21 +1991,19 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
1971
1991
|
this.sql.exec<{ n: number }>(`SELECT COUNT(*) AS n FROM plane_effects WHERE acked_at IS NULL`).toArray()[0]
|
|
1972
1992
|
?.n ?? 0,
|
|
1973
1993
|
);
|
|
1994
|
+
const effectRunId = w.effect.kind === "admit" || w.effect.kind === "steer" ? w.effect.runId : undefined;
|
|
1974
1995
|
const forRun =
|
|
1975
|
-
|
|
1976
|
-
?
|
|
1996
|
+
effectRunId === undefined
|
|
1997
|
+
? 0
|
|
1998
|
+
: Number(
|
|
1977
1999
|
this.sql
|
|
1978
|
-
.exec<{
|
|
1979
|
-
n
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
// run ids, and a LIKE would read `%`/`_` in one as wildcards.
|
|
1983
|
-
`SELECT COUNT(*) AS n FROM plane_effects WHERE acked_at IS NULL AND id = 'admit:' || ?`,
|
|
1984
|
-
w.effect.runId,
|
|
2000
|
+
.exec<{ n: number }>(
|
|
2001
|
+
`SELECT COUNT(*) AS n FROM plane_effects
|
|
2002
|
+
WHERE acked_at IS NULL AND json_extract(body_json, '$.runId') = ?`,
|
|
2003
|
+
effectRunId,
|
|
1985
2004
|
)
|
|
1986
2005
|
.toArray()[0]?.n ?? 0,
|
|
1987
|
-
)
|
|
1988
|
-
: 0;
|
|
2006
|
+
);
|
|
1989
2007
|
const refusal = effectCapRefusal({ total, forRun }, w.effect);
|
|
1990
2008
|
if (refusal !== undefined) throw new Error(refusal);
|
|
1991
2009
|
// A re-offer lands after an ack for any kind — a probe re-probes after
|
|
@@ -2330,6 +2348,13 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2330
2348
|
// Exact id matching (`admit:<runId>`), never LIKE: a bot-minted run id
|
|
2331
2349
|
// can carry `%` or `_`, which a pattern would read as wildcards.
|
|
2332
2350
|
this.sql.exec(`DELETE FROM plane_effects WHERE acked_at IS NULL AND id = 'admit:' || ?`, runId);
|
|
2351
|
+
this.sql.exec(
|
|
2352
|
+
`DELETE FROM plane_effects
|
|
2353
|
+
WHERE acked_at IS NULL
|
|
2354
|
+
AND json_extract(body_json, '$.kind') = 'steer'
|
|
2355
|
+
AND json_extract(body_json, '$.runId') = ?`,
|
|
2356
|
+
runId,
|
|
2357
|
+
);
|
|
2333
2358
|
// The run's steer dedupe rows and any park go with it (record 0064):
|
|
2334
2359
|
// a sealed run holds no turn and reads no steer.
|
|
2335
2360
|
this.sql.exec(`DELETE FROM plane_reservations WHERE kind IN ('steer', 'park') AND run_id = ?`, runId);
|
|
@@ -2434,12 +2459,54 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2434
2459
|
this.sql.exec(`INSERT OR REPLACE INTO meta (key, value) VALUES ('plane_disagreements', ?)`, JSON.stringify(counts));
|
|
2435
2460
|
}
|
|
2436
2461
|
|
|
2462
|
+
/** Fence a pushed steer to the owning live generation before its local
|
|
2463
|
+
* registry is touched. The effect check, owner check and lease renewal are
|
|
2464
|
+
* one transaction: an expired owner may renew before reclaim, but reclaim
|
|
2465
|
+
* can never cross the synchronous local delivery that follows a success. */
|
|
2466
|
+
async planeFenceSteer(
|
|
2467
|
+
id: string,
|
|
2468
|
+
runId: string,
|
|
2469
|
+
gen: string,
|
|
2470
|
+
leaseMs: number,
|
|
2471
|
+
now: number,
|
|
2472
|
+
): Promise<{ accepted: boolean }> {
|
|
2473
|
+
let accepted = false;
|
|
2474
|
+
this.ctx.storage.transactionSync(() => {
|
|
2475
|
+
const offered = this.sql
|
|
2476
|
+
.exec<{ body_json: string }>(`SELECT body_json FROM plane_effects WHERE id = ? AND acked_at IS NULL`, id)
|
|
2477
|
+
.toArray()[0];
|
|
2478
|
+
if (!offered) return;
|
|
2479
|
+
const effect = JSON.parse(offered.body_json) as PlaneEffect;
|
|
2480
|
+
if (effect.kind !== "steer" || effect.runId !== runId) return;
|
|
2481
|
+
const live = this.liveRow(runId);
|
|
2482
|
+
if (!live || live.ownerGen !== gen || live.phase !== "live") return;
|
|
2483
|
+
this.sql.exec(`UPDATE live_runs SET lease_until = ? WHERE run_id = ?`, now + leaseMs, runId);
|
|
2484
|
+
accepted = true;
|
|
2485
|
+
});
|
|
2486
|
+
if (accepted) await this.ensurePlaneAlarm(now);
|
|
2487
|
+
return { accepted };
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2437
2490
|
/** An effect's acknowledgement by id (orchestration-plane item 7): `done` and `skipped` close it,
|
|
2438
|
-
* `deferred` leaves it offered for the next answer.
|
|
2439
|
-
*
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2491
|
+
* `deferred` leaves it offered for the next answer. A steer is special: the
|
|
2492
|
+
* acknowledging generation must still own its live row. A stale process
|
|
2493
|
+
* may retain registry state during reclaim overlap, but it cannot close the
|
|
2494
|
+
* durable offer. An unknown id is a no-op. */
|
|
2495
|
+
planeAck(id: string, outcome: PlaneAckOutcome, now: number, owner?: { runId: string; gen: string }): { ok: true } {
|
|
2496
|
+
if (outcome === "deferred") return { ok: true };
|
|
2497
|
+
const offered = this.sql
|
|
2498
|
+
.exec<{ body_json: string }>(`SELECT body_json FROM plane_effects WHERE id = ? AND acked_at IS NULL`, id)
|
|
2499
|
+
.toArray()[0];
|
|
2500
|
+
if (!offered) return { ok: true };
|
|
2501
|
+
const effect = JSON.parse(offered.body_json) as PlaneEffect;
|
|
2502
|
+
if (effect.kind === "steer") {
|
|
2503
|
+
if (!owner || owner.runId !== effect.runId) return { ok: true };
|
|
2504
|
+
const live = this.sql
|
|
2505
|
+
.exec<{ owner_gen: string }>(`SELECT owner_gen FROM live_runs WHERE run_id = ?`, owner.runId)
|
|
2506
|
+
.toArray()[0];
|
|
2507
|
+
if (live?.owner_gen !== owner.gen) return { ok: true };
|
|
2508
|
+
}
|
|
2509
|
+
this.sql.exec(`UPDATE plane_effects SET acked_at = ? WHERE id = ? AND acked_at IS NULL`, now, id);
|
|
2443
2510
|
return { ok: true };
|
|
2444
2511
|
}
|
|
2445
2512
|
|
|
@@ -2543,7 +2610,8 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2543
2610
|
// ---- the thread events of a unit-owned thread (record 0051's reply-as-event rule) --------------
|
|
2544
2611
|
|
|
2545
2612
|
/** The next sequence assigned in one transaction, the per-event cap applied
|
|
2546
|
-
* (attachments dropped whole, the row saying how many).
|
|
2613
|
+
* (attachments dropped whole, the row saying how many). A stable event id
|
|
2614
|
+
* already on the unit returns its first sequence without another row. */
|
|
2547
2615
|
async appendUnitEvent(
|
|
2548
2616
|
instanceId: string,
|
|
2549
2617
|
unit: string,
|
|
@@ -2551,6 +2619,20 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2551
2619
|
): Promise<{ ok: true; seq: number }> {
|
|
2552
2620
|
let seq = 1;
|
|
2553
2621
|
this.ctx.storage.transactionSync(() => {
|
|
2622
|
+
if (event.id !== undefined) {
|
|
2623
|
+
const existing = this.sql
|
|
2624
|
+
.exec<{ seq: number; json: string }>(
|
|
2625
|
+
`SELECT seq, json FROM coordinator_unit_events WHERE instance_id = ? AND unit = ? ORDER BY seq`,
|
|
2626
|
+
instanceId,
|
|
2627
|
+
unit,
|
|
2628
|
+
)
|
|
2629
|
+
.toArray()
|
|
2630
|
+
.find((row) => (JSON.parse(row.json) as ThreadEvent).id === event.id);
|
|
2631
|
+
if (existing !== undefined) {
|
|
2632
|
+
seq = existing.seq;
|
|
2633
|
+
return;
|
|
2634
|
+
}
|
|
2635
|
+
}
|
|
2554
2636
|
const max = this.sql
|
|
2555
2637
|
.exec<{
|
|
2556
2638
|
m: number | null;
|
|
@@ -2602,6 +2684,38 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2602
2684
|
return { ok: true };
|
|
2603
2685
|
}
|
|
2604
2686
|
|
|
2687
|
+
/** One transaction is the wake's decision: the indexed answer on the unit
|
|
2688
|
+
* row and every event it consumed become visible together. */
|
|
2689
|
+
async answerUnitWake(
|
|
2690
|
+
unit: CoordinatorUnit,
|
|
2691
|
+
waitId: string,
|
|
2692
|
+
answer: UnitWakeAnswer,
|
|
2693
|
+
seqs: number[],
|
|
2694
|
+
by: string,
|
|
2695
|
+
now: number,
|
|
2696
|
+
): Promise<{ ok: true }> {
|
|
2697
|
+
this.ctx.storage.transactionSync(() => {
|
|
2698
|
+
const updated = { ...unit, wakes: { ...(unit.wakes ?? {}), [waitId]: answer } };
|
|
2699
|
+
this.sql.exec(
|
|
2700
|
+
`INSERT INTO coordinator_units (instance_id, unit, json, updated_at) VALUES (?, ?, ?, ?)
|
|
2701
|
+
ON CONFLICT(instance_id, unit) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at`,
|
|
2702
|
+
unit.instanceId,
|
|
2703
|
+
unit.unit,
|
|
2704
|
+
JSON.stringify(updated),
|
|
2705
|
+
now,
|
|
2706
|
+
);
|
|
2707
|
+
for (const seq of seqs)
|
|
2708
|
+
this.sql.exec(
|
|
2709
|
+
`UPDATE coordinator_unit_events SET consumed_by = ? WHERE instance_id = ? AND unit = ? AND seq = ? AND consumed_by IS NULL`,
|
|
2710
|
+
by,
|
|
2711
|
+
unit.instanceId,
|
|
2712
|
+
unit.unit,
|
|
2713
|
+
seq,
|
|
2714
|
+
);
|
|
2715
|
+
});
|
|
2716
|
+
return { ok: true };
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2605
2719
|
// ---- the live-run ledger (run-history items 28–34) --------------------------
|
|
2606
2720
|
|
|
2607
2721
|
private liveRow(runId: string): LiveRunRow | undefined {
|
|
@@ -4820,6 +4934,7 @@ const LEDGER_ROUTES = new Set([
|
|
|
4820
4934
|
"/runs/coordinator/events/append",
|
|
4821
4935
|
"/runs/coordinator/events/list",
|
|
4822
4936
|
"/runs/coordinator/events/mark-consumed",
|
|
4937
|
+
"/runs/coordinator/wake",
|
|
4823
4938
|
"/runs/claim",
|
|
4824
4939
|
"/runs/heartbeat",
|
|
4825
4940
|
"/runs/append",
|
|
@@ -4854,12 +4969,13 @@ const LEDGER_ROUTES = new Set([
|
|
|
4854
4969
|
"/runs/session/notepad/write",
|
|
4855
4970
|
]);
|
|
4856
4971
|
|
|
4857
|
-
/** The plane's routes (record 0064; orchestration-plane items 7 and 8):
|
|
4858
|
-
* effect
|
|
4859
|
-
* key, like every `/runs/*` route. */
|
|
4972
|
+
/** The plane's routes (record 0064; orchestration-plane items 7 and 8):
|
|
4973
|
+
* outcomes, effect delivery fencing and acknowledgements land on the ledger
|
|
4974
|
+
* object of the given store key, like every `/runs/*` route. */
|
|
4860
4975
|
const PLANE_ROUTES = new Set([
|
|
4861
4976
|
"/plane/outcome",
|
|
4862
4977
|
"/plane/ack",
|
|
4978
|
+
"/plane/steer/fence",
|
|
4863
4979
|
"/plane/admit",
|
|
4864
4980
|
"/plane/withdraw",
|
|
4865
4981
|
"/plane/deploy",
|
|
@@ -4915,11 +5031,32 @@ async function handlePlane(pathname: string, body: unknown, env: Env): Promise<R
|
|
|
4915
5031
|
);
|
|
4916
5032
|
return json(r);
|
|
4917
5033
|
}
|
|
5034
|
+
if (pathname === "/plane/steer/fence") {
|
|
5035
|
+
if (typeof b.id !== "string" || b.id.length === 0) return json({ error: "id must be a non-empty string" }, 400);
|
|
5036
|
+
const runId = parseRunId(b.runId);
|
|
5037
|
+
if (!runId.ok) return json({ error: runId.error }, 400);
|
|
5038
|
+
const g = gen(b.gen);
|
|
5039
|
+
if (!g.ok) return json({ error: g.error }, 400);
|
|
5040
|
+
const lease = parseLeaseMs(b.leaseMs);
|
|
5041
|
+
if (!lease.ok) return json({ error: lease.error }, 400);
|
|
5042
|
+
return json(await stub.planeFenceSteer(b.id, runId.value, g.value, lease.value, now));
|
|
5043
|
+
}
|
|
4918
5044
|
if (pathname === "/plane/ack") {
|
|
4919
5045
|
if (typeof b.id !== "string" || b.id.length === 0) return json({ error: "id must be a non-empty string" }, 400);
|
|
4920
5046
|
if (typeof b.outcome !== "string" || !PLANE_ACK_OUTCOMES.has(b.outcome))
|
|
4921
5047
|
return json({ error: "outcome must be done, skipped or deferred" }, 400);
|
|
4922
|
-
|
|
5048
|
+
let owner: { runId: string; gen: string } | undefined;
|
|
5049
|
+
if (b.owner !== undefined) {
|
|
5050
|
+
if (typeof b.owner !== "object" || b.owner === null || Array.isArray(b.owner))
|
|
5051
|
+
return json({ error: "owner must name a runId and generation" }, 400);
|
|
5052
|
+
const rawOwner = b.owner as Record<string, unknown>;
|
|
5053
|
+
const runId = parseRunId(rawOwner.runId);
|
|
5054
|
+
if (!runId.ok) return json({ error: runId.error }, 400);
|
|
5055
|
+
if (typeof rawOwner.gen !== "string" || rawOwner.gen.length === 0)
|
|
5056
|
+
return json({ error: "owner generation must be a non-empty string" }, 400);
|
|
5057
|
+
owner = { runId: runId.value, gen: rawOwner.gen };
|
|
5058
|
+
}
|
|
5059
|
+
return json(await stub.planeAck(b.id, b.outcome as PlaneAckOutcome, now, owner));
|
|
4923
5060
|
}
|
|
4924
5061
|
if (pathname === "/plane/admit") {
|
|
4925
5062
|
// The admission-stage ask (record 0064, "The queue"): the thread key, the
|
|
@@ -5457,8 +5594,22 @@ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<
|
|
|
5457
5594
|
return json({ error: "instanceId must be a Workflow instance id" }, 400);
|
|
5458
5595
|
return json({ units: await stub.listUnits(b.instanceId) });
|
|
5459
5596
|
}
|
|
5597
|
+
if (pathname === "/runs/coordinator/wake") {
|
|
5598
|
+
if (!isCoordinatorUnit(b.unit)) return json({ error: "unit must be a coordinator unit row" }, 400);
|
|
5599
|
+
if (typeof b.waitId !== "string" || !STEP_NAME_PATTERN.test(b.waitId))
|
|
5600
|
+
return json({ error: "waitId must be a step name" }, 400);
|
|
5601
|
+
if (!isUnitWakeAnswer(b.answer)) return json({ error: "answer must be a unit wake answer" }, 400);
|
|
5602
|
+
if (!Array.isArray(b.seqs) || !b.seqs.every((s) => typeof s === "number" && Number.isInteger(s) && s >= 1))
|
|
5603
|
+
return json({ error: "seqs must be an array of sequence numbers" }, 400);
|
|
5604
|
+
if (typeof b.by !== "string" || b.by.length === 0 || b.by.length > 200)
|
|
5605
|
+
return json({ error: "by must name the consumer" }, 400);
|
|
5606
|
+
const r = await stub.answerUnitWake(b.unit, b.waitId, b.answer, b.seqs as number[], b.by, now);
|
|
5607
|
+
console.log(`[runs/coordinator/wake] ${key.value} ${b.unit.instanceId}:${b.unit.unit} ${b.waitId}`);
|
|
5608
|
+
return json(r);
|
|
5609
|
+
}
|
|
5460
5610
|
// The thread events of a unit-owned thread (record 0051's reply-as-event rule): append assigns
|
|
5461
|
-
// the sequence
|
|
5611
|
+
// the sequence (or returns the row with the same stable id), list filters
|
|
5612
|
+
// unconsumed, mark-consumed is idempotent.
|
|
5462
5613
|
if (pathname.startsWith("/runs/coordinator/events/")) {
|
|
5463
5614
|
if (typeof b.instanceId !== "string" || !INSTANCE_ID_PATTERN.test(b.instanceId))
|
|
5464
5615
|
return json({ error: "instanceId must be a Workflow instance id" }, 400);
|