@coreplane/switchboard 1.202.1 → 1.203.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/config/config.example.yaml +10 -0
- package/dist/assets/deploy/cloudflare-resident/preflight.mjs +3 -3
- package/dist/assets/deploy/cloudflare-resident/refresh.ts +179 -93
- package/dist/assets/deploy/cloudflare-resident/shared.ts +15 -10
- package/dist/assets/deploy/cloudflare-resident/worker.ts +459 -632
- 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/core/authz/policy.ts +4 -0
- package/dist/assets/src/core/runRecord.ts +11 -0
- package/dist/assets/src/core/schedules.ts +12 -6
- package/dist/assets/src/core/ship/handoff.ts +246 -0
- package/dist/assets/src/execution/residentDisk.ts +2 -2
- package/dist/assets/src/execution/residentInstanceId.ts +48 -45
- package/dist/assets/src/execution/residentRefresh.ts +17 -66
- package/dist/assets/src/execution/residentState.ts +8 -9
- package/dist/assets/src/execution/residentStepPlan.ts +1 -1
- package/dist/assets/web/dist/.vite/manifest.json +55 -44
- package/dist/assets/web/dist/assets/{AppShell-CJLPO_aI.js → AppShell-Bw3-_TTE.js} +1 -1
- package/dist/assets/web/dist/assets/{CostsPage-C6ZvnAmH.js → CostsPage-XQ-fQzdz.js} +1 -1
- package/dist/assets/web/dist/assets/DeliveryPage-BRwcQyr7.js +1 -0
- package/dist/assets/web/dist/assets/{NotFoundPage-BdLcY60r.js → NotFoundPage-RjH-9dPy.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentDetailPage-hXWrr8T6.js → ResidentDetailPage-BRy5wkv9.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-YLu4JwxJ.js → ResidentsIndexPage-DAEVLN8j.js} +1 -1
- package/dist/assets/web/dist/assets/{RunRoutePage-BDhHRJVO.js → RunRoutePage-B1KHmkZ9.js} +1 -1
- package/dist/assets/web/dist/assets/{RunsIndexPage-D7zZ5a0l.js → RunsIndexPage-3hUWFpFV.js} +1 -1
- package/dist/assets/web/dist/assets/{RunsTabs-BOUlSa2W.js → RunsTabs-Qn_5TVJU.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-BciOQcC-.js → ScheduledPage-DysVvVm1.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-CTDLBv92.js → StatusDot-Bug2a6T6.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-DD2v9Gxx.js → Tooltip-C2eEUbwn.js} +1 -1
- package/dist/assets/web/dist/assets/{favicon-C4Q8cRsP.js → favicon-CFfbl2AI.js} +1 -1
- package/dist/assets/web/dist/assets/{main-DrSlUcMg.js → main-0uhp-taL.js} +3 -3
- package/dist/assets/web/dist/assets/main-Xickfv8V.css +1 -0
- package/dist/cli.js +2932 -1875
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/main-DoTjwE-G.css +0 -1
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.203.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "switchboard",
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.203.0",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"workspaces": [
|
|
12
12
|
"web",
|
|
@@ -18806,7 +18806,7 @@
|
|
|
18806
18806
|
},
|
|
18807
18807
|
"packages/switchboard": {
|
|
18808
18808
|
"name": "@coreplane/switchboard",
|
|
18809
|
-
"version": "1.
|
|
18809
|
+
"version": "1.203.0",
|
|
18810
18810
|
"license": "Apache-2.0",
|
|
18811
18811
|
"dependencies": {
|
|
18812
18812
|
"@anthropic-ai/sdk": "^0.124.0",
|
package/dist/assets/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.203.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
|
|
6
6
|
"license": "Apache-2.0",
|
package/dist/assets/source.json
CHANGED
|
@@ -112,6 +112,10 @@ export const POLICY: readonly Rule[] = [
|
|
|
112
112
|
// ── help / schedules / deploy / env / setup ──────────────────────────────────────
|
|
113
113
|
{ action: "help:read", resource: "command", when: [grant("help:read")] },
|
|
114
114
|
{ action: "status:read", resource: "command", when: [grant("status:read")] },
|
|
115
|
+
// `delivery report` reads GitHub with the App's token and the caller's own
|
|
116
|
+
// visible runs: the grant admits the command (browsers through the read
|
|
117
|
+
// baseline, tokens minted with it); which runs join is the `runs:read` predicate.
|
|
118
|
+
{ action: "delivery:read", resource: "command", when: [grant("delivery:read")] },
|
|
115
119
|
{ action: "schedule:read", resource: "command", when: [grant("schedule:read")] },
|
|
116
120
|
{ action: "deploy:read", resource: "command", when: [grant("deploy:read")] },
|
|
117
121
|
{ action: "deploy:write", resource: "command", when: [grant("deploy:write")] },
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ChannelVisibility, Predicate } from "./authz/types.js";
|
|
2
2
|
import type { RunEvent } from "./runEvents.js";
|
|
3
3
|
import { isHeadMaterial, isSpanRecord } from "./runEvents.js";
|
|
4
|
+
import { isHandoffShape, type Handoff } from "./ship/handoff.js";
|
|
4
5
|
import {
|
|
5
6
|
FRICTION_CATEGORIES,
|
|
6
7
|
type CategoryTotals,
|
|
@@ -89,6 +90,13 @@ export interface RunRecord {
|
|
|
89
90
|
/** The thread that started the run (`IncomingMessage.sourceUrl`), for the
|
|
90
91
|
* index's hover link. Optional as above. */
|
|
91
92
|
sourceUrl?: string;
|
|
93
|
+
/** The typed handoff a coding child submitted (docs/reference/specs/agent-ship.md
|
|
94
|
+
* item 14): its deviations from the plan unit, its follow-ups and the
|
|
95
|
+
* criteria it could not prove — redacted like every stored string. Present
|
|
96
|
+
* only on a run that submitted one (an affirmed empty handoff is stored as
|
|
97
|
+
* three empty lists, distinguishable from none); absent on every other run
|
|
98
|
+
* and on records written before it existed. */
|
|
99
|
+
handoff?: Handoff;
|
|
92
100
|
}
|
|
93
101
|
|
|
94
102
|
/** A run as a listing shows it: the record minus its events. `diagnosis` stays —
|
|
@@ -408,6 +416,9 @@ export function isRunRecord(v: unknown): v is RunRecord {
|
|
|
408
416
|
)
|
|
409
417
|
return false;
|
|
410
418
|
if (!isOptionalString(r.activity) || !isOptionalString(r.sourceUrl) || !isOptionalString(r.userName)) return false;
|
|
419
|
+
// The handoff is checked for shape, not bounds (docs/reference/specs/agent-ship.md
|
|
420
|
+
// item 14): redaction may lengthen a stored string past the tool's limit.
|
|
421
|
+
if (r.handoff !== undefined && !isHandoffShape(r.handoff)) return false;
|
|
411
422
|
if (typeof r.channelId !== "string" || typeof r.userId !== "string" || typeof r.threadKey !== "string") return false;
|
|
412
423
|
// Absent on records written before the stamp existed (read as `unknown`); present → a known value.
|
|
413
424
|
if (r.channelVisibility !== undefined && !CHANNEL_VISIBILITIES.includes(r.channelVisibility as ChannelVisibility))
|
|
@@ -116,7 +116,7 @@ export const SCHEDULES: readonly ScheduleDef[] = [
|
|
|
116
116
|
cron: "*/10 * * * *",
|
|
117
117
|
worker: "resident",
|
|
118
118
|
description:
|
|
119
|
-
"Resident watchdog:
|
|
119
|
+
"Resident watchdog: create each resident's refresh Workflow instance when its ten-minute bucket is due, name a stale mid-flight marker by its instance, and time out stuck onboarding. Cadence must stay shorter than the resident SLEEP_AFTER (20m). Not a run.",
|
|
120
120
|
action: { type: "watchdog" },
|
|
121
121
|
},
|
|
122
122
|
];
|
|
@@ -376,9 +376,7 @@ export interface WatchdogSummary {
|
|
|
376
376
|
action?: unknown;
|
|
377
377
|
error?: string;
|
|
378
378
|
disk?: unknown;
|
|
379
|
-
/**
|
|
380
|
-
lifecycle?: unknown;
|
|
381
|
-
/** What the pass did about the resident's refresh Workflow instance (`workflow` residents only). */
|
|
379
|
+
/** What the pass did about the resident's refresh Workflow instance: `{id, action, why}`. */
|
|
382
380
|
instance?: unknown;
|
|
383
381
|
}>;
|
|
384
382
|
}
|
|
@@ -396,6 +394,14 @@ function fullestDisk(results: WatchdogSummary["results"]): string | undefined {
|
|
|
396
394
|
return best ? `${best.pct}% (${best.resource.replace(/^repo:/, "")})` : undefined;
|
|
397
395
|
}
|
|
398
396
|
|
|
397
|
+
/** The `action` word of a resident's refresh-instance outcome on a watchdog
|
|
398
|
+
* pass (`created | duplicate | skipped | failed`), or undefined when the row
|
|
399
|
+
* carries none. */
|
|
400
|
+
function instanceActionOf(instance: unknown): string | undefined {
|
|
401
|
+
const action = (instance as { action?: unknown } | null | undefined)?.action;
|
|
402
|
+
return typeof action === "string" ? action : undefined;
|
|
403
|
+
}
|
|
404
|
+
|
|
399
405
|
/** Turn a watchdog pass (or the error it threw) into a firing record. A pass is
|
|
400
406
|
* `completed` when every resident was checked; any per-resident error — or a
|
|
401
407
|
* throw before the sweep — is `failed`, naming the first failing resident. */
|
|
@@ -407,10 +413,10 @@ export function watchdogFiring(
|
|
|
407
413
|
if (result instanceof Error)
|
|
408
414
|
return { schedule: schedule.name, firedAt, outcome: "failed", detail: cap(`watchdog threw: ${result.message}`) };
|
|
409
415
|
const errors = result.results.filter((r) => r.error !== undefined);
|
|
410
|
-
const
|
|
416
|
+
const created = result.results.filter((r) => instanceActionOf(r.instance) === "created").length;
|
|
411
417
|
const timedOut = result.results.filter((r) => r.action === "provision-timed-out").length;
|
|
412
418
|
const disk = fullestDisk(result.results);
|
|
413
|
-
const counts = `${result.count}/${result.cap} residents · ${
|
|
419
|
+
const counts = `${result.count}/${result.cap} residents · ${created} refreshed · ${timedOut} timed out · ${errors.length} errors${disk ? ` · disk max ${disk}` : ""}`;
|
|
414
420
|
const first = errors[0];
|
|
415
421
|
return {
|
|
416
422
|
schedule: schedule.name,
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { redactSecrets } from "../redact.js";
|
|
2
|
+
|
|
3
|
+
// The handoff (docs/decisions/0031-the-coordinator-runs-a-plan-not-a-pull-request.md,
|
|
4
|
+
// docs/reference/specs/agent-ship.md item 14, agent-coding.md item 9): what a
|
|
5
|
+
// coding child hands back beside its pull-request description when it ran for
|
|
6
|
+
// a plan unit — where it departed from the unit and why, what it found and did
|
|
7
|
+
// not do, and which of the unit's criteria it could not prove. It is data, not
|
|
8
|
+
// prose in a final message: the child submits it through `submit_handoff` (the
|
|
9
|
+
// same tool path as the description), the run record carries it, and the
|
|
10
|
+
// parent posts it to the unit's board issue, where a person decides each row's
|
|
11
|
+
// disposition and amends the plan's follow-ups ledger while the plan is still
|
|
12
|
+
// `proposed` — no bot path writes a plan record.
|
|
13
|
+
//
|
|
14
|
+
// Pure and dependency-light on purpose: `runRecord.ts` (node-free, shared with
|
|
15
|
+
// the state Worker) imports the type and the shape check, so nothing here may
|
|
16
|
+
// pull in zod or a Node built-in. The validator is hand-written like
|
|
17
|
+
// `isRunRecord`; the redaction seam is the same import-free module every
|
|
18
|
+
// surface uses.
|
|
19
|
+
|
|
20
|
+
export interface HandoffDeviation {
|
|
21
|
+
/** What the unit said. */
|
|
22
|
+
from: string;
|
|
23
|
+
/** What was done instead. */
|
|
24
|
+
to: string;
|
|
25
|
+
why: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface HandoffFollowUp {
|
|
29
|
+
/** What was found and not done. */
|
|
30
|
+
what: string;
|
|
31
|
+
/** Where it belongs — a file, a unit, a spec. */
|
|
32
|
+
where: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface HandoffUnproven {
|
|
36
|
+
/** The unit's test scenario or criterion. */
|
|
37
|
+
criterion: string;
|
|
38
|
+
why: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface Handoff {
|
|
42
|
+
deviations: HandoffDeviation[];
|
|
43
|
+
followUps: HandoffFollowUp[];
|
|
44
|
+
unproven: HandoffUnproven[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The most entries one list may carry: a handoff is a summary for a person,
|
|
48
|
+
* not a second review. */
|
|
49
|
+
export const HANDOFF_MAX_ITEMS = 20;
|
|
50
|
+
/** The most characters one field may carry, on the way in. Stored records are
|
|
51
|
+
* checked for shape only (`isHandoffShape`): redaction may lengthen a string. */
|
|
52
|
+
export const HANDOFF_MAX_FIELD_CHARS = 500;
|
|
53
|
+
|
|
54
|
+
type ListKey = keyof Handoff;
|
|
55
|
+
|
|
56
|
+
/** Each list's entry fields, the one source for the validator, the redactor and the renderers. */
|
|
57
|
+
const FIELDS: Readonly<Record<ListKey, readonly string[]>> = {
|
|
58
|
+
deviations: ["from", "to", "why"],
|
|
59
|
+
followUps: ["what", "where"],
|
|
60
|
+
unproven: ["criterion", "why"],
|
|
61
|
+
};
|
|
62
|
+
const LISTS: readonly ListKey[] = ["deviations", "followUps", "unproven"];
|
|
63
|
+
|
|
64
|
+
/** A handoff under construction: each list as entries keyed by `FIELDS`. It IS
|
|
65
|
+
* a `Handoff` once every entry carries its list's fields — which the two
|
|
66
|
+
* builders below guarantee — so the one conversion at their boundary is the
|
|
67
|
+
* table above standing in for three interface declarations. */
|
|
68
|
+
type FieldLists = Record<ListKey, Record<string, string>[]>;
|
|
69
|
+
|
|
70
|
+
const emptyLists = (): FieldLists => ({ deviations: [], followUps: [], unproven: [] });
|
|
71
|
+
const asHandoff = (lists: FieldLists): Handoff => lists as unknown as Handoff;
|
|
72
|
+
const asLists = (h: Handoff): FieldLists => h as unknown as FieldLists;
|
|
73
|
+
|
|
74
|
+
export function emptyHandoff(): Handoff {
|
|
75
|
+
return asHandoff(emptyLists());
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function isEmptyHandoff(h: Handoff): boolean {
|
|
79
|
+
return LISTS.every((k) => h[k].length === 0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
83
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Structural check — the three lists, each entry an object whose named fields
|
|
87
|
+
* are strings. What a stored record is validated with: no bounds, so a record
|
|
88
|
+
* never becomes unreadable because redaction lengthened a field. */
|
|
89
|
+
export function isHandoffShape(v: unknown): v is Handoff {
|
|
90
|
+
if (!isRecord(v)) return false;
|
|
91
|
+
return LISTS.every((key) => {
|
|
92
|
+
const list = v[key];
|
|
93
|
+
return (
|
|
94
|
+
Array.isArray(list) &&
|
|
95
|
+
list.every((entry) => isRecord(entry) && FIELDS[key].every((f) => typeof entry[f] === "string"))
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type ParsedHandoff = { ok: true; handoff: Handoff } | { ok: false; error: string };
|
|
101
|
+
|
|
102
|
+
const fieldList = (key: ListKey): string => {
|
|
103
|
+
const fs = FIELDS[key];
|
|
104
|
+
return `${fs.slice(0, -1).join(", ")} and ${fs[fs.length - 1]}`;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** Validate untrusted input (a tool call) into a Handoff: the three lists
|
|
108
|
+
* present (each may be empty), at most `HANDOFF_MAX_ITEMS` entries each,
|
|
109
|
+
* every field a non-empty string of at most `HANDOFF_MAX_FIELD_CHARS` once
|
|
110
|
+
* trimmed; unknown keys are dropped. A refusal is a string naming the path,
|
|
111
|
+
* never a throw, so the model can fix the object and call again. */
|
|
112
|
+
export function parseHandoff(input: unknown): ParsedHandoff {
|
|
113
|
+
if (!isRecord(input))
|
|
114
|
+
return { ok: false, error: "handoff: must be an object with deviations, followUps and unproven" };
|
|
115
|
+
const out = emptyLists();
|
|
116
|
+
for (const key of LISTS) {
|
|
117
|
+
const list = input[key];
|
|
118
|
+
if (!Array.isArray(list))
|
|
119
|
+
return { ok: false, error: `${key}: must be an array (empty when there is nothing to say)` };
|
|
120
|
+
if (list.length > HANDOFF_MAX_ITEMS) return { ok: false, error: `${key}: at most ${HANDOFF_MAX_ITEMS} entries` };
|
|
121
|
+
for (let i = 0; i < list.length; i++) {
|
|
122
|
+
const entry: unknown = list[i];
|
|
123
|
+
if (!isRecord(entry)) return { ok: false, error: `${key}.${i}: must be an object with ${fieldList(key)}` };
|
|
124
|
+
const clean: Record<string, string> = {};
|
|
125
|
+
for (const f of FIELDS[key]) {
|
|
126
|
+
const raw = entry[f];
|
|
127
|
+
const value = typeof raw === "string" ? raw.trim() : "";
|
|
128
|
+
if (value.length === 0 || value.length > HANDOFF_MAX_FIELD_CHARS)
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
error: `${key}.${i}.${f}: must be a non-empty string of at most ${HANDOFF_MAX_FIELD_CHARS} characters`,
|
|
132
|
+
};
|
|
133
|
+
clean[f] = value;
|
|
134
|
+
}
|
|
135
|
+
out[key].push(clean);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { ok: true, handoff: asHandoff(out) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Every string leaf through the redaction seam (`redactSecrets` by default),
|
|
142
|
+
* structure preserved, the input untouched — the same rule the published
|
|
143
|
+
* description follows, so a credential pasted into a handoff never reaches a
|
|
144
|
+
* run record or a board issue. */
|
|
145
|
+
export function redactHandoff(h: Handoff, redact: (s: string) => string = redactSecrets): Handoff {
|
|
146
|
+
const out = emptyLists();
|
|
147
|
+
for (const key of LISTS) {
|
|
148
|
+
for (const entry of asLists(h)[key]) {
|
|
149
|
+
const clean: Record<string, string> = {};
|
|
150
|
+
for (const f of FIELDS[key]) clean[f] = redact(entry[f]!);
|
|
151
|
+
out[key].push(clean);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return asHandoff(out);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---- the renders ---------------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
export interface HandoffRenderContext {
|
|
160
|
+
/** The unit's id as the plan spells it (`U17`). */
|
|
161
|
+
unitId: string;
|
|
162
|
+
/** The unit's pull request, when the round opened or edited one. */
|
|
163
|
+
pr?: { number: number; url: string };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The header of the plan's follow-ups ledger (Appendix B), so the rows below
|
|
167
|
+
* paste into it as the same table. */
|
|
168
|
+
export const HANDOFF_LEDGER_HEADER = "| Follow-up | Source | Disposition |\n|---|---|---|";
|
|
169
|
+
|
|
170
|
+
/** One line: a newline would end a bullet or a table row. */
|
|
171
|
+
function line(s: string): string {
|
|
172
|
+
return s.replace(/\s*\n\s*/g, " ");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A table cell: a literal `|` would split the row; a backslash is escaped
|
|
176
|
+
* first so an input `\|` does not become an escaped escape. */
|
|
177
|
+
function cell(s: string): string {
|
|
178
|
+
return line(s.replace(/\\/g, "\\\\").replace(/\|/g, "\\|"));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Each entry as one line of prose, the same words in a bullet and in a ledger row. */
|
|
182
|
+
function entryLines(h: Handoff): Array<{ list: ListKey; bullet: string; row: string }> {
|
|
183
|
+
return [
|
|
184
|
+
...h.deviations.map((d) => {
|
|
185
|
+
const text = `${d.from} → ${d.to} — ${d.why}`;
|
|
186
|
+
return { list: "deviations" as const, bullet: text, row: `Deviation: ${text}` };
|
|
187
|
+
}),
|
|
188
|
+
...h.followUps.map((f) => {
|
|
189
|
+
const text = `${f.what} — ${f.where}`;
|
|
190
|
+
return { list: "followUps" as const, bullet: text, row: text };
|
|
191
|
+
}),
|
|
192
|
+
...h.unproven.map((u) => {
|
|
193
|
+
const text = `${u.criterion} — ${u.why}`;
|
|
194
|
+
return { list: "unproven" as const, bullet: text, row: `Unproven: ${text}` };
|
|
195
|
+
}),
|
|
196
|
+
];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function prLink(pr: HandoffRenderContext["pr"]): string | undefined {
|
|
200
|
+
return pr ? `[#${pr.number}](${pr.url})` : undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The rows a person pastes into the plan's follow-ups ledger — one per entry,
|
|
205
|
+
* in the ledger's shape `| Follow-up | Source | Disposition |`, the source
|
|
206
|
+
* naming the unit's handoff and its pull request, the disposition `open` until
|
|
207
|
+
* a person decides. Empty string for an empty handoff.
|
|
208
|
+
*/
|
|
209
|
+
export function renderHandoffLedgerRows(h: Handoff, ctx: HandoffRenderContext): string {
|
|
210
|
+
const link = prLink(ctx.pr);
|
|
211
|
+
const source = `${ctx.unitId} handoff${link ? ` (${link})` : ""}`;
|
|
212
|
+
return entryLines(h)
|
|
213
|
+
.map((e) => `| ${cell(e.row)} | ${cell(source)} | open |`)
|
|
214
|
+
.join("\n");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const HEADINGS: Readonly<Record<ListKey, string>> = {
|
|
218
|
+
deviations: "### Deviations",
|
|
219
|
+
followUps: "### Follow-ups",
|
|
220
|
+
unproven: "### Unproven",
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The comment the parent posts on the unit's board issue: the unit id and the
|
|
225
|
+
* pull request in the first line, each non-empty list under its own heading,
|
|
226
|
+
* then the ledger rows in a fenced block a person pastes into the plan while
|
|
227
|
+
* it is `proposed`. An empty list is omitted with its heading; an entirely
|
|
228
|
+
* empty handoff renders nothing — there is nothing for a person to decide.
|
|
229
|
+
* The same object renders to the same bytes.
|
|
230
|
+
*/
|
|
231
|
+
export function renderHandoffComment(h: Handoff, ctx: HandoffRenderContext): string | undefined {
|
|
232
|
+
if (isEmptyHandoff(h)) return undefined;
|
|
233
|
+
const link = prLink(ctx.pr);
|
|
234
|
+
const parts: string[] = [`**Handoff — ${ctx.unitId}** · ${link ? `pull request ${link}` : "no pull request"}`];
|
|
235
|
+
const lines = entryLines(h);
|
|
236
|
+
for (const key of LISTS) {
|
|
237
|
+
const bullets = lines.filter((e) => e.list === key).map((e) => `- ${line(e.bullet)}`);
|
|
238
|
+
if (bullets.length > 0) parts.push(`${HEADINGS[key]}\n\n${bullets.join("\n")}`);
|
|
239
|
+
}
|
|
240
|
+
parts.push(
|
|
241
|
+
"### Ledger rows\n\n" +
|
|
242
|
+
"Paste into the plan's follow-ups ledger while the plan is `proposed`; a person decides each disposition.\n\n" +
|
|
243
|
+
`\`\`\`markdown\n${HANDOFF_LEDGER_HEADER}\n${renderHandoffLedgerRows(h, ctx)}\n\`\`\``,
|
|
244
|
+
);
|
|
245
|
+
return parts.join("\n\n");
|
|
246
|
+
}
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
// falls back cold with a message that reads like a lock bug, and nothing frees
|
|
11
11
|
// the disk. Two facts fix that: the failure is classified `disk-full` (never
|
|
12
12
|
// serviceable, so the bot skips the attach and the card names the disk), and
|
|
13
|
-
// the resident recycles its container — the disk is a cache; the next
|
|
14
|
-
// restores mirror + checkout from R2 — once nothing live would be lost.
|
|
13
|
+
// the resident recycles its container — the disk is a cache; the next refresh
|
|
14
|
+
// cycle restores mirror + checkout from R2 — once nothing live would be lost.
|
|
15
15
|
|
|
16
16
|
/** The errno wording tools print for ENOSPC: Node's `ENOSPC` code and libc's
|
|
17
17
|
* strerror text (git, cp, tar, pnpm all pass it through). A message carrying
|
|
@@ -1,44 +1,43 @@
|
|
|
1
1
|
/** The refresh cycle as a cron-created Workflow instance — the id scheme, the
|
|
2
|
-
* per-resident lifecycle
|
|
2
|
+
* per-resident lifecycle row and the cron's decision to create one
|
|
3
3
|
* (docs/reference/specs/resident-repos.md item 7), kept pure and
|
|
4
4
|
* dependency-free so it is unit-testable from src/ and imported by the
|
|
5
5
|
* resident Worker like residentRefresh — the tested code IS the shipped code.
|
|
6
6
|
*
|
|
7
|
-
* Background: the refresh cycle
|
|
8
|
-
* and every failure between two re-arms
|
|
9
|
-
*
|
|
10
|
-
* fact the chain
|
|
11
|
-
* and which step it reached, retries a failed step on a policy,
|
|
12
|
-
* failed instance by name. The port
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* schedulers never both drive a cycle for the same resident.
|
|
7
|
+
* Background: the refresh cycle used to be driven by a self-rearming alarm
|
|
8
|
+
* chain, and every failure between two re-arms ended the chain silently; a
|
|
9
|
+
* watchdog guessed from a timestamp. A Cloudflare Workflow instance is the
|
|
10
|
+
* durable fact the chain lacked: the engine records that a sequence is in
|
|
11
|
+
* progress and which step it reached, retries a failed step on a policy,
|
|
12
|
+
* and shows a failed instance by name. The port ran behind a per-resident
|
|
13
|
+
* flag for one release; the chain is gone now and Workflows is the one
|
|
14
|
+
* scheduler, so every row reads `workflow` whatever it stores.
|
|
16
15
|
*
|
|
17
16
|
* Shape: a cycle is one SHORT instance, not a loop — the resident Worker's
|
|
18
17
|
* existing ten-minute cron creates one per eligible resident with a
|
|
19
18
|
* deterministic id per resident and ten-minute bucket, so a second firing in
|
|
20
19
|
* the same bucket is refused as a duplicate id and cycles stay serialized
|
|
21
|
-
* per resident
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* per resident. (A perpetual instance that slept between cycles was
|
|
21
|
+
* rejected by arithmetic: five steps every 600 s reaches the engine's
|
|
22
|
+
* 10,000-step instance cap in about two weeks.) */
|
|
24
23
|
|
|
25
24
|
import { STALE_MIDFLIGHT_MS } from "./residentIncarnation.js";
|
|
26
|
-
import { nextRefreshDelayS } from "./residentRefresh.js";
|
|
27
25
|
import type { ResidentLifecycleState } from "./residentState.js";
|
|
28
26
|
|
|
29
|
-
// -- the
|
|
27
|
+
// -- the lifecycle row ---------------------------------------------------------
|
|
30
28
|
|
|
31
|
-
/** Which scheduler drives a resident's refresh cycle.
|
|
32
|
-
|
|
29
|
+
/** Which scheduler drives a resident's refresh cycle: there is one. The row
|
|
30
|
+
* survives from the flagged rollout so `/status` can say so; an `alarm` value
|
|
31
|
+
* a flip left behind names a chain that no longer exists. */
|
|
32
|
+
export type ResidentLifecycle = "workflow";
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
export const DEFAULT_LIFECYCLE: ResidentLifecycle = "alarm";
|
|
34
|
+
export const DEFAULT_LIFECYCLE: ResidentLifecycle = "workflow";
|
|
36
35
|
|
|
37
36
|
export function parseLifecycle(value: unknown): ResidentLifecycle | undefined {
|
|
38
|
-
return value === "
|
|
37
|
+
return value === "workflow" ? value : undefined;
|
|
39
38
|
}
|
|
40
39
|
|
|
41
|
-
/** What a stored row means:
|
|
40
|
+
/** What a stored row means: `workflow`, whatever it says. */
|
|
42
41
|
export function lifecycleOf(stored: unknown): ResidentLifecycle {
|
|
43
42
|
return parseLifecycle(stored) ?? DEFAULT_LIFECYCLE;
|
|
44
43
|
}
|
|
@@ -94,7 +93,6 @@ export function refreshInstanceId(owner: string, name: string, atMs: number): st
|
|
|
94
93
|
|
|
95
94
|
/** What the cron reads about one resident before creating its instance. */
|
|
96
95
|
export interface RefreshRow {
|
|
97
|
-
lifecycle: ResidentLifecycle;
|
|
98
96
|
state: ResidentLifecycleState;
|
|
99
97
|
/** When the state last changed (epoch ms); null when the row never recorded one. */
|
|
100
98
|
updatedAt: number | null;
|
|
@@ -110,36 +108,41 @@ export interface RefreshRow {
|
|
|
110
108
|
}
|
|
111
109
|
|
|
112
110
|
export interface RefreshCadence {
|
|
113
|
-
/** The awake cadence (
|
|
111
|
+
/** The awake cadence (REFRESH_INTERVAL_S). */
|
|
114
112
|
intervalS: number;
|
|
115
|
-
/** The idle cadence (
|
|
113
|
+
/** The idle cadence (IDLE_REFRESH_INTERVAL_S). */
|
|
116
114
|
idleIntervalS: number;
|
|
117
115
|
}
|
|
118
116
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
*
|
|
125
|
-
* one is
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
* `nextRefreshDelayS`'s idle interval in buckets. */
|
|
130
|
-
export function shouldCreateRefreshInstance(row: RefreshRow, nowMs: number, cadence: RefreshCadence): InstanceDecision {
|
|
131
|
-
if (row.lifecycle !== "workflow") return { create: false, why: "alarm-lifecycle" };
|
|
132
|
-
if (row.state === "onboarding" || row.state === "down") return { create: false, why: "not-serving" };
|
|
133
|
-
if (row.instanceRunning) return { create: false, why: "running" };
|
|
117
|
+
/** Why no cycle may start for a row right now, or null when one may: the
|
|
118
|
+
* resident is not serving (`onboarding` is provisioning's, `down` is a
|
|
119
|
+
* rebuild's); the engine still runs the last instance; or a `refreshing`
|
|
120
|
+
* marker younger than the stale bound says a cycle is live (an older one is
|
|
121
|
+
* an orphan the next cycle normalizes, exactly as the watchdog judges it).
|
|
122
|
+
* Shared by the cron's decision and the admin `refresh-now` op, which
|
|
123
|
+
* starts a cycle whether or not one is due but never beside a live one. */
|
|
124
|
+
export function refreshCycleBlocked(row: RefreshRow, nowMs: number): "not-serving" | "running" | "mid-cycle" | null {
|
|
125
|
+
if (row.state === "onboarding" || row.state === "down") return "not-serving";
|
|
126
|
+
if (row.instanceRunning) return "running";
|
|
134
127
|
if (row.state === "refreshing" && row.updatedAt !== null && nowMs - row.updatedAt <= STALE_MIDFLIGHT_MS) {
|
|
135
|
-
return
|
|
128
|
+
return "mid-cycle";
|
|
136
129
|
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export type InstanceDecision =
|
|
134
|
+
{ create: true; why: "due" } | { create: false; why: "not-serving" | "mid-cycle" | "not-due" | "running" };
|
|
135
|
+
|
|
136
|
+
/** Create an instance only for a row no live cycle blocks (`refreshCycleBlocked`)
|
|
137
|
+
* whose cadence has elapsed since the last instance — counted in whole
|
|
138
|
+
* buckets so cron jitter never skips a due bucket: the awake cadence is one
|
|
139
|
+
* bucket, the idle cadence the row records (`idleSince` set) is the idle
|
|
140
|
+
* interval in buckets. */
|
|
141
|
+
export function shouldCreateRefreshInstance(row: RefreshRow, nowMs: number, cadence: RefreshCadence): InstanceDecision {
|
|
142
|
+
const blocked = refreshCycleBlocked(row, nowMs);
|
|
143
|
+
if (blocked) return { create: false, why: blocked };
|
|
137
144
|
if (row.lastInstanceAt !== null) {
|
|
138
|
-
const delayS =
|
|
139
|
-
outcome: row.idleSince !== null ? "idle" : "normal",
|
|
140
|
-
intervalS: cadence.intervalS,
|
|
141
|
-
idleIntervalS: cadence.idleIntervalS,
|
|
142
|
-
});
|
|
145
|
+
const delayS = row.idleSince !== null ? cadence.idleIntervalS : cadence.intervalS;
|
|
143
146
|
const dueBuckets = Math.max(1, Math.ceil((delayS * 1000) / REFRESH_BUCKET_MS));
|
|
144
147
|
if (refreshBucket(nowMs) - refreshBucket(row.lastInstanceAt) < dueBuckets) return { create: false, why: "not-due" };
|
|
145
148
|
}
|