@coreplane/switchboard 1.248.0 → 1.249.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 +44 -18
- package/dist/assets/deploy/cloudflare/preflight.mjs +21 -19
- package/dist/assets/deploy/cloudflare-memory/worker.ts +31 -0
- package/dist/assets/deploy/cloudflare-resident/drain.ts +109 -0
- package/dist/assets/deploy/cloudflare-resident/refresh.ts +5 -3
- package/dist/assets/deploy/cloudflare-resident/threadErr.ts +32 -3
- package/dist/assets/deploy/cloudflare-resident/worker.ts +235 -13
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +56 -9
- package/dist/assets/deploy/secrets.manifest.json +6 -0
- 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 +4 -4
- package/dist/assets/src/core/budgets.ts +24 -0
- package/dist/assets/src/core/coordinator/contract.ts +4 -3
- package/dist/assets/src/core/coordinator/driver.ts +40 -7
- package/dist/assets/src/core/modelCard.ts +348 -0
- package/dist/assets/src/core/modelPricing.ts +14 -5
- package/dist/assets/src/core/modelRegistry.ts +51 -0
- package/dist/assets/src/core/provider.ts +103 -0
- package/dist/assets/src/core/refusal.ts +181 -0
- package/dist/assets/src/core/runEvents.ts +43 -0
- package/dist/assets/src/core/ship/coordinator.ts +80 -12
- package/dist/assets/src/core/ship/handoff.ts +54 -19
- package/dist/assets/src/core/trace/workerTrace.ts +9 -3
- package/dist/assets/src/core/types.ts +327 -0
- package/dist/assets/src/deploy/liveGate.ts +35 -0
- package/dist/assets/src/deploy/restart.ts +12 -11
- package/dist/assets/src/execution/residentRefresh.ts +26 -1
- package/dist/assets/src/execution/sandboxErrors.ts +122 -4
- package/dist/assets/web/dist/.vite/manifest.json +30 -30
- package/dist/assets/web/dist/assets/DeliveryPage-DF4aQypG.js +1 -0
- package/dist/assets/web/dist/assets/{HomePage-DYxC0izY.js → HomePage-BpQRky8B.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentDetailPage-DLIpWYOc.js → ResidentDetailPage-BIUXyz6K.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-6LipuDjR.js → ResidentsIndexPage-BZymgSAb.js} +1 -1
- package/dist/assets/web/dist/assets/{RunFoldRow-V-iSy64e.js → RunFoldRow-3m4CPRI4.js} +1 -1
- package/dist/assets/web/dist/assets/{RunRoutePage-DUalB1u2.js → RunRoutePage-bgkjkA0p.js} +3 -3
- package/dist/assets/web/dist/assets/{RunsIndexPage-B9Ba1KdD.js → RunsIndexPage-8S944AzB.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-KdjLtD_7.js → ScheduledPage-8bBtG9y3.js} +1 -1
- package/dist/assets/web/dist/assets/{SettingsPage-IT5l_NaL.js → SettingsPage-DQeNvfaV.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-DBHAl4Il.js → StatusDot-BPE5syBa.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-_LEjptLV.js → Tooltip-DkoeZfTs.js} +1 -1
- package/dist/assets/web/dist/assets/UnitRoutePage-BUzw--Ii.js +1 -0
- package/dist/assets/web/dist/assets/{dist-BcYPGOBL.js → dist-D11y9ZJ4.js} +1 -1
- package/dist/assets/web/dist/assets/{main-DUfSE0dj.js → main-B6LcgNM6.js} +2 -2
- package/dist/cli.js +2475 -1021
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/DeliveryPage-NP4g6bQd.js +0 -1
- package/dist/assets/web/dist/assets/UnitRoutePage-DPBsvGPR.js +0 -1
|
@@ -4,7 +4,9 @@ import { redactSecrets } from "../redact.js";
|
|
|
4
4
|
// docs/reference/specs/agent-ship.md item 14, agent-coding.md item 9): what a
|
|
5
5
|
// coding child hands back beside its pull-request description when it ran for
|
|
6
6
|
// a plan unit — where it departed from the unit and why, what it found and did
|
|
7
|
-
// not do,
|
|
7
|
+
// not do, which of the unit's criteria it could not prove, and what of the
|
|
8
|
+
// unit was already on the base before it began (`landed`, the fact the ship
|
|
9
|
+
// machine ends a unit `already_landed` on, agent-ship.md item 12). It is data, not
|
|
8
10
|
// prose in a final message: the child submits it through `submit_handoff` (the
|
|
9
11
|
// same tool path as the description), the run record carries it, and the
|
|
10
12
|
// parent posts it to the unit's board issue, where a person decides each row's
|
|
@@ -38,10 +40,24 @@ export interface HandoffUnproven {
|
|
|
38
40
|
why: string;
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
/** A part of the unit's scope that was already on the base when the run
|
|
44
|
+
* began — the fact the ship machine reads at round 0 (agent-ship.md item 12):
|
|
45
|
+
* a handoff naming where the scope landed, beside a branch with no commits
|
|
46
|
+
* over the base, ends the unit `already_landed` instead of aborting it. */
|
|
47
|
+
export interface HandoffLanded {
|
|
48
|
+
/** What of the unit was already there. */
|
|
49
|
+
what: string;
|
|
50
|
+
/** Where it landed — the pull request or commit that carries it. */
|
|
51
|
+
where: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
export interface Handoff {
|
|
42
55
|
deviations: HandoffDeviation[];
|
|
43
56
|
followUps: HandoffFollowUp[];
|
|
44
57
|
unproven: HandoffUnproven[];
|
|
58
|
+
/** Absent on a handoff that named none and on a record written before the
|
|
59
|
+
* list existed; every reader treats absent as empty. */
|
|
60
|
+
landed?: HandoffLanded[];
|
|
45
61
|
}
|
|
46
62
|
|
|
47
63
|
/** The most entries one list may carry: a handoff is a summary for a person,
|
|
@@ -58,25 +74,33 @@ const FIELDS: Readonly<Record<ListKey, readonly string[]>> = {
|
|
|
58
74
|
deviations: ["from", "to", "why"],
|
|
59
75
|
followUps: ["what", "where"],
|
|
60
76
|
unproven: ["criterion", "why"],
|
|
77
|
+
landed: ["what", "where"],
|
|
61
78
|
};
|
|
62
|
-
const LISTS: readonly ListKey[] = ["deviations", "followUps", "unproven"];
|
|
79
|
+
const LISTS: readonly ListKey[] = ["deviations", "followUps", "unproven", "landed"];
|
|
80
|
+
/** The lists every handoff carries; `landed` is optional (its type says so). */
|
|
81
|
+
const REQUIRED: ReadonlySet<ListKey> = new Set<ListKey>(["deviations", "followUps", "unproven"]);
|
|
63
82
|
|
|
64
|
-
/** A handoff under construction: each list as entries keyed by `FIELDS
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* table above standing in for
|
|
68
|
-
|
|
83
|
+
/** A handoff under construction: each list as entries keyed by `FIELDS`, the
|
|
84
|
+
* optional one present only when given. It IS a `Handoff` once every entry
|
|
85
|
+
* carries its list's fields — which the two builders below guarantee — so the
|
|
86
|
+
* one conversion at their boundary is the table above standing in for four
|
|
87
|
+
* interface declarations. */
|
|
88
|
+
type FieldLists = Record<Exclude<ListKey, "landed">, Record<string, string>[]> & {
|
|
89
|
+
landed?: Record<string, string>[];
|
|
90
|
+
};
|
|
69
91
|
|
|
70
92
|
const emptyLists = (): FieldLists => ({ deviations: [], followUps: [], unproven: [] });
|
|
71
93
|
const asHandoff = (lists: FieldLists): Handoff => lists as unknown as Handoff;
|
|
72
94
|
const asLists = (h: Handoff): FieldLists => h as unknown as FieldLists;
|
|
95
|
+
/** A list's entries, an absent optional list read as empty. */
|
|
96
|
+
const listOf = (lists: FieldLists, key: ListKey): Record<string, string>[] => lists[key] ?? [];
|
|
73
97
|
|
|
74
98
|
export function emptyHandoff(): Handoff {
|
|
75
99
|
return asHandoff(emptyLists());
|
|
76
100
|
}
|
|
77
101
|
|
|
78
102
|
export function isEmptyHandoff(h: Handoff): boolean {
|
|
79
|
-
return LISTS.every((k) => h
|
|
103
|
+
return LISTS.every((k) => listOf(asLists(h), k).length === 0);
|
|
80
104
|
}
|
|
81
105
|
|
|
82
106
|
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
@@ -90,6 +114,7 @@ export function isHandoffShape(v: unknown): v is Handoff {
|
|
|
90
114
|
if (!isRecord(v)) return false;
|
|
91
115
|
return LISTS.every((key) => {
|
|
92
116
|
const list = v[key];
|
|
117
|
+
if (list === undefined) return !REQUIRED.has(key);
|
|
93
118
|
return (
|
|
94
119
|
Array.isArray(list) &&
|
|
95
120
|
list.every((entry) => isRecord(entry) && FIELDS[key].every((f) => typeof entry[f] === "string"))
|
|
@@ -105,18 +130,21 @@ const fieldList = (key: ListKey): string => {
|
|
|
105
130
|
};
|
|
106
131
|
|
|
107
132
|
/** Validate untrusted input (a tool call) into a Handoff: the three lists
|
|
108
|
-
* present (each may be empty)
|
|
109
|
-
* every field a non-empty string of at most
|
|
110
|
-
* trimmed; unknown keys are dropped. A refusal
|
|
111
|
-
* never a throw, so the model can fix the object
|
|
133
|
+
* present (each may be empty) and `landed` when given, at most
|
|
134
|
+
* `HANDOFF_MAX_ITEMS` entries each, every field a non-empty string of at most
|
|
135
|
+
* `HANDOFF_MAX_FIELD_CHARS` once trimmed; unknown keys are dropped. A refusal
|
|
136
|
+
* is a string naming the path, never a throw, so the model can fix the object
|
|
137
|
+
* and call again. */
|
|
112
138
|
export function parseHandoff(input: unknown): ParsedHandoff {
|
|
113
139
|
if (!isRecord(input))
|
|
114
140
|
return { ok: false, error: "handoff: must be an object with deviations, followUps and unproven" };
|
|
115
141
|
const out = emptyLists();
|
|
116
142
|
for (const key of LISTS) {
|
|
117
143
|
const list = input[key];
|
|
144
|
+
if (list === undefined && !REQUIRED.has(key)) continue;
|
|
118
145
|
if (!Array.isArray(list))
|
|
119
146
|
return { ok: false, error: `${key}: must be an array (empty when there is nothing to say)` };
|
|
147
|
+
out[key] = [];
|
|
120
148
|
if (list.length > HANDOFF_MAX_ITEMS) return { ok: false, error: `${key}: at most ${HANDOFF_MAX_ITEMS} entries` };
|
|
121
149
|
for (let i = 0; i < list.length; i++) {
|
|
122
150
|
const entry: unknown = list[i];
|
|
@@ -132,7 +160,7 @@ export function parseHandoff(input: unknown): ParsedHandoff {
|
|
|
132
160
|
};
|
|
133
161
|
clean[f] = value;
|
|
134
162
|
}
|
|
135
|
-
out[key]
|
|
163
|
+
out[key]!.push(clean);
|
|
136
164
|
}
|
|
137
165
|
}
|
|
138
166
|
return { ok: true, handoff: asHandoff(out) };
|
|
@@ -144,12 +172,14 @@ export function parseHandoff(input: unknown): ParsedHandoff {
|
|
|
144
172
|
* run record or a board issue. */
|
|
145
173
|
export function redactHandoff(h: Handoff, redact: (s: string) => string = redactSecrets): Handoff {
|
|
146
174
|
const out = emptyLists();
|
|
175
|
+
const lists = asLists(h);
|
|
147
176
|
for (const key of LISTS) {
|
|
148
|
-
|
|
177
|
+
if (lists[key] === undefined) continue;
|
|
178
|
+
out[key] = lists[key].map((entry) => {
|
|
149
179
|
const clean: Record<string, string> = {};
|
|
150
180
|
for (const f of FIELDS[key]) clean[f] = redact(entry[f]!);
|
|
151
|
-
|
|
152
|
-
}
|
|
181
|
+
return clean;
|
|
182
|
+
});
|
|
153
183
|
}
|
|
154
184
|
return asHandoff(out);
|
|
155
185
|
}
|
|
@@ -193,14 +223,18 @@ function entryLines(h: Handoff): Array<{ list: ListKey; bullet: string; row: str
|
|
|
193
223
|
const text = `${u.criterion} — ${u.why}`;
|
|
194
224
|
return { list: "unproven" as const, bullet: text, row: `Unproven: ${text}` };
|
|
195
225
|
}),
|
|
226
|
+
...(h.landed ?? []).map((l) => {
|
|
227
|
+
const text = `${l.what} — ${l.where}`;
|
|
228
|
+
return { list: "landed" as const, bullet: text, row: `Landed: ${text}` };
|
|
229
|
+
}),
|
|
196
230
|
];
|
|
197
231
|
}
|
|
198
232
|
|
|
199
233
|
/** Each entry as one line, list by list — a deviation as `Deviation: from → to
|
|
200
234
|
* — why`, a follow-up as `what — where`, an unproven criterion as `Unproven:
|
|
201
|
-
* criterion — why
|
|
202
|
-
* wants the handoff as plain lines
|
|
203
|
-
* an empty handoff. */
|
|
235
|
+
* criterion — why`, a landed part as `Landed: what — where` — the same words
|
|
236
|
+
* the ledger rows carry, for a reader that wants the handoff as plain lines
|
|
237
|
+
* (the thread's artifacts block). Empty for an empty handoff. */
|
|
204
238
|
export function handoffLines(h: Handoff): string[] {
|
|
205
239
|
return entryLines(h).map((e) => e.row);
|
|
206
240
|
}
|
|
@@ -227,6 +261,7 @@ const HEADINGS: Readonly<Record<ListKey, string>> = {
|
|
|
227
261
|
deviations: "### Deviations",
|
|
228
262
|
followUps: "### Follow-ups",
|
|
229
263
|
unproven: "### Unproven",
|
|
264
|
+
landed: "### Already landed",
|
|
230
265
|
};
|
|
231
266
|
|
|
232
267
|
/**
|
|
@@ -57,9 +57,15 @@ export function shimRoute(pathname: string): string | undefined {
|
|
|
57
57
|
// The model proxy's two routes (docs/reference/specs/model-proxy.md): a bounded
|
|
58
58
|
// request per model call, forwarded to the container like everything else.
|
|
59
59
|
if (pathname === "/v1/messages" || pathname === "/v1/chat/completions") return "model-proxy";
|
|
60
|
-
// The pi harness's
|
|
61
|
-
// run's extension asking for its tools, a verdict, a relayed tool's result
|
|
62
|
-
|
|
60
|
+
// The pi harness's four routes (docs/reference/specs/harness-pi.md item 7): a
|
|
61
|
+
// run's extension asking for its tools, a verdict, a relayed tool's result,
|
|
62
|
+
// how a compaction is written.
|
|
63
|
+
if (
|
|
64
|
+
pathname === "/harness/tools" ||
|
|
65
|
+
pathname === "/harness/authorize" ||
|
|
66
|
+
pathname === "/harness/tool" ||
|
|
67
|
+
pathname === "/harness/compaction"
|
|
68
|
+
)
|
|
63
69
|
return "harness";
|
|
64
70
|
if (pathname === "/docs" || pathname.startsWith("/docs/")) return "docs";
|
|
65
71
|
if (pathname === "/" || pathname === "/index.html") return "page";
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// Channel abstraction. A channel (Slack, CLI, Discord, HTTP, ...) is only a
|
|
2
|
+
// transport: it receives text from a user somewhere, hands it to the core
|
|
3
|
+
// dispatcher as an IncomingMessage, and provides a ChannelIO for the core to
|
|
4
|
+
// talk back through. Everything else — config resolution, permissions, agent
|
|
5
|
+
// selection, execution — is channel-agnostic and lives in the dispatcher.
|
|
6
|
+
|
|
7
|
+
/** An image the user attached, already downloaded and base64-encoded. */
|
|
8
|
+
export interface ImageAttachment {
|
|
9
|
+
/** e.g. "image/png" — adapters only pass types every provider accepts */
|
|
10
|
+
mediaType: string;
|
|
11
|
+
/** base64 payload, no data: URI prefix */
|
|
12
|
+
data: string;
|
|
13
|
+
name?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A non-image file the user attached, already downloaded. Two representations
|
|
18
|
+
* share this carrier, keyed by `mediaType`:
|
|
19
|
+
* - PDFs (`mediaType === "application/pdf"`) → `data` is the base64 payload,
|
|
20
|
+
* rendered as a provider-native document block where supported.
|
|
21
|
+
* - Text/code/CSV/log files (any other `mediaType`) → `data` is the decoded
|
|
22
|
+
* UTF-8 file content, inlined as a fenced text part.
|
|
23
|
+
*/
|
|
24
|
+
export interface DocumentAttachment {
|
|
25
|
+
mediaType: string;
|
|
26
|
+
data: string;
|
|
27
|
+
name?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A file left on the platform by reference (record 0033): what the store's
|
|
31
|
+
* copy needs and what the turn's line says — metadata only, never bytes. */
|
|
32
|
+
export interface StagedFile {
|
|
33
|
+
name: string;
|
|
34
|
+
size: number;
|
|
35
|
+
/** The platform's media type, as the object's content type and the line's word. */
|
|
36
|
+
type: string;
|
|
37
|
+
/** The platform's private download URL (Slack: `url_private`); the bot's Worker reads it, the bot never does. */
|
|
38
|
+
url: string;
|
|
39
|
+
/** The platform's id of the message that carried the file — the per-message segment of its key. */
|
|
40
|
+
messageId: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface IncomingMessage {
|
|
44
|
+
/**
|
|
45
|
+
* Scope key for channel-level config. Must be globally unique across
|
|
46
|
+
* platforms — adapters namespace with a platform prefix, e.g. "slack:C0123".
|
|
47
|
+
*/
|
|
48
|
+
channelId: string;
|
|
49
|
+
/** Scope key for user-level config and permissions, e.g. "slack:U0123". */
|
|
50
|
+
userId: string;
|
|
51
|
+
/**
|
|
52
|
+
* Stable key for the conversation: same thread => same key => same
|
|
53
|
+
* workspace/sandbox. e.g. "slack:C0123:1712345.6789".
|
|
54
|
+
*/
|
|
55
|
+
threadKey: string;
|
|
56
|
+
/** The request text, already stripped of platform artifacts (mentions etc.). */
|
|
57
|
+
text: string;
|
|
58
|
+
/**
|
|
59
|
+
* Human display name for the channel/conversation (e.g. "general"),
|
|
60
|
+
* for run labels and other human-facing surfaces. Adapters resolve it from their
|
|
61
|
+
* platform; the core stays channel-agnostic and treats it as an optional hint —
|
|
62
|
+
* absent for adapters that have no name (HTTP/MCP) or when a lookup fails.
|
|
63
|
+
*/
|
|
64
|
+
channelName?: string;
|
|
65
|
+
/**
|
|
66
|
+
* Human display name for the sending user (e.g. "alice"). Same contract as
|
|
67
|
+
* `channelName`: an optional adapter-provided hint, never required by the core.
|
|
68
|
+
*/
|
|
69
|
+
userName?: string;
|
|
70
|
+
/**
|
|
71
|
+
* A link back to the triggering message on its platform (a Slack permalink),
|
|
72
|
+
* for human-facing surfaces such as the run page's Request block. Optional
|
|
73
|
+
* adapter hint like the names above — absent for HTTP/MCP.
|
|
74
|
+
*/
|
|
75
|
+
sourceUrl?: string;
|
|
76
|
+
/**
|
|
77
|
+
* The app that posted the message for the person `userId` names, when the
|
|
78
|
+
* message was not their own (a Claude Code session relaying a request from
|
|
79
|
+
* its owner's thread, docs/reference/specs/slack-channel.md item 13) — its
|
|
80
|
+
* display name. Absent when the person posted the message themselves.
|
|
81
|
+
*/
|
|
82
|
+
relayedBy?: string;
|
|
83
|
+
/**
|
|
84
|
+
* The same app as a platform-namespaced actor id (`slack:bot:<bot_id>`), set
|
|
85
|
+
* exactly when `relayedBy` is: authorization treats the run as that app
|
|
86
|
+
* acting on behalf of `userId`, so a relayed request never holds more than
|
|
87
|
+
* the app and the person both hold (authorization.md item 14).
|
|
88
|
+
*/
|
|
89
|
+
postedBy?: string;
|
|
90
|
+
/**
|
|
91
|
+
* The credential that authenticated the request, as a platform-namespaced
|
|
92
|
+
* actor id (`http:<subject>`, `mcp:<subject>`, `cli:local`), when the
|
|
93
|
+
* adapter resolved it to the PERSON it is bound to and named them in
|
|
94
|
+
* `userId` (authorization.md item 15: an ingress token entry's `email`, the
|
|
95
|
+
* CLI's `SWITCHBOARD_CLI_EMAIL`). Identity, never authority: the run, its
|
|
96
|
+
* record and its costs are the person's; what the run may do is exactly what
|
|
97
|
+
* config grants this credential — every gate decides on the actor
|
|
98
|
+
* `resolveChatActor` builds from it. Absent when the sender and the
|
|
99
|
+
* credential are one (Slack, an unbound token) or when the request was
|
|
100
|
+
* relayed (`postedBy`).
|
|
101
|
+
*/
|
|
102
|
+
authenticatedAs?: string;
|
|
103
|
+
/** Images attached to the triggering message, if any. */
|
|
104
|
+
images?: ImageAttachment[];
|
|
105
|
+
/** Non-image files (PDFs, text/code/CSV/logs) on the triggering message, if any. */
|
|
106
|
+
documents?: DocumentAttachment[];
|
|
107
|
+
/** Files the inline path cannot carry — too large, or a type the model does
|
|
108
|
+
* not read — left on the platform by reference (record 0033): a run with a
|
|
109
|
+
* workspace stages them into `attachments/` before its turn; the bytes never
|
|
110
|
+
* enter the bot. Present only when the artifact store is configured. */
|
|
111
|
+
staged?: StagedFile[];
|
|
112
|
+
/** The platform's id of this message (Slack's `ts`, the value `staged[].messageId`
|
|
113
|
+
* carries): the `input` event and the files received with it name it, so the
|
|
114
|
+
* run page joins them by data. Absent for a channel with no message id
|
|
115
|
+
* (CLI, HTTP, MCP) → `messageIdOf` uses the run id. */
|
|
116
|
+
messageId?: string;
|
|
117
|
+
/**
|
|
118
|
+
* When OUR process saw the message (ms epoch, from the adapter's clock at its
|
|
119
|
+
* entry — never from a body or a platform stamp): the run's window opens
|
|
120
|
+
* here (docs/reference/specs/tracing.md). Absent (tests, older callers) → the dispatcher
|
|
121
|
+
* reads its own clock at entry.
|
|
122
|
+
*/
|
|
123
|
+
receivedAt?: number;
|
|
124
|
+
/**
|
|
125
|
+
* When the platform says the message was posted (Slack's `ts`), for the
|
|
126
|
+
* `queued … before we saw it` caption; a caption, never part of a duration.
|
|
127
|
+
*/
|
|
128
|
+
originAt?: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The request's message id as its `input` event and its received files record it:
|
|
132
|
+
* the platform's when the adapter gave one, else the run id — one deterministic
|
|
133
|
+
* value both writers reach for, so a channel without message ids still joins. */
|
|
134
|
+
export function messageIdOf(msg: Pick<IncomingMessage, "messageId">, runId: string): string {
|
|
135
|
+
return msg.messageId ?? runId;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface HistoryItem {
|
|
139
|
+
role: "user" | "assistant";
|
|
140
|
+
text: string;
|
|
141
|
+
/** When the platform stamps its messages: the turn's time in epoch ms, so a
|
|
142
|
+
* follow-up can tell the lines written after a run ended from the ones that
|
|
143
|
+
* run already saw (docs/reference/specs/session-log.md item 9). */
|
|
144
|
+
at?: number;
|
|
145
|
+
/** Images attached to this turn, if any (user turns only in practice). */
|
|
146
|
+
images?: ImageAttachment[];
|
|
147
|
+
/** Non-image files attached to this turn, if any (user turns only in practice). */
|
|
148
|
+
documents?: DocumentAttachment[];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** What the run is doing right now, typed so each channel draws it in its
|
|
152
|
+
* own dialect and none re-parses text to find a command
|
|
153
|
+
* (docs/reference/specs/run-visibility.md item 2). `command` is a shell call
|
|
154
|
+
* with the command it was given — Slack draws it as a code block; a text
|
|
155
|
+
* surface flattens it to one `→ $ …` line. `line` is the one-line trace
|
|
156
|
+
* every other event makes (`→ read src/a.ts`, `✓ bash: exit 0`, `💭 thought
|
|
157
|
+
* for 5.1s`). */
|
|
158
|
+
export type StatusActivity = { kind: "command"; tool: string; command: string } | { kind: "line"; text: string };
|
|
159
|
+
|
|
160
|
+
/** A structured progress frame; adapters decide how to render it. */
|
|
161
|
+
export interface StatusUpdate {
|
|
162
|
+
/** one-line headline, e.g. "⚡ review on anthropic/claude-fable-5 · 42s" */
|
|
163
|
+
title: string;
|
|
164
|
+
/** the agent's checklist and any lead lines, one per line */
|
|
165
|
+
detail?: string;
|
|
166
|
+
/** the current activity, live frames only — a close never carries it */
|
|
167
|
+
activity?: StatusActivity;
|
|
168
|
+
/**
|
|
169
|
+
* The run's live page, rendered by each channel in its own short form
|
|
170
|
+
* (Slack: a one-line `<url|label>` hyperlink; CLI: the bare URL). Kept out of
|
|
171
|
+
* `detail` on purpose: the capability URL is 100+ chars and, inlined, wraps
|
|
172
|
+
* to four lines on Slack — enough to push the card behind the "Show more"
|
|
173
|
+
* fold, where EVERY edit (5 s heartbeat included) flashes the card open and
|
|
174
|
+
* shut and shoves the thread around.
|
|
175
|
+
*/
|
|
176
|
+
link?: { url: string; label: string };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A live, updatable progress indicator (e.g. an edited Slack message). */
|
|
180
|
+
export interface StatusHandle {
|
|
181
|
+
update(frame: StatusUpdate): void;
|
|
182
|
+
done(frame: StatusUpdate): Promise<void>;
|
|
183
|
+
/** Where the indicator lives, when it is a message another process could
|
|
184
|
+
* edit (Slack: channel + ts) — recorded on the run ledger so a resumed run
|
|
185
|
+
* closes the same card (docs/reference/specs/run-history.md item 35). Absent for a
|
|
186
|
+
* no-op handle. */
|
|
187
|
+
handle?: { channel: string; ts: string };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** How a run ended, as reported to the channel once its record is closed.
|
|
191
|
+
* `interrupted` is a run whose pi container was replaced under it
|
|
192
|
+
* (docs/reference/specs/harness-pi.md item 16): its request runs again as a
|
|
193
|
+
* new run on the same channel handle, whose own receipt follows. */
|
|
194
|
+
export type RunFinalStatus = "completed" | "failed" | "stopped_soft" | "stopped_hard" | "interrupted";
|
|
195
|
+
|
|
196
|
+
/** The receipt a channel gets when the run behind its request finishes: the
|
|
197
|
+
* run id (the `/runs/:id` record) and its terminal status. Never the view
|
|
198
|
+
* token — a receipt names the run, it does not grant access to it. */
|
|
199
|
+
export interface RunReceipt {
|
|
200
|
+
id: string;
|
|
201
|
+
status: RunFinalStatus;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** A thread a channel opened for a child run (docs/reference/specs/thread-admission.md
|
|
205
|
+
* item 6): the key the child is dispatched on and the handle its replies,
|
|
206
|
+
* card and history go through. */
|
|
207
|
+
export interface OpenedThread {
|
|
208
|
+
thread: {
|
|
209
|
+
/** The new thread's key, namespaced like every thread key (`slack:C…:<ts>`). */
|
|
210
|
+
threadKey: string;
|
|
211
|
+
/** A link to the thread's lead message on its platform, when the platform has one. */
|
|
212
|
+
sourceUrl?: string;
|
|
213
|
+
};
|
|
214
|
+
io: ChannelIO;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** A minted one-shot upload (`ChannelIO.uploadTicket`): where the container
|
|
218
|
+
* POSTs the bytes, and the call that shares the uploaded file into the
|
|
219
|
+
* conversation once the POST succeeded. */
|
|
220
|
+
export interface UploadTicket {
|
|
221
|
+
/** Accepts one POST of exactly the ticketed size; single use, short-lived. */
|
|
222
|
+
url: string;
|
|
223
|
+
/** Share the uploaded file into the conversation with `lead` as its message. */
|
|
224
|
+
complete(lead: string): Promise<void>;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** What the core needs from a channel to serve one request. */
|
|
228
|
+
/** What a channel shows for a confirmation (`ChannelIO.offer`): the id its
|
|
229
|
+
* affordance carries back, the full command line to run, the one risk line
|
|
230
|
+
* (empty when the command declares none), the footer naming the scope that
|
|
231
|
+
* asked, and when the offer expires (ms epoch, the config object's clock). */
|
|
232
|
+
export interface ConfirmationOffer {
|
|
233
|
+
id: string;
|
|
234
|
+
line: string;
|
|
235
|
+
risk: string;
|
|
236
|
+
footer: string;
|
|
237
|
+
expiresAt: number;
|
|
238
|
+
/** Present on a question's offer (record 0054): the refusal's sentence,
|
|
239
|
+
* shown above the line, and the evidence naming the match, shown under it.
|
|
240
|
+
* A channel that offers labels the same two actions Yes and No instead of
|
|
241
|
+
* Run and Cancel; Yes redispatches the stored proposal, No cancels. */
|
|
242
|
+
question?: { text: string; evidence: string };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export interface ChannelIO {
|
|
246
|
+
/** Post a reply in the conversation. Adapter handles chunking/formatting. */
|
|
247
|
+
reply(text: string): Promise<void>;
|
|
248
|
+
/**
|
|
249
|
+
* Present when this channel has nowhere to deliver a reply (the resumed-run
|
|
250
|
+
* null channel, docs/reference/specs/run-history.md item 38): the reason, e.g.
|
|
251
|
+
* "no channel to deliver to". `reply` still resolves (it logs), but the run's
|
|
252
|
+
* seal must say `replyOk: false` with this reason — a reply nobody could
|
|
253
|
+
* receive was not delivered. Absent on every real channel.
|
|
254
|
+
*/
|
|
255
|
+
undeliverable?: string;
|
|
256
|
+
/**
|
|
257
|
+
* Post `lead` as the message and `text` as an attached file beside it — for
|
|
258
|
+
* output too long to read as chat (a 100-tool `mcp show`): the channel's
|
|
259
|
+
* collapsible container rather than a run of chunked messages. Optional;
|
|
260
|
+
* a channel without attachments (or one whose upload fails) falls back to
|
|
261
|
+
* `reply(lead + text)` itself, so callers never branch on the outcome.
|
|
262
|
+
*/
|
|
263
|
+
attach?(file: { name: string; text: string; lead: string }): Promise<void>;
|
|
264
|
+
/**
|
|
265
|
+
* Post `lead` as the message and `bytes` as a file beside it — a screenshot,
|
|
266
|
+
* a PDF, a recording a run produced in its workspace, for the person to see
|
|
267
|
+
* inline. The binary sibling of `attach`: bytes have no text fallback, so a
|
|
268
|
+
* channel that cannot take the file (no upload API, a failed upload) throws
|
|
269
|
+
* and the caller reports it. Optional; a channel without uploads leaves it
|
|
270
|
+
* out and the tool behind it says so.
|
|
271
|
+
*/
|
|
272
|
+
attachFile?(file: { name: string; bytes: Uint8Array; lead: string }): Promise<void>;
|
|
273
|
+
/**
|
|
274
|
+
* A one-shot upload the run's CONTAINER performs (docs/reference/specs/agent-coding.md
|
|
275
|
+
* item 10, record 0033): the channel mints a URL that accepts exactly
|
|
276
|
+
* `size` bytes under `name`, the container POSTs the file to it, and
|
|
277
|
+
* `complete(lead)` shares it into the conversation with the lead. The bot
|
|
278
|
+
* process never holds the bytes — this is how a 1 GB recording reaches the
|
|
279
|
+
* thread. Optional; a channel without such an API (the CLI, HTTP) leaves it
|
|
280
|
+
* out and the tool posts the run-page link through `reply` instead.
|
|
281
|
+
*/
|
|
282
|
+
uploadTicket?(file: { name: string; size: number }): Promise<UploadTicket>;
|
|
283
|
+
/**
|
|
284
|
+
* Show the confirmation a routed write is offered as
|
|
285
|
+
* (docs/reference/specs/routing-and-config.md item 25, record 0044): the full
|
|
286
|
+
* command line the router bound, its one risk line, the footer naming the
|
|
287
|
+
* scope that asked, and the id the channel's affordance carries back to
|
|
288
|
+
* `dispatchClick` — a button whose value is the id, on a channel with
|
|
289
|
+
* components. Optional; a channel without it (the CLI, an HTTP reply, the
|
|
290
|
+
* browser) is answered the pasteable line instead, and nothing below the
|
|
291
|
+
* seam names a channel. The channel shows the offer and holds nothing else:
|
|
292
|
+
* the row lives in the config object until the click or the expiry.
|
|
293
|
+
*/
|
|
294
|
+
offer?(offer: ConfirmationOffer): Promise<void>;
|
|
295
|
+
/** Create a progress indicator. Adapters may return a no-op handle. */
|
|
296
|
+
status(initial: StatusUpdate): Promise<StatusHandle>;
|
|
297
|
+
/**
|
|
298
|
+
* Prior turns of this conversation, oldest first, excluding the triggering
|
|
299
|
+
* message and any bot status noise. Adapters without history return [].
|
|
300
|
+
*/
|
|
301
|
+
history(): Promise<HistoryItem[]>;
|
|
302
|
+
/**
|
|
303
|
+
* Called once by the core when the run created for this request has been
|
|
304
|
+
* finished in the registry (agent runs AND inline command runs), before the
|
|
305
|
+
* reply goes out. Single-shot channels (HTTP) hand the receipt back to their
|
|
306
|
+
* caller so a machine client — e.g. the Worker shim firing a scheduled job —
|
|
307
|
+
* can name the run it caused. Optional: Slack/CLI need nothing from it.
|
|
308
|
+
*/
|
|
309
|
+
runFinished?(receipt: RunReceipt): void;
|
|
310
|
+
/**
|
|
311
|
+
* Called once by the core the moment a run has been CREATED in the registry
|
|
312
|
+
* (before it executes), with the run id. The async HTTP ingress path uses it
|
|
313
|
+
* to answer `202 Accepted` with the run id while the run continues in the
|
|
314
|
+
* background; Slack/CLI need nothing from it. Optional, like runFinished.
|
|
315
|
+
*/
|
|
316
|
+
runStarted?(started: { id: string }): void;
|
|
317
|
+
/**
|
|
318
|
+
* Open a thread of this channel's own for a child run
|
|
319
|
+
* (docs/reference/specs/thread-admission.md item 6): post `lead` where a new
|
|
320
|
+
* thread can start — top-level in this conversation's channel — and hand
|
|
321
|
+
* back the thread's key and a handle bound to it. Optional: a single-shot
|
|
322
|
+
* channel (HTTP, MCP) has no thread to open, and a spawn from such a channel
|
|
323
|
+
* is refused by name (`spawn_unsupported`); it never falls back to the
|
|
324
|
+
* parent's own thread.
|
|
325
|
+
*/
|
|
326
|
+
openThread?(lead: string): Promise<OpenedThread>;
|
|
327
|
+
}
|
|
@@ -187,6 +187,41 @@ export function decideRestarted(
|
|
|
187
187
|
});
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
/** The words of a wait that ran out, per command: what was not done, how to
|
|
191
|
+
* run it again, what `--force` would do instead. */
|
|
192
|
+
export interface GaveUpWords {
|
|
193
|
+
notDone: string;
|
|
194
|
+
rerun: string;
|
|
195
|
+
force: string;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** `deploy all`'s words: the release job is re-run once the runs finish. */
|
|
199
|
+
export const DEPLOY_GAVE_UP_WORDS: GaveUpWords = {
|
|
200
|
+
notDone: "NOT deployed",
|
|
201
|
+
rerun: "re-run the deploy once they finish (a CI job: `gh run rerun RUN_ID --failed`)",
|
|
202
|
+
force: "--force to deploy over them (kills the runs in flight that no resume recovers)",
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/** `deploy restart`'s words. */
|
|
206
|
+
export const RESTART_GAVE_UP_WORDS: GaveUpWords = {
|
|
207
|
+
notDone: "NOT restarted",
|
|
208
|
+
rerun: "re-run `deploy restart` once they finish",
|
|
209
|
+
force: "--force to stop over them (kills the runs in flight that no resume recovers)",
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/** The failure a preflight still refusing at the end of the wait budget
|
|
213
|
+
* produces. The wait is only how long to hold before failing: it never ends
|
|
214
|
+
* in a deploy over what refused (a rolled container kills the runs it drives,
|
|
215
|
+
* and a handoff is a recovery, not a guarantee), so the line names the budget,
|
|
216
|
+
* the refusal, that nothing was done, and the two ways forward. */
|
|
217
|
+
export function preflightGaveUpLine(
|
|
218
|
+
waitMaxMs: number,
|
|
219
|
+
reason: string,
|
|
220
|
+
words: GaveUpWords = DEPLOY_GAVE_UP_WORDS,
|
|
221
|
+
): string {
|
|
222
|
+
return `preflight still refusing after ${waitMaxMs / 60_000} min (${reason}) — ${words.notDone}; ${words.rerun}, or ${words.force}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
190
225
|
/** The line printed on every preflight retry, so a long wait is never silent.
|
|
191
226
|
* `tag` names the command waiting (`deploy:all`, `deploy:restart`). */
|
|
192
227
|
export function heartbeatLine(
|
|
@@ -53,20 +53,21 @@ export const RESTART_SCOPE = "deploy:write";
|
|
|
53
53
|
export interface RestartVerdict {
|
|
54
54
|
allow: boolean;
|
|
55
55
|
forced: boolean;
|
|
56
|
-
/** What refuses (fail-closed
|
|
56
|
+
/** What refuses: runs in flight (a stop rolls the container under them), and the fail-closed cases (no JSON body, an impossible count). */
|
|
57
57
|
problems: string[];
|
|
58
|
-
/** What is said but does not refuse:
|
|
58
|
+
/** What is said but does not refuse: a drain under way with nothing in flight. */
|
|
59
59
|
warnings: string[];
|
|
60
60
|
message: string;
|
|
61
61
|
}
|
|
62
62
|
/**
|
|
63
63
|
* Whether the container may be stopped now — the deploy preflight's rules
|
|
64
64
|
* (deploy/cloudflare/preflight.mjs `decide`) minus the rollout-state check (a
|
|
65
|
-
* restart is not a rollout).
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
65
|
+
* restart is not a rollout). Runs in flight REFUSE: the stop rolls the
|
|
66
|
+
* container under them, and the handoff (docs/reference/specs/run-history.md
|
|
67
|
+
* item 39) is a recovery the next generation may fail, not a guarantee — the
|
|
68
|
+
* CLI waits the 409 out instead. A drain under way with nothing in flight is a
|
|
69
|
+
* warning. Fail closed on a body that is not JSON or an impossible count;
|
|
70
|
+
* `force` allows everything anyway, flagged, naming what it kills.
|
|
70
71
|
*/
|
|
71
72
|
export function decideRestart(body: HealthzBody | undefined, opts: { force: boolean }): RestartVerdict {
|
|
72
73
|
const problems: string[] = [];
|
|
@@ -79,8 +80,8 @@ export function decideRestart(body: HealthzBody | undefined, opts: { force: bool
|
|
|
79
80
|
if (!Number.isInteger(body.inFlight) || (body.inFlight as number) < 0) {
|
|
80
81
|
problems.push(`bot reports an impossible inFlight=${JSON.stringify(body.inFlight)} (counter bug or old Worker)`);
|
|
81
82
|
} else if ((body.inFlight as number) > 0) {
|
|
82
|
-
|
|
83
|
-
`${body.inFlight} run(s) in flight —
|
|
83
|
+
problems.push(
|
|
84
|
+
`${body.inFlight} run(s) in flight — a stop rolls the bot container under them (a handoff is a recovery, not a guarantee)`,
|
|
84
85
|
);
|
|
85
86
|
}
|
|
86
87
|
if (body.draining === true)
|
|
@@ -98,14 +99,14 @@ export function decideRestart(body: HealthzBody | undefined, opts: { force: bool
|
|
|
98
99
|
forced: true,
|
|
99
100
|
problems,
|
|
100
101
|
warnings,
|
|
101
|
-
message: `restart WARNING: stopping by force despite —\n${detail}`,
|
|
102
|
+
message: `restart WARNING: stopping by force despite —\n${detail}\n this WILL kill the runs in flight that no resume recovers`,
|
|
102
103
|
};
|
|
103
104
|
return {
|
|
104
105
|
allow: false,
|
|
105
106
|
forced: false,
|
|
106
107
|
problems,
|
|
107
108
|
warnings,
|
|
108
|
-
message: `restart REFUSED —\n${detail}\n wait and retry, or pass --force to stop
|
|
109
|
+
message: `restart REFUSED —\n${detail}\n wait for them to finish and retry, or pass --force to stop over them`,
|
|
109
110
|
};
|
|
110
111
|
}
|
|
111
112
|
|