@coreplane/switchboard 0.0.0 → 1.18.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/LICENSE +201 -0
- package/README.md +18 -1
- package/dist/assets/.dockerignore +27 -0
- package/dist/assets/.env.example +33 -0
- package/dist/assets/Dockerfile +111 -0
- package/dist/assets/config/config.example.yaml +359 -0
- package/dist/assets/deploy/bin/build-stamp.d.mts +15 -0
- package/dist/assets/deploy/bin/build-stamp.mjs +98 -0
- package/dist/assets/deploy/bin/cf-logs +32 -0
- package/dist/assets/deploy/cloudflare/package.json +29 -0
- package/dist/assets/deploy/cloudflare/preflight.mjs +243 -0
- package/dist/assets/deploy/cloudflare/tsconfig.json +18 -0
- package/dist/assets/deploy/cloudflare/worker.ts +382 -0
- package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +67 -0
- package/dist/assets/deploy/cloudflare/write-build.d.mts +7 -0
- package/dist/assets/deploy/cloudflare/write-build.mjs +53 -0
- package/dist/assets/deploy/cloudflare-docs/package.json +18 -0
- package/dist/assets/deploy/cloudflare-docs/wrangler.template.jsonc +30 -0
- package/dist/assets/deploy/cloudflare-memory/package.json +25 -0
- package/dist/assets/deploy/cloudflare-memory/tsconfig.json +17 -0
- package/dist/assets/deploy/cloudflare-memory/worker.ts +2635 -0
- package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +50 -0
- package/dist/assets/deploy/cloudflare-resident/Dockerfile +91 -0
- package/dist/assets/deploy/cloudflare-resident/gc.ts +287 -0
- package/dist/assets/deploy/cloudflare-resident/node-async-hooks.d.ts +11 -0
- package/dist/assets/deploy/cloudflare-resident/package.json +29 -0
- package/dist/assets/deploy/cloudflare-resident/preflight.mjs +224 -0
- package/dist/assets/deploy/cloudflare-resident/tsconfig.json +19 -0
- package/dist/assets/deploy/cloudflare-resident/worker.ts +6637 -0
- package/dist/assets/deploy/cloudflare-resident/wrangler.template.jsonc +120 -0
- package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +67 -0
- package/dist/assets/deploy/cloudflare-sandbox/docker-wrapper.sh +37 -0
- package/dist/assets/deploy/cloudflare-sandbox/package.json +26 -0
- package/dist/assets/deploy/cloudflare-sandbox/tsconfig.json +20 -0
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +410 -0
- package/dist/assets/deploy/cloudflare-sandbox/wrangler.template.jsonc +67 -0
- package/dist/assets/deploy/profile.example.json +13 -0
- package/dist/assets/deploy/secrets.manifest.json +108 -0
- package/dist/assets/docker-entrypoint.sh +15 -0
- package/dist/assets/package-lock.json +18407 -0
- package/dist/assets/package.json +104 -0
- package/dist/assets/project.json +219 -0
- package/dist/assets/source.json +5 -0
- package/dist/assets/src/core/authz/actor.ts +100 -0
- package/dist/assets/src/core/authz/authorize.ts +169 -0
- package/dist/assets/src/core/authz/grants.ts +347 -0
- package/dist/assets/src/core/authz/policy.ts +281 -0
- package/dist/assets/src/core/authz/resource.ts +147 -0
- package/dist/assets/src/core/authz/types.ts +164 -0
- package/dist/assets/src/core/drain.ts +54 -0
- package/dist/assets/src/core/ingressTokens.ts +64 -0
- package/dist/assets/src/core/memory/engine.ts +115 -0
- package/dist/assets/src/core/memory/scorer.ts +147 -0
- package/dist/assets/src/core/memory/types.ts +120 -0
- package/dist/assets/src/core/normalizeSpans.ts +299 -0
- package/dist/assets/src/core/prDescriptionTypes.ts +54 -0
- package/dist/assets/src/core/redact.ts +113 -0
- package/dist/assets/src/core/runEvents.ts +537 -0
- package/dist/assets/src/core/runFriction.ts +665 -0
- package/dist/assets/src/core/runLedger/decisions.ts +126 -0
- package/dist/assets/src/core/runLedger/types.ts +177 -0
- package/dist/assets/src/core/runRecord.ts +627 -0
- package/dist/assets/src/core/runShape.ts +61 -0
- package/dist/assets/src/core/schedules.ts +452 -0
- package/dist/assets/src/core/time/formatDuration.ts +61 -0
- package/dist/assets/src/core/trace/attrs.ts +203 -0
- package/dist/assets/src/core/trace/classify.ts +49 -0
- package/dist/assets/src/core/trace/clock.ts +6 -0
- package/dist/assets/src/core/trace/context.ts +9 -0
- package/dist/assets/src/core/trace/ids.ts +23 -0
- package/dist/assets/src/core/trace/partition.ts +235 -0
- package/dist/assets/src/core/trace/sinks.ts +68 -0
- package/dist/assets/src/core/trace/streamSpans.ts +163 -0
- package/dist/assets/src/core/trace/traceparent.ts +29 -0
- package/dist/assets/src/core/trace/tracer.ts +247 -0
- package/dist/assets/src/core/trace/types.ts +125 -0
- package/dist/assets/src/core/trace/workerTrace.ts +97 -0
- package/dist/assets/src/deploy/buildStamp.ts +93 -0
- package/dist/assets/src/deploy/liveGate.ts +203 -0
- package/dist/assets/src/deploy/profile.ts +162 -0
- package/dist/assets/src/deploy/restart.ts +393 -0
- package/dist/assets/src/effort.ts +17 -0
- package/dist/assets/src/execution/bashTimeout.ts +78 -0
- package/dist/assets/src/execution/bindingPurge.ts +43 -0
- package/dist/assets/src/execution/residentBackupTransfer.ts +50 -0
- package/dist/assets/src/execution/residentCleanliness.ts +95 -0
- package/dist/assets/src/execution/residentCredentials.ts +81 -0
- package/dist/assets/src/execution/residentDepCache.ts +321 -0
- package/dist/assets/src/execution/residentDepsStore.ts +326 -0
- package/dist/assets/src/execution/residentDetach.ts +48 -0
- package/dist/assets/src/execution/residentDisk.ts +107 -0
- package/dist/assets/src/execution/residentDiskBudget.ts +448 -0
- package/dist/assets/src/execution/residentExecWrap.ts +100 -0
- package/dist/assets/src/execution/residentHead.ts +85 -0
- package/dist/assets/src/execution/residentReadonly.ts +72 -0
- package/dist/assets/src/execution/residentRefresh.ts +429 -0
- package/dist/assets/src/execution/residentRestoreExtract.ts +130 -0
- package/dist/assets/src/execution/residentState.ts +47 -0
- package/dist/assets/src/execution/residentStepReport.ts +98 -0
- package/dist/assets/src/execution/residentStepTrace.ts +97 -0
- package/dist/assets/src/execution/residentSteps.ts +99 -0
- package/dist/assets/src/execution/residentText.ts +83 -0
- package/dist/assets/src/execution/residentTrace.ts +119 -0
- package/dist/assets/src/execution/sandboxEnv.ts +42 -0
- package/dist/assets/src/execution/sandboxErrors.ts +159 -0
- package/dist/assets/src/execution/sandboxKeepalive.ts +118 -0
- package/dist/assets/src/execution/shellQuote.ts +8 -0
- package/dist/assets/src/mcp/registry.ts +242 -0
- package/dist/assets/src/providers/types.ts +152 -0
- package/dist/assets/web/dist/.vite/manifest.json +176 -0
- package/dist/assets/web/dist/assets/AppShell-Bk2gbvet.js +1 -0
- package/dist/assets/web/dist/assets/CostsPage-CTZcMYYx.js +1 -0
- package/dist/assets/web/dist/assets/NotFoundPage-C-BuaSm8.js +1 -0
- package/dist/assets/web/dist/assets/ResidentDetailPage-D3shEnzl.js +1 -0
- package/dist/assets/web/dist/assets/ResidentsIndexPage-DWIubQ05.js +1 -0
- package/dist/assets/web/dist/assets/RunRoutePage-BMjuE-oX.js +126 -0
- package/dist/assets/web/dist/assets/RunRoutePage-XVFj0XDc.css +1 -0
- package/dist/assets/web/dist/assets/RunsIndexPage-C3_jYIo0.js +1 -0
- package/dist/assets/web/dist/assets/RunsTabs-C4krAL9o.js +1 -0
- package/dist/assets/web/dist/assets/ScheduledPage-g1W58mtN.js +1 -0
- package/dist/assets/web/dist/assets/StatusDot-DcPRw3zu.js +1 -0
- package/dist/assets/web/dist/assets/Tooltip-DJUkMYjo.js +1 -0
- package/dist/assets/web/dist/assets/favicon-DL1rdWJt.js +1 -0
- package/dist/assets/web/dist/assets/localIso-L06jV29p.js +1 -0
- package/dist/assets/web/dist/assets/main-BsBGUyMH.css +2 -0
- package/dist/assets/web/dist/assets/main-CyM5f4JC.js +28 -0
- package/dist/assets/web/dist/assets/residentDiskBudget-BMBKlYRH.js +1 -0
- package/dist/assets/web/dist/assets/seed-BglCRKLA.js +6 -0
- package/dist/assets/web/dist/assets/wallClock-Ckv3sKoR.js +1 -0
- package/dist/cli.js +34494 -0
- package/package.json +43 -10
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import type { Actor, Grants } from "./authz/types.js";
|
|
2
|
+
import { parseIngressTokenMap, tokenForSubject } from "./ingressTokens.js";
|
|
3
|
+
|
|
4
|
+
// The schedule registry: the ONE catalog of every cron any of our
|
|
5
|
+
// Cloudflare Workers runs. Each Worker's `wrangler.jsonc` `triggers.crons` must
|
|
6
|
+
// equal the registry's expressions for that worker (a unit test enforces it per
|
|
7
|
+
// worker), and each Worker's `scheduled()` looks its firing up HERE — so a
|
|
8
|
+
// schedule can never exist in one place and not the other.
|
|
9
|
+
//
|
|
10
|
+
// A schedule has three independent facets:
|
|
11
|
+
// worker — whose wrangler.jsonc carries the cron and whose `scheduled()` fires it
|
|
12
|
+
// action — what a firing does: `run` (the bot shim POSTs the generic /ingress as
|
|
13
|
+
// the `cron` identity with the command text; the dispatcher does the
|
|
14
|
+
// rest — run id, event stream, /runs/<id>, history), `healthz` (the bot
|
|
15
|
+
// shim touches the container), `watchdog` (the resident Worker sweeps
|
|
16
|
+
// its residents)
|
|
17
|
+
// internal — plumbing nobody operates: never rendered on /runs, never recorded
|
|
18
|
+
//
|
|
19
|
+
// Anything expressible as a chat command is a one-line `run` entry here; grow
|
|
20
|
+
// commands, not action types.
|
|
21
|
+
//
|
|
22
|
+
// Node-free on purpose: the shims (workerd) import this file by relative path,
|
|
23
|
+
// like deploy/cloudflare-memory imports src/core/memory/engine.ts. Nothing in
|
|
24
|
+
// here may touch node:* modules.
|
|
25
|
+
|
|
26
|
+
/** The system identity every scheduled run is dispatched as. Its bearer lives
|
|
27
|
+
* in SWITCHBOARD_INGRESS_TOKENS as `{"<token>": {"subject": "cron", "channel":
|
|
28
|
+
* "cron"}}` — the same map every ingress caller uses, no extra secret. The
|
|
29
|
+
* dispatcher sees `http:cron`; whatever a scheduled command requires (e.g.
|
|
30
|
+
* `friction propose` → `friction:write`) must be granted to it. */
|
|
31
|
+
export const CRON_IDENTITY = "cron";
|
|
32
|
+
|
|
33
|
+
/** The Workers that run crons: `bot` = deploy/cloudflare (the shim in front of
|
|
34
|
+
* the bot container), `resident` = deploy/cloudflare-resident. */
|
|
35
|
+
export type ScheduleWorker = "bot" | "resident";
|
|
36
|
+
|
|
37
|
+
export interface RunAction {
|
|
38
|
+
type: "run";
|
|
39
|
+
/** The exact text POSTed to /ingress — a chat command the dispatcher answers. */
|
|
40
|
+
command: string;
|
|
41
|
+
/** The ingress identity (`subject`) whose token the shim presents. */
|
|
42
|
+
identity: string;
|
|
43
|
+
/** Who the firing IS under the one authorization model (docs/decisions/0007-authorization-policy-table.md): the
|
|
44
|
+
* `schedule` actor `schedule:<name>` with the grants the registry declares
|
|
45
|
+
* for it — the floor `ConfigStore.grantsFor` serves for that id; a native
|
|
46
|
+
* `grants["schedule:<name>"]` entry in config.yaml replaces them. The shim
|
|
47
|
+
* still authenticates as `identity` above (the dispatcher sees
|
|
48
|
+
* `http:<identity>`) until a later unit hands firings to this actor; the
|
|
49
|
+
* grants are declared here so that switch is a wiring change, not a policy one. */
|
|
50
|
+
actor: Pick<Actor, "kind" | "id" | "grants"> & { kind: "schedule"; id: `schedule:${string}` };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The `schedule` actor for a run schedule's name, holding exactly `grants`. */
|
|
54
|
+
export function scheduleActor(name: string, grants: Grants): RunAction["actor"] {
|
|
55
|
+
return { kind: "schedule", id: `schedule:${name}`, grants };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** What the weekly pass needs: read the fleet's runs (`friction report` is a
|
|
59
|
+
* run read across every channel) and file proposals. No repos. */
|
|
60
|
+
const SELF_IMPROVEMENT_GRANTS: Grants = Object.freeze({
|
|
61
|
+
actions: new Set(["friction:read", "friction:write"]),
|
|
62
|
+
channels: "all",
|
|
63
|
+
repos: new Set<string>(),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export type ScheduleAction =
|
|
67
|
+
| RunAction
|
|
68
|
+
/** Bot shim: GET the container's /healthz — starts it if stopped, renews its activity timeout. */
|
|
69
|
+
| { type: "healthz" }
|
|
70
|
+
/** Resident Worker: one watchdog pass over every resident (re-arm dead refresh chains, time out stuck onboarding). */
|
|
71
|
+
| { type: "watchdog" };
|
|
72
|
+
|
|
73
|
+
export interface ScheduleDef {
|
|
74
|
+
name: string;
|
|
75
|
+
/** Five-field cron expression, UTC (Cloudflare Workers cron triggers are UTC). */
|
|
76
|
+
cron: string;
|
|
77
|
+
description: string;
|
|
78
|
+
worker: ScheduleWorker;
|
|
79
|
+
action: ScheduleAction;
|
|
80
|
+
/** Infrastructure plumbing: not shown on the /runs Scheduled panel, no firing recorded. */
|
|
81
|
+
internal?: true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type RunSchedule = ScheduleDef & { action: RunAction };
|
|
85
|
+
|
|
86
|
+
export function isRunSchedule(s: ScheduleDef): s is RunSchedule {
|
|
87
|
+
return s.action.type === "run";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const SCHEDULES: readonly ScheduleDef[] = [
|
|
91
|
+
{
|
|
92
|
+
name: "keep-alive",
|
|
93
|
+
cron: "* * * * *",
|
|
94
|
+
worker: "bot",
|
|
95
|
+
internal: true,
|
|
96
|
+
description:
|
|
97
|
+
"Container keep-alive (GET /healthz) — also what restarts the container after a deploy or platform maintenance. Not a run.",
|
|
98
|
+
action: { type: "healthz" },
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
name: "self-improvement",
|
|
102
|
+
cron: "0 14 * * 1",
|
|
103
|
+
worker: "bot",
|
|
104
|
+
description:
|
|
105
|
+
"Weekly self-improvement pass: cluster the friction ledger and file deduped `self-improvement` issues. Proposals only.",
|
|
106
|
+
action: {
|
|
107
|
+
type: "run",
|
|
108
|
+
command: "friction propose",
|
|
109
|
+
identity: CRON_IDENTITY,
|
|
110
|
+
actor: scheduleActor("self-improvement", SELF_IMPROVEMENT_GRANTS),
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: "resident-watchdog",
|
|
115
|
+
cron: "*/10 * * * *",
|
|
116
|
+
worker: "resident",
|
|
117
|
+
description:
|
|
118
|
+
"Resident watchdog: re-arm dead refresh alarm chains (marking degraded(alarm-missed)) and time out stuck onboarding. Cadence must stay shorter than the resident SLEEP_AFTER (20m). Not a run.",
|
|
119
|
+
action: { type: "watchdog" },
|
|
120
|
+
},
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
/** The schedules one Worker's wrangler.jsonc must carry and its `scheduled()` fires. */
|
|
124
|
+
export function schedulesFor(worker: ScheduleWorker): ScheduleDef[] {
|
|
125
|
+
return SCHEDULES.filter((s) => s.worker === worker);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The schedule a Workers `ScheduledController.cron` belongs to, looked up
|
|
129
|
+
* among THAT worker's schedules only (two workers may share an expression). */
|
|
130
|
+
export function scheduleForCron(cron: string, worker: ScheduleWorker): ScheduleDef | undefined {
|
|
131
|
+
return SCHEDULES.find((s) => s.worker === worker && s.cron === cron);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Cron evaluation — enough of Vixie cron to compute "next fire" for the panel
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
export interface CronSpec {
|
|
139
|
+
minute: Set<number>;
|
|
140
|
+
hour: Set<number>;
|
|
141
|
+
dayOfMonth: Set<number>;
|
|
142
|
+
month: Set<number>;
|
|
143
|
+
/** 0 = Sunday … 6 = Saturday (an input `7` is folded to 0). */
|
|
144
|
+
dayOfWeek: Set<number>;
|
|
145
|
+
/** Whether the day fields were `*` — decides the dom/dow OR rule. */
|
|
146
|
+
anyDayOfMonth: boolean;
|
|
147
|
+
anyDayOfWeek: boolean;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function parseField(field: string, min: number, max: number): Set<number> | undefined {
|
|
151
|
+
const out = new Set<number>();
|
|
152
|
+
for (const part of field.split(",")) {
|
|
153
|
+
const m = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(part);
|
|
154
|
+
if (!m) return undefined;
|
|
155
|
+
const step = m[2] === undefined ? 1 : Number(m[2]);
|
|
156
|
+
if (!Number.isInteger(step) || step < 1) return undefined;
|
|
157
|
+
let lo: number;
|
|
158
|
+
let hi: number;
|
|
159
|
+
if (m[1] === "*") {
|
|
160
|
+
lo = min;
|
|
161
|
+
hi = max;
|
|
162
|
+
} else {
|
|
163
|
+
const [a, b] = m[1].split("-").map(Number);
|
|
164
|
+
// Vixie cron: a step needs a range (`*` or `a-b`); `5/2` is malformed.
|
|
165
|
+
if (b === undefined && m[2] !== undefined) return undefined;
|
|
166
|
+
lo = a;
|
|
167
|
+
hi = b ?? a;
|
|
168
|
+
}
|
|
169
|
+
if (lo < min || hi > max || lo > hi) return undefined;
|
|
170
|
+
for (let v = lo; v <= hi; v += step) out.add(v);
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Parse a five-field cron expression; undefined for anything malformed (no
|
|
176
|
+
* names, no `@daily` macros — the registry uses plain numerics). */
|
|
177
|
+
export function parseCron(expr: string): CronSpec | undefined {
|
|
178
|
+
const fields = expr.trim().split(/\s+/);
|
|
179
|
+
if (fields.length !== 5 || fields[0] === "") return undefined;
|
|
180
|
+
const minute = parseField(fields[0], 0, 59);
|
|
181
|
+
const hour = parseField(fields[1], 0, 23);
|
|
182
|
+
const dayOfMonth = parseField(fields[2], 1, 31);
|
|
183
|
+
const month = parseField(fields[3], 1, 12);
|
|
184
|
+
const dowRaw = parseField(fields[4], 0, 7);
|
|
185
|
+
if (!minute || !hour || !dayOfMonth || !month || !dowRaw) return undefined;
|
|
186
|
+
const dayOfWeek = new Set([...dowRaw].map((d) => (d === 7 ? 0 : d)));
|
|
187
|
+
return {
|
|
188
|
+
minute,
|
|
189
|
+
hour,
|
|
190
|
+
dayOfMonth,
|
|
191
|
+
month,
|
|
192
|
+
dayOfWeek,
|
|
193
|
+
anyDayOfMonth: fields[2] === "*",
|
|
194
|
+
anyDayOfWeek: fields[4] === "*",
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** How far ahead `nextFire` searches before giving up (a Feb 30 never fires). */
|
|
199
|
+
const NEXT_FIRE_HORIZON_DAYS = 366 * 5;
|
|
200
|
+
|
|
201
|
+
/** The first firing time (ms, UTC) strictly after `fromMs`; undefined when the
|
|
202
|
+
* expression is malformed or nothing fires within the horizon. */
|
|
203
|
+
export function nextFire(expr: string, fromMs: number): number | undefined {
|
|
204
|
+
const spec = parseCron(expr);
|
|
205
|
+
if (!spec) return undefined;
|
|
206
|
+
const hours = [...spec.hour].sort((a, b) => a - b);
|
|
207
|
+
const minutes = [...spec.minute].sort((a, b) => a - b);
|
|
208
|
+
// Start at the next whole minute after `from`.
|
|
209
|
+
const start = new Date(Math.floor(fromMs / 60_000) * 60_000 + 60_000);
|
|
210
|
+
let day = Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate());
|
|
211
|
+
for (let i = 0; i < NEXT_FIRE_HORIZON_DAYS; i++, day += 86_400_000) {
|
|
212
|
+
const d = new Date(day);
|
|
213
|
+
if (!spec.month.has(d.getUTCMonth() + 1)) continue;
|
|
214
|
+
const domOk = spec.dayOfMonth.has(d.getUTCDate());
|
|
215
|
+
const dowOk = spec.dayOfWeek.has(d.getUTCDay());
|
|
216
|
+
// Vixie rule: both restricted → either matches; otherwise the restricted one decides.
|
|
217
|
+
const dayOk = spec.anyDayOfMonth ? dowOk : spec.anyDayOfWeek ? domOk : domOk || dowOk;
|
|
218
|
+
if (!dayOk) continue;
|
|
219
|
+
for (const h of hours) {
|
|
220
|
+
for (const m of minutes) {
|
|
221
|
+
const t = day + h * 3_600_000 + m * 60_000;
|
|
222
|
+
if (t >= start.getTime()) return t;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// Firing records — what a firing left behind, for the /runs Scheduled panel
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
/** How a firing ended. The four run statuses mirror the ingress run receipt;
|
|
234
|
+
* `no-run` = ingress answered 200 without a run receipt; `ingress-error` =
|
|
235
|
+
* non-2xx (401 unknown identity, 503 disabled, 5xx bot down); `misconfigured`
|
|
236
|
+
* = the shim could not even plan the request (no `cron` token) and ran nothing. */
|
|
237
|
+
export type FiringOutcome =
|
|
238
|
+
"completed" | "failed" | "stopped_soft" | "stopped_hard" | "no-run" | "ingress-error" | "misconfigured";
|
|
239
|
+
|
|
240
|
+
const OUTCOMES: ReadonlySet<string> = new Set<FiringOutcome>([
|
|
241
|
+
"completed",
|
|
242
|
+
"failed",
|
|
243
|
+
"stopped_soft",
|
|
244
|
+
"stopped_hard",
|
|
245
|
+
"no-run",
|
|
246
|
+
"ingress-error",
|
|
247
|
+
"misconfigured",
|
|
248
|
+
]);
|
|
249
|
+
|
|
250
|
+
export interface ScheduleFiring {
|
|
251
|
+
/** `ScheduleDef.name`. */
|
|
252
|
+
schedule: string;
|
|
253
|
+
/** Wall-clock ms when the shim fired. */
|
|
254
|
+
firedAt: number;
|
|
255
|
+
/** The run the dispatcher created, when one was. */
|
|
256
|
+
runId?: string;
|
|
257
|
+
outcome: FiringOutcome;
|
|
258
|
+
/** A short human note: the reply's first line, or the HTTP status + error. Capped. */
|
|
259
|
+
detail?: string;
|
|
260
|
+
/** The shim's trace for the firing (docs/reference/specs/tracing.md item 21), once the shim has roots. */
|
|
261
|
+
traceId?: string;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Detail cap: a firing record is a row on a dashboard, never a report. */
|
|
265
|
+
export const FIRING_DETAIL_MAX = 300;
|
|
266
|
+
|
|
267
|
+
export function isScheduleFiring(v: unknown): v is ScheduleFiring {
|
|
268
|
+
if (typeof v !== "object" || v === null) return false;
|
|
269
|
+
const f = v as Record<string, unknown>;
|
|
270
|
+
if (typeof f.schedule !== "string" || f.schedule === "") return false;
|
|
271
|
+
if (typeof f.firedAt !== "number" || !Number.isFinite(f.firedAt)) return false;
|
|
272
|
+
if (typeof f.outcome !== "string" || !OUTCOMES.has(f.outcome)) return false;
|
|
273
|
+
if (f.runId !== undefined && typeof f.runId !== "string") return false;
|
|
274
|
+
if (f.detail !== undefined && typeof f.detail !== "string") return false;
|
|
275
|
+
if (f.traceId !== undefined && typeof f.traceId !== "string") return false;
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// The shim's two pure steps: plan the /ingress request, interpret its answer
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
export type FiringPlan =
|
|
284
|
+
{ ok: true; token: string; body: { text: string; thread: string } } | { ok: false; reason: string };
|
|
285
|
+
|
|
286
|
+
/** Turn a run schedule into the /ingress request the shim sends. Fail-closed:
|
|
287
|
+
* without a usable `SWITCHBOARD_INGRESS_TOKENS` entry for the schedule's
|
|
288
|
+
* identity there is no request — the reason is for the log and the firing
|
|
289
|
+
* record, never guessed around. Each firing gets its own thread key so two
|
|
290
|
+
* firings never share a conversation. */
|
|
291
|
+
export function planScheduledFiring(
|
|
292
|
+
schedule: RunSchedule,
|
|
293
|
+
ingressTokensJson: string | undefined,
|
|
294
|
+
firedAt: number,
|
|
295
|
+
): FiringPlan {
|
|
296
|
+
if (!ingressTokensJson || ingressTokensJson.trim() === "")
|
|
297
|
+
return { ok: false, reason: "SWITCHBOARD_INGRESS_TOKENS is not set" };
|
|
298
|
+
const parsed = parseIngressTokenMap(ingressTokensJson);
|
|
299
|
+
if (parsed.ok === false) return { ok: false, reason: `SWITCHBOARD_INGRESS_TOKENS is ${parsed.reason}` };
|
|
300
|
+
const token = tokenForSubject(parsed.tokens, schedule.action.identity);
|
|
301
|
+
if (!token)
|
|
302
|
+
return { ok: false, reason: `SWITCHBOARD_INGRESS_TOKENS has no entry with subject "${schedule.action.identity}"` };
|
|
303
|
+
return { ok: true, token, body: { text: schedule.action.command, thread: `${schedule.name}-${firedAt}` } };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const RUN_STATUSES: ReadonlySet<string> = new Set(["completed", "failed", "stopped_soft", "stopped_hard"]);
|
|
307
|
+
|
|
308
|
+
/** The firing detail is the reply's FIRST non-empty line, whitespace-collapsed
|
|
309
|
+
* and capped: the head line of every command reply is its one-line summary
|
|
310
|
+
* (e.g. `🔍 *Friction proposals* — 244 runs analyzed · 23 recurring patterns`);
|
|
311
|
+
* the body under it is the long form, which the /runs panel has no room for. */
|
|
312
|
+
function cap(text: string): string {
|
|
313
|
+
const first = text.split("\n").find((l) => l.trim() !== "") ?? "";
|
|
314
|
+
const line = first.replace(/\s+/g, " ").trim();
|
|
315
|
+
return line.length > FIRING_DETAIL_MAX ? `${line.slice(0, FIRING_DETAIL_MAX - 1)}…` : line;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Build the firing record from the /ingress response. A 2xx with a run
|
|
319
|
+
* receipt yields the run's id + terminal status; a 2xx without one is
|
|
320
|
+
* `no-run`; anything else is `ingress-error` with the status and the error
|
|
321
|
+
* field (or the raw body) as detail. */
|
|
322
|
+
export function interpretIngressResponse(
|
|
323
|
+
schedule: RunSchedule,
|
|
324
|
+
firedAt: number,
|
|
325
|
+
status: number,
|
|
326
|
+
bodyText: string,
|
|
327
|
+
): ScheduleFiring {
|
|
328
|
+
let body: Record<string, unknown> | undefined;
|
|
329
|
+
try {
|
|
330
|
+
const parsed: unknown = JSON.parse(bodyText);
|
|
331
|
+
if (typeof parsed === "object" && parsed !== null) body = parsed as Record<string, unknown>;
|
|
332
|
+
} catch {
|
|
333
|
+
// non-JSON body: handled below
|
|
334
|
+
}
|
|
335
|
+
if (status < 200 || status >= 300) {
|
|
336
|
+
const error: unknown = body?.error;
|
|
337
|
+
const reason = typeof error === "string" ? error : bodyText;
|
|
338
|
+
return { schedule: schedule.name, firedAt, outcome: "ingress-error", detail: cap(`HTTP ${status} ${reason}`) };
|
|
339
|
+
}
|
|
340
|
+
const run = body?.run as Record<string, unknown> | undefined;
|
|
341
|
+
const reply = typeof body?.reply === "string" ? body.reply : undefined;
|
|
342
|
+
if (run && typeof run.id === "string" && typeof run.status === "string" && RUN_STATUSES.has(run.status)) {
|
|
343
|
+
return {
|
|
344
|
+
schedule: schedule.name,
|
|
345
|
+
firedAt,
|
|
346
|
+
runId: run.id,
|
|
347
|
+
outcome: run.status as FiringOutcome,
|
|
348
|
+
...(reply !== undefined ? { detail: cap(reply) } : {}),
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
schedule: schedule.name,
|
|
353
|
+
firedAt,
|
|
354
|
+
outcome: "no-run",
|
|
355
|
+
...(reply !== undefined ? { detail: cap(reply) } : {}),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
// The resident watchdog's firing record
|
|
361
|
+
// ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
/** The shape `runWatchdog` (deploy/cloudflare-resident/worker.ts) returns; only
|
|
364
|
+
* the fields the firing record summarizes are typed here. */
|
|
365
|
+
export interface WatchdogSummary {
|
|
366
|
+
cap: number;
|
|
367
|
+
count: number;
|
|
368
|
+
/** `error` is already a message (`runWatchdog` passes rejections through `errMsg`).
|
|
369
|
+
* `disk` is the resident's last disk sample gauge (`{usedKiB, totalKiB, …}`,
|
|
370
|
+
* docs/reference/specs/resident-repos.md item 55) when it has one. */
|
|
371
|
+
results: ReadonlyArray<{
|
|
372
|
+
resource: string;
|
|
373
|
+
state?: unknown;
|
|
374
|
+
reason?: unknown;
|
|
375
|
+
action?: unknown;
|
|
376
|
+
error?: string;
|
|
377
|
+
disk?: unknown;
|
|
378
|
+
}>;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The fullest resident's disk, as `<pct>% (<owner/name>)`, from the per-resident
|
|
382
|
+
* gauges a watchdog pass carries; undefined when no resident has measured. */
|
|
383
|
+
function fullestDisk(results: WatchdogSummary["results"]): string | undefined {
|
|
384
|
+
let best: { pct: number; resource: string } | undefined;
|
|
385
|
+
for (const r of results) {
|
|
386
|
+
const d = r.disk as { usedKiB?: unknown; totalKiB?: unknown } | null | undefined;
|
|
387
|
+
if (!d || typeof d.usedKiB !== "number" || typeof d.totalKiB !== "number" || d.totalKiB <= 0) continue;
|
|
388
|
+
const pct = Math.round((d.usedKiB / d.totalKiB) * 100);
|
|
389
|
+
if (!best || pct > best.pct) best = { pct, resource: r.resource };
|
|
390
|
+
}
|
|
391
|
+
return best ? `${best.pct}% (${best.resource.replace(/^repo:/, "")})` : undefined;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Turn a watchdog pass (or the error it threw) into a firing record. A pass is
|
|
395
|
+
* `completed` when every resident was checked; any per-resident error — or a
|
|
396
|
+
* throw before the sweep — is `failed`, naming the first failing resident. */
|
|
397
|
+
export function watchdogFiring(
|
|
398
|
+
schedule: ScheduleDef,
|
|
399
|
+
firedAt: number,
|
|
400
|
+
result: WatchdogSummary | Error,
|
|
401
|
+
): ScheduleFiring {
|
|
402
|
+
if (result instanceof Error)
|
|
403
|
+
return { schedule: schedule.name, firedAt, outcome: "failed", detail: cap(`watchdog threw: ${result.message}`) };
|
|
404
|
+
const errors = result.results.filter((r) => r.error !== undefined);
|
|
405
|
+
const reArmed = result.results.filter((r) => r.action === "re-armed").length;
|
|
406
|
+
const timedOut = result.results.filter((r) => r.action === "provision-timed-out").length;
|
|
407
|
+
const disk = fullestDisk(result.results);
|
|
408
|
+
const counts = `${result.count}/${result.cap} residents · ${reArmed} re-armed · ${timedOut} timed out · ${errors.length} errors${disk ? ` · disk max ${disk}` : ""}`;
|
|
409
|
+
const first = errors[0];
|
|
410
|
+
return {
|
|
411
|
+
schedule: schedule.name,
|
|
412
|
+
firedAt,
|
|
413
|
+
outcome: errors.length > 0 ? "failed" : "completed",
|
|
414
|
+
detail: cap(first ? `${counts} — ${first.resource}: ${String(first.error)}` : counts),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
// Recording a firing on the state Worker's ScheduleDO (shared by both shims)
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
export interface ScheduleRecorderConfig {
|
|
423
|
+
/** `STATE_WORKER_URL` var: the state Worker's base URL. */
|
|
424
|
+
url: string | undefined;
|
|
425
|
+
/** `MEMORY_TOKEN` secret: the state Worker's bearer. */
|
|
426
|
+
token: string | undefined;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export type RecordResult = { ok: true } | { ok: false; reason: string };
|
|
430
|
+
|
|
431
|
+
/** POST the firing to `<url>/schedules/record`. Best-effort telemetry for the
|
|
432
|
+
* /runs Scheduled panel: a failure is returned for the caller's log line, never
|
|
433
|
+
* thrown — the firing's real work already happened and is its own record.
|
|
434
|
+
* Fail-closed on config: no URL or bearer → nothing sent. */
|
|
435
|
+
export async function recordFiring(
|
|
436
|
+
config: ScheduleRecorderConfig,
|
|
437
|
+
firing: ScheduleFiring,
|
|
438
|
+
fetchImpl: typeof fetch = fetch,
|
|
439
|
+
): Promise<RecordResult> {
|
|
440
|
+
if (!config.url) return { ok: false, reason: "STATE_WORKER_URL var is not set" };
|
|
441
|
+
if (!config.token) return { ok: false, reason: "MEMORY_TOKEN secret is not set" };
|
|
442
|
+
try {
|
|
443
|
+
const res = await fetchImpl(`${config.url.replace(/\/+$/, "")}/schedules/record`, {
|
|
444
|
+
method: "POST",
|
|
445
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${config.token}` },
|
|
446
|
+
body: JSON.stringify({ firing }),
|
|
447
|
+
});
|
|
448
|
+
return res.ok ? { ok: true } : { ok: false, reason: `state Worker HTTP ${res.status}` };
|
|
449
|
+
} catch (err) {
|
|
450
|
+
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
|
451
|
+
}
|
|
452
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** The one duration formatter (docs/reference/specs/tracing.md). Three styles, one place:
|
|
2
|
+
*
|
|
3
|
+
* - `precise` — a single measured step: `800ms`, `1.3s`, `5m 04s`.
|
|
4
|
+
* - `clock` — a stopwatch reading that ticks: `0s`, `38s`, `4m 12s`, `1h 03m`;
|
|
5
|
+
* two parts above a minute so a column keeps its width; negative,
|
|
6
|
+
* non-finite or missing input reads `0s` (missing → `""`).
|
|
7
|
+
* - `report` — a compact total in prose: `850ms`, `45s`, `1m 18s`, `1h 05m`.
|
|
8
|
+
*
|
|
9
|
+
* Pure leaf module: no imports, so the dashboard bundle and the Workers share
|
|
10
|
+
* it byte for byte with the bot. */
|
|
11
|
+
|
|
12
|
+
export type DurationStyle = "precise" | "clock" | "report";
|
|
13
|
+
|
|
14
|
+
export function formatDuration(ms: number | undefined, style: DurationStyle): string {
|
|
15
|
+
if (ms === undefined) return "";
|
|
16
|
+
switch (style) {
|
|
17
|
+
case "precise":
|
|
18
|
+
return precise(ms);
|
|
19
|
+
case "clock":
|
|
20
|
+
return clock(ms);
|
|
21
|
+
case "report":
|
|
22
|
+
return report(ms);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function precise(ms: number): string {
|
|
27
|
+
if (!(ms >= 0)) return "0ms";
|
|
28
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
29
|
+
// Round to the unit the branch prints BEFORE choosing the branch, so a value
|
|
30
|
+
// that rounds up to the next unit carries (59.95 s → `1m 00s`, never `60.0s`;
|
|
31
|
+
// 119.5 s → `2m 00s`, never `1m 60s`).
|
|
32
|
+
const tenths = Math.round(ms / 100);
|
|
33
|
+
if (tenths < 600) return `${(tenths / 10).toFixed(1)}s`;
|
|
34
|
+
const seconds = Math.round(ms / 1000);
|
|
35
|
+
return `${Math.floor(seconds / 60)}m ${pad(seconds % 60)}s`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function clock(ms: number): string {
|
|
39
|
+
if (!(ms > 0)) return "0s";
|
|
40
|
+
const s = Math.floor(ms / 1000);
|
|
41
|
+
if (s < 60) return `${s}s`;
|
|
42
|
+
const m = Math.floor(s / 60);
|
|
43
|
+
if (m < 60) return `${m}m ${pad(s % 60)}s`;
|
|
44
|
+
const h = Math.floor(m / 60);
|
|
45
|
+
return `${h}h ${pad(m % 60)}m`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function report(ms: number): string {
|
|
49
|
+
if (!(ms >= 0)) return "0ms";
|
|
50
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
51
|
+
const s = Math.round(ms / 1000);
|
|
52
|
+
if (s < 60) return `${s}s`;
|
|
53
|
+
const m = Math.floor(s / 60);
|
|
54
|
+
if (m < 60) return `${m}m ${pad(s % 60)}s`;
|
|
55
|
+
const h = Math.floor(m / 60);
|
|
56
|
+
return `${h}h ${pad(m % 60)}m`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function pad(n: number): string {
|
|
60
|
+
return n < 10 ? `0${n}` : String(n);
|
|
61
|
+
}
|