@coreplane/switchboard 1.239.0 → 1.241.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 +32 -7
- package/dist/assets/deploy/cloudflare/worker.ts +77 -18
- package/dist/assets/deploy/cloudflare-memory/worker.ts +115 -1
- package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +7 -1
- package/dist/assets/deploy/cloudflare-resident/worker.ts +224 -70
- package/dist/assets/deploy/secrets.manifest.json +7 -1
- package/dist/assets/package-lock.json +207 -3
- package/dist/assets/package.json +2 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +27 -8
- package/dist/assets/src/core/authz/actor.ts +38 -2
- package/dist/assets/src/core/authz/grants.ts +20 -9
- package/dist/assets/src/core/authz/policy.ts +6 -0
- package/dist/assets/src/core/coordinator/contract.ts +49 -1
- package/dist/assets/src/core/coordinator/driver.ts +30 -1
- package/dist/assets/src/core/coordinator/instancesRoute.ts +72 -0
- package/dist/assets/src/core/costs.ts +1119 -0
- package/dist/assets/src/core/costsSnapshotStore.ts +209 -0
- package/dist/assets/src/core/delivery.ts +4 -11
- package/dist/assets/src/core/deliverySnapshotStore.ts +12 -28
- package/dist/assets/src/core/ingressTokens.ts +27 -13
- package/dist/assets/src/core/runLedger/types.ts +3 -0
- package/dist/assets/src/core/runRecord.ts +10 -3
- package/dist/assets/src/core/schedules.ts +2 -1
- package/dist/assets/src/core/ship/coordinator.ts +92 -7
- package/dist/assets/src/core/snapshotAge.ts +30 -0
- package/dist/assets/src/core/stateWorkerRef.ts +41 -0
- package/dist/assets/src/core/trace/attrs.ts +1 -1
- package/dist/assets/src/execution/bindingPurge.ts +2 -2
- package/dist/assets/src/execution/residentAutoRebuild.ts +73 -0
- package/dist/assets/src/execution/residentRefresh.ts +25 -3
- package/dist/assets/src/execution/restoreWaiters.ts +61 -0
- package/dist/assets/src/mcp/registry.ts +7 -0
- package/dist/assets/web/dist/.vite/manifest.json +509 -427
- package/dist/assets/web/dist/assets/AppShell-DEy5jRuS.js +1 -0
- package/dist/assets/web/dist/assets/CostsPage-BuHD-Bv7.js +2 -0
- package/dist/assets/web/dist/assets/DeliveryPage-BGpPr5xr.js +1 -0
- package/dist/assets/web/dist/assets/HomePage-dKBvWh-E.js +2 -0
- package/dist/assets/web/dist/assets/NotFoundPage-DtmQM2gD.js +1 -0
- package/dist/assets/web/dist/assets/PendingTurnRow-CrEAWx5b.js +1 -0
- package/dist/assets/web/dist/assets/{ResidentDetailPage-CrfCd8fx.js → ResidentDetailPage-C-48fxat.js} +1 -1
- package/dist/assets/web/dist/assets/ResidentsIndexPage-CBBIjPOa.js +1 -0
- package/dist/assets/web/dist/assets/RunFoldRow-B6vxjcBI.js +1 -0
- package/dist/assets/web/dist/assets/RunRoutePage-CQjd3Cz-.js +6 -0
- package/dist/assets/web/dist/assets/RunsIndexPage-Cy9G6D0p.js +1 -0
- package/dist/assets/web/dist/assets/{RunsTabs-XpCqeWu4.js → RunsTabs-CtNGNnhN.js} +1 -1
- package/dist/assets/web/dist/assets/ScheduledPage-Ci1Ygqnc.js +1 -0
- package/dist/assets/web/dist/assets/SettingsPage-DxmJvAmT.js +1 -0
- package/dist/assets/web/dist/assets/SlackMark-VDNs7Vjh.js +1 -0
- package/dist/assets/web/dist/assets/{StatusDot-DkXALvKf.js → StatusDot-DPWE1JmO.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-DtLIyi5P.js → Tooltip-DKhSRH9t.js} +1 -1
- package/dist/assets/web/dist/assets/UnitRoutePage-DEN5bSQf.js +1 -0
- package/dist/assets/web/dist/assets/{angular-html-DYtV9uIN.js → angular-html-DgSK1qvr.js} +1 -1
- package/dist/assets/web/dist/assets/{angular-ts-DF2P25Zp.js → angular-ts-D3gNdiSG.js} +1 -1
- package/dist/assets/web/dist/assets/{apl-Ff96wxko.js → apl-CkHCYM8I.js} +1 -1
- package/dist/assets/web/dist/assets/{astro-LbBnZna-.js → astro-DLm45axt.js} +1 -1
- package/dist/assets/web/dist/assets/{blade-DltuxeBW.js → blade-BKa-VE-c.js} +1 -1
- package/dist/assets/web/dist/assets/{c-DBg7yuPx.js → c-C8NCNWai.js} +1 -1
- package/dist/assets/web/dist/assets/{chapel-C83hUAds.js → chapel-DqDQ7IKH.js} +1 -1
- package/dist/assets/web/dist/assets/{cobol-DB0yV_2D.js → cobol-j4vCD6AJ.js} +1 -1
- package/dist/assets/web/dist/assets/{coffee-Bpih6Kzl.js → coffee-C6oDVH-0.js} +1 -1
- package/dist/assets/web/dist/assets/{cpp-CU00ZeRn.js → cpp-Bd3A1Rtc.js} +1 -1
- package/dist/assets/web/dist/assets/{crystal-f8ImJkux.js → crystal-CUOgOw0d.js} +1 -1
- package/dist/assets/web/dist/assets/{css-4VrC4ISo.js → css-RYljyv7G.js} +1 -1
- package/dist/assets/web/dist/assets/delivery-CTdOqpVK.js +1 -0
- package/dist/assets/web/dist/assets/{dist-DtA7JiH5.js → dist-BnJXKHCY.js} +2 -2
- package/dist/assets/web/dist/assets/durationTone-DXG-3R7_.js +1 -0
- package/dist/assets/web/dist/assets/{edge-BwFDzfS_.js → edge-BJhNdQDc.js} +1 -1
- package/dist/assets/web/dist/assets/{elixir-wwIYIpJm.js → elixir-g1AWOAfT.js} +1 -1
- package/dist/assets/web/dist/assets/{elm-DyDuM3v3.js → elm-CER5e4Dz.js} +1 -1
- package/dist/assets/web/dist/assets/{erb-DFJBEVrh.js → erb-D5aOmMQf.js} +1 -1
- package/dist/assets/web/dist/assets/eventSource-tRprhqm7.js +1 -0
- package/dist/assets/web/dist/assets/favicon-D1hkyC5j.js +1 -0
- package/dist/assets/web/dist/assets/{git-rebase-CuDv-44X.js → git-rebase-D6rfV8jp.js} +1 -1
- package/dist/assets/web/dist/assets/{glimmer-js-ZFnrm4J3.js → glimmer-js-BvDH3-mC.js} +1 -1
- package/dist/assets/web/dist/assets/{glimmer-ts-XY4Yqshp.js → glimmer-ts-DChtNs9V.js} +1 -1
- package/dist/assets/web/dist/assets/{glsl-xnR8efx0.js → glsl-BHlsWrxf.js} +1 -1
- package/dist/assets/web/dist/assets/{graphql-D-gO5tnP.js → graphql-BsaBHb53.js} +1 -1
- package/dist/assets/web/dist/assets/{hack-CSxbW4ax.js → hack-0lbDosCd.js} +1 -1
- package/dist/assets/web/dist/assets/{haml-D4ar42QT.js → haml-BKtco2vr.js} +1 -1
- package/dist/assets/web/dist/assets/{handlebars-CMLbDE7o.js → handlebars-BE_cj04z.js} +1 -1
- package/dist/assets/web/dist/assets/{html-CVekU4Fs.js → html-BNcr8EVd.js} +1 -1
- package/dist/assets/web/dist/assets/{html-derivative-St3HHw_N.js → html-derivative-BsJt2Kei.js} +1 -1
- package/dist/assets/web/dist/assets/{http-Mon0DWHS.js → http-BR5P8ER_.js} +1 -1
- package/dist/assets/web/dist/assets/{hurl-Cbl5IvBs.js → hurl-CtHalpWS.js} +1 -1
- package/dist/assets/web/dist/assets/{localIso-CNA-bhS-.js → indexFormat-B-pd4r7Y.js} +1 -1
- package/dist/assets/web/dist/assets/{indexRow-FLJu5mR9.js → indexRow-BD1VT8o8.js} +1 -1
- package/dist/assets/web/dist/assets/{java-Bprck1l-.js → java-BXVdhN11.js} +1 -1
- package/dist/assets/web/dist/assets/{javascript-C73G1-UR.js → javascript-CyoShgNQ.js} +1 -1
- package/dist/assets/web/dist/assets/{jinja-C4DuRxYJ.js → jinja-BQYZzHex.js} +1 -1
- package/dist/assets/web/dist/assets/{jison-B2U03GEC.js → jison-DqrUa1Eq.js} +1 -1
- package/dist/assets/web/dist/assets/{json-CvTZUDzx.js → json-CQiTF9Jj.js} +1 -1
- package/dist/assets/web/dist/assets/{jsx-DMWKCzwo.js → jsx-Y16MkCYQ.js} +1 -1
- package/dist/assets/web/dist/assets/{julia-Czn7bV_3.js → julia-zn16YONe.js} +1 -1
- package/dist/assets/web/dist/assets/{just-BBxpNfvC.js → just-DcVeN_MN.js} +1 -1
- package/dist/assets/web/dist/assets/{latex-Bw9yg5mr.js → latex-BNNoF-WP.js} +1 -1
- package/dist/assets/web/dist/assets/{liquid-BRCjaydE.js → liquid-DAi6qHzn.js} +1 -1
- package/dist/assets/web/dist/assets/localIso-L06jV29p.js +1 -0
- package/dist/assets/web/dist/assets/{lua-DUu2KIC0.js → lua-BswS0axw.js} +1 -1
- package/dist/assets/web/dist/assets/main-B3is4sk6.js +28 -0
- package/dist/assets/web/dist/assets/main-DP_zSemY.css +1 -0
- package/dist/assets/web/dist/assets/{marko-CZJZIlSQ.js → marko-C-4hkmQZ.js} +1 -1
- package/dist/assets/web/dist/assets/{mdc-BmClq6fd.js → mdc-0ZaHUkS7.js} +1 -1
- package/dist/assets/web/dist/assets/{nginx-Bmt3xSEm.js → nginx-DBDg5tOR.js} +1 -1
- package/dist/assets/web/dist/assets/{nim-Da-i0utK.js → nim-BVMz49Yw.js} +1 -1
- package/dist/assets/web/dist/assets/{org-BvAre2qP.js → org-1hO_K6ya.js} +1 -1
- package/dist/assets/web/dist/assets/{perl-v9WwFFtB.js → perl-CVRQLvVk.js} +1 -1
- package/dist/assets/web/dist/assets/{php-DTU4Zyze.js → php-K9nCrB9n.js} +1 -1
- package/dist/assets/web/dist/assets/{pug-DhTwEqTO.js → pug-CTxOcmO3.js} +1 -1
- package/dist/assets/web/dist/assets/{qml-BUD9AC_c.js → qml-CmCkfG5q.js} +1 -1
- package/dist/assets/web/dist/assets/{r-CAnu56n7.js → r-pP47Xn1X.js} +1 -1
- package/dist/assets/web/dist/assets/{razor-Dn0P4gAq.js → razor-BpW6r3tC.js} +1 -1
- package/dist/assets/web/dist/assets/{regexp-CZyBRaVW.js → regexp-CINgdY4N.js} +1 -1
- package/dist/assets/web/dist/assets/{rst-Ckyd5UsY.js → rst-BZIUEq2Q.js} +1 -1
- package/dist/assets/web/dist/assets/{ruby-BBQ62fsx.js → ruby-nzAOxz6r.js} +1 -1
- package/dist/assets/web/dist/assets/{sas-CW35hMxG.js → sas-QIS1bFth.js} +1 -1
- package/dist/assets/web/dist/assets/{scss-CpV8pF4J.js → scss-BuhBBVNt.js} +1 -1
- package/dist/assets/web/dist/assets/settingsApi-priOPiwu.js +1 -0
- package/dist/assets/web/dist/assets/{shellscript-CRJvsmE1.js → shellscript-CQc1vXbk.js} +1 -1
- package/dist/assets/web/dist/assets/{shellsession-CyriUZxf.js → shellsession-CoubCAUv.js} +1 -1
- package/dist/assets/web/dist/assets/{soy-D5QzYUeg.js → soy-CPRzlder.js} +1 -1
- package/dist/assets/web/dist/assets/{sql-CUBf_Mse.js → sql-W9krb8-9.js} +1 -1
- package/dist/assets/web/dist/assets/sseReplay-yji8a1wM.js +9 -0
- package/dist/assets/web/dist/assets/{stata-B-4n4ITz.js → stata-CuISJEC0.js} +1 -1
- package/dist/assets/web/dist/assets/{surrealql-RuWZBLtr.js → surrealql-CSet7584.js} +1 -1
- package/dist/assets/web/dist/assets/{svelte-CO5YzCsK.js → svelte-ChXTSYwK.js} +1 -1
- package/dist/assets/web/dist/assets/{templ-DbkWVaBF.js → templ-Bm55v62k.js} +1 -1
- package/dist/assets/web/dist/assets/{tex-IuxoEcId.js → tex-CICMX8Gj.js} +1 -1
- package/dist/assets/web/dist/assets/{ts-tags-g6dGQPjd.js → ts-tags-CA1UzWyB.js} +1 -1
- package/dist/assets/web/dist/assets/{tsx-WnXwiyH2.js → tsx-Dy04HNbv.js} +1 -1
- package/dist/assets/web/dist/assets/{twig-Lx1G7PnY.js → twig-BBHsVnVD.js} +1 -1
- package/dist/assets/web/dist/assets/{typescript-BQd7biLF.js → typescript-Dvc-wVBT.js} +1 -1
- package/dist/assets/web/dist/assets/{typst-D2lXPHK7.js → typst-Dvlqx3_q.js} +1 -1
- package/dist/assets/web/dist/assets/{vue-html-DZPaRte4.js → vue-html-D4YxT3An.js} +1 -1
- package/dist/assets/web/dist/assets/{vue-vine-DbmZCmu-.js → vue-vine-0YE7uQlH.js} +1 -1
- package/dist/assets/web/dist/assets/{vue-4wVQD9y9.js → vue-zWi1MNU2.js} +1 -1
- package/dist/assets/web/dist/assets/{xml-DV16szyo.js → xml-gm-iksZB.js} +1 -1
- package/dist/assets/web/dist/assets/{xsl-eo5-1sk0.js → xsl-D_W5BcrY.js} +1 -1
- package/dist/assets/web/dist/assets/{yaml-BXdX8HZf.js → yaml-CHmZ21wZ.js} +1 -1
- package/dist/cli.js +6014 -2362
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/AppShell-MYHNxJks.js +0 -1
- package/dist/assets/web/dist/assets/CostsPage-DKk9OjB0.js +0 -2
- package/dist/assets/web/dist/assets/DeliveryPage-Kz6js49i.js +0 -1
- package/dist/assets/web/dist/assets/NotFoundPage-BXLrQQzy.js +0 -1
- package/dist/assets/web/dist/assets/ResidentsIndexPage-BdW0k2h3.js +0 -1
- package/dist/assets/web/dist/assets/RunFoldRow-CbTd9SiS.js +0 -9
- package/dist/assets/web/dist/assets/RunRoutePage-br0H-8Pz.js +0 -6
- package/dist/assets/web/dist/assets/RunsIndexPage-Bnzb15zH.js +0 -1
- package/dist/assets/web/dist/assets/ScheduledPage-NLhjzHAz.js +0 -1
- package/dist/assets/web/dist/assets/SettingsPage-D-i0uVw-.js +0 -1
- package/dist/assets/web/dist/assets/UnitRoutePage-DGXukEAH.js +0 -1
- package/dist/assets/web/dist/assets/durationTone-CYMux25J.js +0 -1
- package/dist/assets/web/dist/assets/favicon-CSt-qDvc.js +0 -1
- package/dist/assets/web/dist/assets/main-CFHF2RvQ.css +0 -1
- package/dist/assets/web/dist/assets/main-Dht0in4v.js +0 -28
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { CloudflareUsage, DateRange, LlmCostRow } from "./costs.js";
|
|
2
|
+
import { isRunUsageReport, type RunUsageReport } from "./runUsage.js";
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_STATE_WORKER_TOKEN_ENV,
|
|
5
|
+
STATE_WORKER_BLOCK_NAMES,
|
|
6
|
+
stateWorkerOf,
|
|
7
|
+
type SecretReader,
|
|
8
|
+
type StateWorkerBlocks,
|
|
9
|
+
} from "./stateWorkerRef.js";
|
|
10
|
+
import { errorSuffix } from "./workerError.js";
|
|
11
|
+
|
|
12
|
+
// Where the costs snapshot lives (docs/reference/specs/costs.md item 6): the
|
|
13
|
+
// billing sources' answers over the page's widest range — Cloudflare's usage
|
|
14
|
+
// rows, Anthropic's daily cost rows, the run history's per-user usage — as the
|
|
15
|
+
// sources gave them, and when they were read. ONE snapshot per installation:
|
|
16
|
+
// the Cloudflare read is account-wide and the LLM rows carry every workspace,
|
|
17
|
+
// so every configured group's report is arithmetic over the same document.
|
|
18
|
+
// Two implementations of one seam (AGENTS.md invariant 2):
|
|
19
|
+
//
|
|
20
|
+
// - `WorkerCostsSnapshotStore` — the production choice — an HTTPS client to
|
|
21
|
+
// the `CostsSnapshotDO` on the state Worker (deploy/cloudflare-memory/, one
|
|
22
|
+
// SQLite Durable Object, the same MEMORY_TOKEN bearer as memory, run history
|
|
23
|
+
// and the delivery snapshot), so a bot restart keeps the snapshot
|
|
24
|
+
// (invariant 6) and the first request after one is served, not computed.
|
|
25
|
+
// - `InMemoryCostsSnapshotStore` — tests, and the process without a state
|
|
26
|
+
// Worker: the snapshot lives as long as the process does.
|
|
27
|
+
//
|
|
28
|
+
// Node-free on purpose: the state Worker imports `isCostsSnapshot` by
|
|
29
|
+
// relative path so the bot and the Worker validate ONE shape.
|
|
30
|
+
//
|
|
31
|
+
// Route contract (JSON in/out, bearer = the Worker's MEMORY_TOKEN):
|
|
32
|
+
// POST /costs/snapshot/get {} → {snapshot: CostsSnapshot | null}
|
|
33
|
+
// POST /costs/snapshot/put {snapshot} → {ok: true} replace the snapshot whole
|
|
34
|
+
|
|
35
|
+
/** The billing sources' answers over the snapshot window, and when they were read. */
|
|
36
|
+
export interface CostsSnapshot {
|
|
37
|
+
/** ISO 8601 — when the read began. */
|
|
38
|
+
takenAt: string;
|
|
39
|
+
/** Who asked for it: `schedule` for the refresh loop, else the caller's label. */
|
|
40
|
+
takenBy: string;
|
|
41
|
+
/** How long the read took, both sources and the run history together. */
|
|
42
|
+
durationMs: number;
|
|
43
|
+
/** The window the rows were read for: `MAX_DAYS` UTC days ending on the take day. */
|
|
44
|
+
range: DateRange;
|
|
45
|
+
/** Every meter Cloudflare bills on, account-wide, as `CloudflareUsageSource` answered. */
|
|
46
|
+
usage: CloudflareUsage;
|
|
47
|
+
/** Anthropic's daily cost rows (the open days estimated); null when no LLM source is configured. */
|
|
48
|
+
llm: LlmCostRow[] | null;
|
|
49
|
+
/** The run history's per-user usage over the window; null with run history off. */
|
|
50
|
+
runUsage: RunUsageReport | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
54
|
+
const isInstant = (v: unknown): v is string => typeof v === "string" && Number.isFinite(Date.parse(v));
|
|
55
|
+
const isFiniteNumber = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
|
56
|
+
|
|
57
|
+
/** The datasets every `CloudflareUsage` carries — the guard checks each is rows with a day. */
|
|
58
|
+
const USAGE_DATASETS = [
|
|
59
|
+
"containers",
|
|
60
|
+
"durableObjectRequests",
|
|
61
|
+
"durableObjectDays",
|
|
62
|
+
"durableObjectStorage",
|
|
63
|
+
"workers",
|
|
64
|
+
"r2Storage",
|
|
65
|
+
"r2Operations",
|
|
66
|
+
"workflows",
|
|
67
|
+
] as const;
|
|
68
|
+
|
|
69
|
+
function isDatedRows(v: unknown): boolean {
|
|
70
|
+
return (
|
|
71
|
+
Array.isArray(v) &&
|
|
72
|
+
v.every((row) => typeof row === "object" && row !== null && ISO_DAY.test(String((row as { date?: unknown }).date)))
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isDateRange(v: unknown): v is DateRange {
|
|
77
|
+
if (typeof v !== "object" || v === null) return false;
|
|
78
|
+
const r = v as Record<string, unknown>;
|
|
79
|
+
return (
|
|
80
|
+
typeof r.from === "string" &&
|
|
81
|
+
ISO_DAY.test(r.from) &&
|
|
82
|
+
typeof r.to === "string" &&
|
|
83
|
+
ISO_DAY.test(r.to) &&
|
|
84
|
+
isFiniteNumber(r.days) &&
|
|
85
|
+
typeof r.partialLastDay === "boolean"
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isLlmRows(v: unknown): v is LlmCostRow[] {
|
|
90
|
+
return (
|
|
91
|
+
Array.isArray(v) &&
|
|
92
|
+
v.every((row) => {
|
|
93
|
+
if (typeof row !== "object" || row === null) return false;
|
|
94
|
+
const r = row as Record<string, unknown>;
|
|
95
|
+
return (
|
|
96
|
+
typeof r.date === "string" &&
|
|
97
|
+
ISO_DAY.test(r.date) &&
|
|
98
|
+
(r.workspaceId === null || typeof r.workspaceId === "string") &&
|
|
99
|
+
isFiniteNumber(r.amountUsd)
|
|
100
|
+
);
|
|
101
|
+
})
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The fields the arithmetic reads, present and of the right kind; the rows are the sources'. */
|
|
106
|
+
export function isCostsSnapshot(v: unknown): v is CostsSnapshot {
|
|
107
|
+
if (typeof v !== "object" || v === null) return false;
|
|
108
|
+
const s = v as Record<string, unknown>;
|
|
109
|
+
if (!isInstant(s.takenAt) || typeof s.takenBy !== "string") return false;
|
|
110
|
+
if (!isFiniteNumber(s.durationMs) || s.durationMs < 0) return false;
|
|
111
|
+
if (!isDateRange(s.range)) return false;
|
|
112
|
+
const usage = s.usage as Record<string, unknown> | null | undefined;
|
|
113
|
+
if (typeof usage !== "object" || usage === null) return false;
|
|
114
|
+
if (!USAGE_DATASETS.every((name) => isDatedRows(usage[name]))) return false;
|
|
115
|
+
if (s.llm !== null && !isLlmRows(s.llm)) return false;
|
|
116
|
+
return s.runUsage === null || isRunUsageReport(s.runUsage);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface CostsSnapshotStore {
|
|
120
|
+
/** The stored snapshot, or undefined when none was ever stored. Throws on failure. */
|
|
121
|
+
get(): Promise<CostsSnapshot | undefined>;
|
|
122
|
+
/** Replace the snapshot whole. Throws on failure; callers warn, never crash. */
|
|
123
|
+
put(snapshot: CostsSnapshot): Promise<void>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export class InMemoryCostsSnapshotStore implements CostsSnapshotStore {
|
|
127
|
+
private stored: CostsSnapshot | undefined;
|
|
128
|
+
|
|
129
|
+
async get(): Promise<CostsSnapshot | undefined> {
|
|
130
|
+
return this.stored ? structuredClone(this.stored) : undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async put(snapshot: CostsSnapshot): Promise<void> {
|
|
134
|
+
this.stored = structuredClone(snapshot);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Per-request ceiling: a ninety-day snapshot is a few hundred KB each way. */
|
|
139
|
+
export const COSTS_SNAPSHOT_WORKER_TIMEOUT_MS = 30_000;
|
|
140
|
+
|
|
141
|
+
export interface WorkerCostsSnapshotStoreOptions {
|
|
142
|
+
/** Base URL of the state Worker. */
|
|
143
|
+
baseUrl: string;
|
|
144
|
+
/** Bearer secret (MEMORY_TOKEN on the Worker). */
|
|
145
|
+
token: string;
|
|
146
|
+
/** Injectable for tests; defaults to global fetch. */
|
|
147
|
+
fetch?: typeof fetch;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export class WorkerCostsSnapshotStore implements CostsSnapshotStore {
|
|
151
|
+
private readonly baseUrl: string;
|
|
152
|
+
private readonly fetchImpl: typeof fetch;
|
|
153
|
+
|
|
154
|
+
constructor(private readonly opts: WorkerCostsSnapshotStoreOptions) {
|
|
155
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
156
|
+
this.fetchImpl = opts.fetch ?? fetch;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async get(): Promise<CostsSnapshot | undefined> {
|
|
160
|
+
const res = await this.post("/costs/snapshot/get", {});
|
|
161
|
+
if (!res.ok) throw new Error(`state Worker /costs/snapshot/get HTTP ${res.status}${await errorSuffix(res)}`);
|
|
162
|
+
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
163
|
+
if (data.snapshot === null || data.snapshot === undefined) return undefined;
|
|
164
|
+
if (!isCostsSnapshot(data.snapshot))
|
|
165
|
+
throw new Error("state Worker /costs/snapshot/get: the answer is not a costs snapshot");
|
|
166
|
+
return data.snapshot;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async put(snapshot: CostsSnapshot): Promise<void> {
|
|
170
|
+
const res = await this.post("/costs/snapshot/put", { snapshot });
|
|
171
|
+
if (!res.ok) throw new Error(`state Worker /costs/snapshot/put HTTP ${res.status}${await errorSuffix(res)}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private post(path: string, body: unknown): Promise<Response> {
|
|
175
|
+
return this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${this.opts.token}` },
|
|
178
|
+
body: JSON.stringify(body),
|
|
179
|
+
signal: AbortSignal.timeout(COSTS_SNAPSHOT_WORKER_TIMEOUT_MS),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Process-startup store selection (src/index.ts): the Worker store behind
|
|
185
|
+
* whichever `*.worker` block names the state Worker — the snapshot is a cache,
|
|
186
|
+
* so it earns no block of its own — else the in-memory store with a warning
|
|
187
|
+
* naming what a restart then costs. Pure w.r.t. the environment. */
|
|
188
|
+
export function buildCostsSnapshotStore(
|
|
189
|
+
config: StateWorkerBlocks,
|
|
190
|
+
secrets: SecretReader,
|
|
191
|
+
warn: (message: string) => void,
|
|
192
|
+
): CostsSnapshotStore {
|
|
193
|
+
const worker = stateWorkerOf(config);
|
|
194
|
+
if (!worker) {
|
|
195
|
+
warn(
|
|
196
|
+
`the costs snapshot is kept in memory — a restart loses it and the costs page has nothing to show until the next one is taken. Name the state Worker (${STATE_WORKER_BLOCK_NAMES}) to keep it there.`,
|
|
197
|
+
);
|
|
198
|
+
return new InMemoryCostsSnapshotStore();
|
|
199
|
+
}
|
|
200
|
+
const tokenEnv = worker.tokenEnv ?? DEFAULT_STATE_WORKER_TOKEN_ENV;
|
|
201
|
+
const token = secrets.named(tokenEnv);
|
|
202
|
+
if (!token) {
|
|
203
|
+
warn(
|
|
204
|
+
`the state Worker is configured but ${tokenEnv} is unset — the costs snapshot is kept in memory and a restart loses it. Set ${tokenEnv} to the state Worker's bearer.`,
|
|
205
|
+
);
|
|
206
|
+
return new InMemoryCostsSnapshotStore();
|
|
207
|
+
}
|
|
208
|
+
return new WorkerCostsSnapshotStore({ baseUrl: worker.baseUrl, token: token.reveal() });
|
|
209
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { snapshotAgeText } from "./snapshotAge.js";
|
|
1
2
|
import { systemClock } from "./trace/clock.js";
|
|
2
3
|
|
|
3
4
|
// Delivery indicators: what the run history and the pull requests' own facts
|
|
@@ -553,17 +554,9 @@ function indicatorLines(x: DeliveryIndicators): string[] {
|
|
|
553
554
|
];
|
|
554
555
|
}
|
|
555
556
|
|
|
556
|
-
/** `just now`, `12 minutes ago`, `3 hours ago`, `2 days ago` — how old the snapshot is, for a footer or a head line
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
if (!Number.isFinite(minutes)) return "";
|
|
560
|
-
const unit = (n: number, word: string): string => `${n} ${word}${n === 1 ? "" : "s"} ago`;
|
|
561
|
-
if (minutes < 1) return "just now";
|
|
562
|
-
if (minutes < 90) return unit(minutes, "minute");
|
|
563
|
-
const hours = Math.round(minutes / 60);
|
|
564
|
-
if (hours < 48) return unit(hours, "hour");
|
|
565
|
-
return unit(Math.round(hours / 24), "day");
|
|
566
|
-
}
|
|
557
|
+
/** `just now`, `12 minutes ago`, `3 hours ago`, `2 days ago` — how old the snapshot is, for a footer or a head line
|
|
558
|
+
* (src/core/snapshotAge.ts, shared with the costs page). */
|
|
559
|
+
export { snapshotAgeText };
|
|
567
560
|
|
|
568
561
|
/** `<YYYY-MM-DD> <hh:mm> UTC` — an instant as the text spells one. */
|
|
569
562
|
const clock = (iso: string): string => `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import { REPO_SLUG, type DeliveryRange, type PullRequestFacts } from "./delivery.js";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_STATE_WORKER_TOKEN_ENV,
|
|
4
|
+
STATE_WORKER_BLOCK_NAMES,
|
|
5
|
+
stateWorkerOf,
|
|
6
|
+
type SecretReader,
|
|
7
|
+
type StateWorkerBlocks,
|
|
8
|
+
} from "./stateWorkerRef.js";
|
|
2
9
|
import { errorSuffix } from "./workerError.js";
|
|
3
10
|
|
|
11
|
+
export type { SecretReader, StateWorkerBlocks, StateWorkerRef } from "./stateWorkerRef.js";
|
|
12
|
+
|
|
4
13
|
// Where a repository's delivery snapshot lives (docs/reference/specs/delivery.md item 10):
|
|
5
14
|
// the merged pull requests' facts over the snapshot window, as GitHub gave
|
|
6
15
|
// them, and when they were read. Two implementations of one seam (AGENTS.md
|
|
@@ -207,29 +216,6 @@ export class WorkerDeliverySnapshotStore implements DeliverySnapshotStore {
|
|
|
207
216
|
}
|
|
208
217
|
}
|
|
209
218
|
|
|
210
|
-
/** A `{ baseUrl, tokenEnv? }` reference to the state Worker, as every `*.worker` config block spells it. */
|
|
211
|
-
export interface StateWorkerRef {
|
|
212
|
-
baseUrl: string;
|
|
213
|
-
/** Env var holding the bearer; default MEMORY_TOKEN. */
|
|
214
|
-
tokenEnv?: string;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/** The config blocks that may name the state Worker — one Worker, referenced from each capability that uses it. */
|
|
218
|
-
export interface StateWorkerBlocks {
|
|
219
|
-
runtimeOverrides?: { worker?: StateWorkerRef };
|
|
220
|
-
runHistory?: { worker?: StateWorkerRef };
|
|
221
|
-
schedules?: { worker?: StateWorkerRef };
|
|
222
|
-
memory?: { worker?: StateWorkerRef };
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const DEFAULT_TOKEN_ENV = "MEMORY_TOKEN";
|
|
226
|
-
|
|
227
|
-
/** The slice of the process's `Secrets` the builder reads (src/secrets.ts; named
|
|
228
|
-
* structurally so this module stays free of the bot's Node-only imports for the Worker). */
|
|
229
|
-
export interface SecretReader {
|
|
230
|
-
named(name: string): { reveal(): string } | undefined;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
219
|
/** Process-startup store selection (src/index.ts, the CLI): the Worker store
|
|
234
220
|
* behind whichever `*.worker` block names the state Worker — the snapshot is
|
|
235
221
|
* a cache, so it earns no block of its own — else the in-memory store with a
|
|
@@ -239,16 +225,14 @@ export function buildDeliverySnapshotStore(
|
|
|
239
225
|
secrets: SecretReader,
|
|
240
226
|
warn: (message: string) => void,
|
|
241
227
|
): DeliverySnapshotStore {
|
|
242
|
-
const worker =
|
|
243
|
-
(block) => block?.worker?.baseUrl,
|
|
244
|
-
)?.worker;
|
|
228
|
+
const worker = stateWorkerOf(config);
|
|
245
229
|
if (!worker) {
|
|
246
230
|
warn(
|
|
247
|
-
|
|
231
|
+
`delivery snapshots are kept in memory — a restart loses them and the first request after one reads GitHub live. Name the state Worker (${STATE_WORKER_BLOCK_NAMES}) to keep them on it.`,
|
|
248
232
|
);
|
|
249
233
|
return new InMemoryDeliverySnapshotStore();
|
|
250
234
|
}
|
|
251
|
-
const tokenEnv = worker.tokenEnv ??
|
|
235
|
+
const tokenEnv = worker.tokenEnv ?? DEFAULT_STATE_WORKER_TOKEN_ENV;
|
|
252
236
|
const token = secrets.named(tokenEnv);
|
|
253
237
|
if (!token) {
|
|
254
238
|
warn(
|
|
@@ -2,22 +2,29 @@
|
|
|
2
2
|
// (src/channels/http.ts, src/channels/mcp.ts) and the Cloudflare Worker shim
|
|
3
3
|
// (deploy/cloudflare/worker.ts, which fires scheduled runs through /ingress)
|
|
4
4
|
// read `SWITCHBOARD_INGRESS_TOKENS` with ONE parser. The shape is
|
|
5
|
-
// {"<raw bearer token>": {"subject": "alice", "channel": "ops"}, ...}
|
|
5
|
+
// {"<raw bearer token>": {"subject": "alice", "channel": "ops", "email": "alice@example.com"}, ...}
|
|
6
6
|
// A token is a CREDENTIAL, nothing more: what its bearer may do — start a run
|
|
7
7
|
// (`dispatch`), read runs, anything else — is the `grants` entry for
|
|
8
8
|
// `http:<subject>` / `mcp:<subject>` in config.yaml (docs/reference/specs/authorization.md
|
|
9
|
-
// item 9).
|
|
10
|
-
//
|
|
11
|
-
//
|
|
9
|
+
// item 9). `email` names the PERSON the credential belongs to (identity, never
|
|
10
|
+
// authority: the run is theirs, the grants stay the credential's —
|
|
11
|
+
// authorization.md item 15). Absent, empty, or malformed => an empty map.
|
|
12
|
+
// Callers treat an empty map as "ingress disabled" (fail-closed) — this module
|
|
13
|
+
// never decides that, it only parses. Token material is never logged here; the
|
|
14
|
+
// caller may log the reason.
|
|
12
15
|
|
|
13
|
-
/** The identity a token maps to. `subject` becomes the platform-namespaced
|
|
14
|
-
* id (`http:<subject>`); an optional `channel` names the channel a
|
|
15
|
-
* through this token is recorded under (`http:<channel>`) — routing,
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
/** The identity a token maps to. `subject` becomes the platform-namespaced
|
|
17
|
+
* credential id (`http:<subject>`); an optional `channel` names the channel a
|
|
18
|
+
* dispatch through this token is recorded under (`http:<channel>`) — routing,
|
|
19
|
+
* not a grant; an optional `email` names the person the credential is bound
|
|
20
|
+
* to, resolved to their Slack identity by the adapter (`boundRequester`) so
|
|
21
|
+
* the run is requested by the person and the credential is `authenticatedAs`.
|
|
22
|
+
* These three are the whole identity: any other field in an entry is ignored,
|
|
23
|
+
* so nothing in the token map can widen what the grants entry says. */
|
|
18
24
|
export interface IngressIdentity {
|
|
19
25
|
subject: string;
|
|
20
26
|
channel?: string;
|
|
27
|
+
email?: string;
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
export type IngressTokenMap = Record<string, IngressIdentity>;
|
|
@@ -28,9 +35,10 @@ export type ParsedIngressTokens =
|
|
|
28
35
|
| { ok: false; reason: string; tokens: IngressTokenMap };
|
|
29
36
|
|
|
30
37
|
/** Parse the raw env value. Entries with an empty token, a non-object value, a
|
|
31
|
-
* missing/empty `subject`,
|
|
32
|
-
* kept, each reduced to `{ subject, channel? }
|
|
33
|
-
*
|
|
38
|
+
* missing/empty `subject`, a non-string `channel` or a non-string `email` are
|
|
39
|
+
* skipped; the rest are kept, each reduced to `{ subject, channel?, email? }`
|
|
40
|
+
* (an `email` without an `@` is dropped from the entry, never the entry).
|
|
41
|
+
* `ok: false` only for a value that is not a JSON object at all. */
|
|
34
42
|
export function parseIngressTokenMap(raw: string | undefined): ParsedIngressTokens {
|
|
35
43
|
if (!raw || raw.trim() === "") return { ok: true, tokens: {} };
|
|
36
44
|
let parsed: unknown;
|
|
@@ -49,9 +57,15 @@ export function parseIngressTokenMap(raw: string | undefined): ParsedIngressToke
|
|
|
49
57
|
const v = value as Record<string, unknown>;
|
|
50
58
|
const subject = v.subject;
|
|
51
59
|
const channel = v.channel;
|
|
60
|
+
const email = v.email;
|
|
52
61
|
if (typeof subject !== "string" || subject === "") continue;
|
|
53
62
|
if (channel !== undefined && typeof channel !== "string") continue;
|
|
54
|
-
|
|
63
|
+
if (email !== undefined && typeof email !== "string") continue;
|
|
64
|
+
tokens[token] = {
|
|
65
|
+
subject,
|
|
66
|
+
...(typeof channel === "string" ? { channel } : {}),
|
|
67
|
+
...(typeof email === "string" && email.includes("@") ? { email: email.trim().toLowerCase() } : {}),
|
|
68
|
+
};
|
|
55
69
|
}
|
|
56
70
|
return { ok: true, tokens };
|
|
57
71
|
}
|
|
@@ -53,6 +53,9 @@ export interface LiveRunMeta {
|
|
|
53
53
|
repo?: string;
|
|
54
54
|
sourceUrl?: string;
|
|
55
55
|
userName?: string;
|
|
56
|
+
/** The bound credential behind the person (authorization.md item 15): a
|
|
57
|
+
* resume or restart dispatches under ITS grants again, never the person's. */
|
|
58
|
+
authenticatedAs?: string;
|
|
56
59
|
effort?: string;
|
|
57
60
|
ref?: string;
|
|
58
61
|
headSha?: string;
|
|
@@ -69,12 +69,18 @@ export interface RunRecord {
|
|
|
69
69
|
model?: string;
|
|
70
70
|
/** Platform-namespaced ids (AGENTS.md invariant 4). */
|
|
71
71
|
channelId: string;
|
|
72
|
-
/** The person the run was for — the message's sender,
|
|
73
|
-
* relayed it for (slack-channel.md item 13)
|
|
74
|
-
*
|
|
72
|
+
/** The person the run was for — the message's sender, the person an app
|
|
73
|
+
* relayed it for (slack-channel.md item 13), or the person a bound
|
|
74
|
+
* credential belongs to (authorization.md item 15); `slack:bot:<id>` only
|
|
75
|
+
* when no person could be found behind an app's post. */
|
|
75
76
|
userId: string;
|
|
76
77
|
/** The app that posted the request for `userId`, by display name, when it was not their own message. */
|
|
77
78
|
relayedBy?: string;
|
|
79
|
+
/** The credential that authenticated the request (`http:<subject>`,
|
|
80
|
+
* `mcp:<subject>`, `cli:local`) when it was bound to the person `userId`
|
|
81
|
+
* names — the run is theirs, the grants were the credential's. Absent when
|
|
82
|
+
* the sender and the credential are one. */
|
|
83
|
+
authenticatedAs?: string;
|
|
78
84
|
threadKey: string;
|
|
79
85
|
/** How the run's channel may travel (authorization): stamped at dispatch
|
|
80
86
|
* from the `ChannelDirectory`, read by `member-of` (a `public` run is
|
|
@@ -767,6 +773,7 @@ export function isRunRecord(v: unknown): v is RunRecord {
|
|
|
767
773
|
return false;
|
|
768
774
|
if (typeof r.channelId !== "string" || typeof r.userId !== "string" || typeof r.threadKey !== "string") return false;
|
|
769
775
|
if (r.relayedBy !== undefined && typeof r.relayedBy !== "string") return false;
|
|
776
|
+
if (r.authenticatedAs !== undefined && typeof r.authenticatedAs !== "string") return false;
|
|
770
777
|
// Absent on records written before the stamp existed (read as `unknown`); present → a known value.
|
|
771
778
|
if (r.channelVisibility !== undefined && !CHANNEL_VISIBILITIES.includes(r.channelVisibility as ChannelVisibility))
|
|
772
779
|
return false;
|
|
@@ -415,8 +415,9 @@ export function watchdogFiring(
|
|
|
415
415
|
const errors = result.results.filter((r) => r.error !== undefined);
|
|
416
416
|
const created = result.results.filter((r) => instanceActionOf(r.instance) === "created").length;
|
|
417
417
|
const timedOut = result.results.filter((r) => r.action === "provision-timed-out").length;
|
|
418
|
+
const rebuilt = result.results.filter((r) => r.action === "auto-rebuilt").length;
|
|
418
419
|
const disk = fullestDisk(result.results);
|
|
419
|
-
const counts = `${result.count}/${result.cap} residents · ${created} refreshed · ${timedOut} timed out · ${errors.length} errors${disk ? ` · disk max ${disk}` : ""}`;
|
|
420
|
+
const counts = `${result.count}/${result.cap} residents · ${created} refreshed · ${timedOut} timed out · ${errors.length} errors${rebuilt > 0 ? ` · ${rebuilt} rebuilt` : ""}${disk ? ` · disk max ${disk}` : ""}`;
|
|
420
421
|
const first = errors[0];
|
|
421
422
|
return {
|
|
422
423
|
schedule: schedule.name,
|
|
@@ -448,6 +448,8 @@ export type CoordinatorAction =
|
|
|
448
448
|
recover?: { runId: string };
|
|
449
449
|
}
|
|
450
450
|
| { type: "merge"; step: string; prNumber: number; headSha: string }
|
|
451
|
+
/** Wait for the intake's checks-settled event at the approved head, bounded as the fallback. */
|
|
452
|
+
| { type: "wait-checks"; step: string; headSha: string; timeoutMs: number }
|
|
451
453
|
| { type: "sleep"; step: string; ms: number }
|
|
452
454
|
| { type: "end"; step: string; ending: UnitEnding };
|
|
453
455
|
|
|
@@ -508,6 +510,7 @@ export type StepReturn =
|
|
|
508
510
|
// fired, or a person merged — so the runner merged nothing (`by: other`).
|
|
509
511
|
| { type: "merge"; step: string; outcome: "merged"; by: "other"; sha: string; mergedAt: string; at: number }
|
|
510
512
|
| { type: "merge"; step: string; outcome: "pending" | "refused"; reason: string; at: number }
|
|
513
|
+
| { type: "wait-checks"; step: string; outcome: "event" | "timeout" }
|
|
511
514
|
| { type: "sleep"; step: string };
|
|
512
515
|
|
|
513
516
|
/** How one unit's pipeline ended — the truthful vocabulary the ship pipeline
|
|
@@ -554,6 +557,27 @@ export type CoordinatorNote =
|
|
|
554
557
|
* person can see whether the cap or the child is the problem. */
|
|
555
558
|
export type ShipBudgetSpent = Readonly<Record<"coding" | "review" | "waiting", number>>;
|
|
556
559
|
|
|
560
|
+
/** The severity ship must address before an approve stands (agent-ship item
|
|
561
|
+
* 9's gate): an approve carrying a finding at or above this level continues
|
|
562
|
+
* into the findings step exactly as a request_changes does. Ordered most to
|
|
563
|
+
* least severe. */
|
|
564
|
+
export const ADDRESS_SEVERITIES = ["blocking", "major", "minor", "nit"] as const;
|
|
565
|
+
export type AddressSeverity = (typeof ADDRESS_SEVERITIES)[number];
|
|
566
|
+
/** Where the level in force came from, most specific wins: a `severity:` directive on the request (`run`), the user's or the channel's config scope, the org's `ship.addressSeverity` (or its default). */
|
|
567
|
+
export type AddressSeveritySource = "org" | "channel" | "user" | "run";
|
|
568
|
+
export const DEFAULT_ADDRESS_SEVERITY: AddressSeverity = "minor";
|
|
569
|
+
export const isAddressSeverity = (v: unknown): v is AddressSeverity =>
|
|
570
|
+
(ADDRESS_SEVERITIES as readonly unknown[]).includes(v);
|
|
571
|
+
|
|
572
|
+
const severityRank = (s: AddressSeverity): number => ADDRESS_SEVERITIES.indexOf(s);
|
|
573
|
+
/** The findings the gate acts on at `level`: at or above it. Every parsed
|
|
574
|
+
* finding carries one of the ladder's four levels — the verdict parser
|
|
575
|
+
* (`parseVerdictInput`) drops an entry with any other severity, `fyi` or none,
|
|
576
|
+
* with a note — so nothing outside the ladder ever reaches the gate. */
|
|
577
|
+
export function findingsAtOrAbove(findings: readonly Finding[], level: AddressSeverity): Finding[] {
|
|
578
|
+
return findings.filter((f) => severityRank(f.severity) <= severityRank(level));
|
|
579
|
+
}
|
|
580
|
+
|
|
557
581
|
export interface UnitPipelineInput {
|
|
558
582
|
unit: { id: string; branch: string };
|
|
559
583
|
repo: string;
|
|
@@ -567,6 +591,12 @@ export interface UnitPipelineInput {
|
|
|
567
591
|
* `runner` (a seeded plan, under its grant) or `person` (a task, or a
|
|
568
592
|
* record without the field). */
|
|
569
593
|
merge: "runner" | "person";
|
|
594
|
+
/** The severity to address: resolved once by the hand-off —
|
|
595
|
+
* directive > user > channel > org — and written on the instance beside
|
|
596
|
+
* `merge`, so the machine reads one value. Absent reads as the default. */
|
|
597
|
+
addressSeverity?: AddressSeverity;
|
|
598
|
+
/** Which layer set the level in force; named in the round header and the ending. */
|
|
599
|
+
addressSeveritySource?: AddressSeveritySource;
|
|
570
600
|
/** The instance's mark (agent-ship item 16): a generated one-unit plan — a
|
|
571
601
|
* `plan` with an id and no `path` — whose unit runs in the requesting
|
|
572
602
|
* thread and is re-issued with the request's own text, never a plan path. */
|
|
@@ -601,7 +631,7 @@ type Phase =
|
|
|
601
631
|
dead?: "failed" | "interrupted";
|
|
602
632
|
}
|
|
603
633
|
| { at: "merge"; pr: PrRef; headSha: string; n: number; since: number }
|
|
604
|
-
| { at: "merge-
|
|
634
|
+
| { at: "merge-wait"; pr: PrRef; headSha: string; n: number; since: number }
|
|
605
635
|
| { at: "ended" };
|
|
606
636
|
|
|
607
637
|
export interface UnitPipelineState {
|
|
@@ -641,9 +671,16 @@ export const WAIT_MARGIN_MS = 5 * MIN;
|
|
|
641
671
|
* margin and the merge poll are the same number), and it keeps a round to a
|
|
642
672
|
* few steps: a coding child's 45 minutes are ten waits and ten reads. */
|
|
643
673
|
export const WAIT_CHUNK_MS = 5 * MIN;
|
|
644
|
-
/** How
|
|
645
|
-
|
|
674
|
+
/** How long the merge step waits for the guards at most. While checks are
|
|
675
|
+
* pending the machine waits on the intake's `checks-settled-<head>` event
|
|
676
|
+
* (http-ingress.md item 12) — one bounded wait per ask, the remainder of this
|
|
677
|
+
* cap, as the fallback when the event never arrives. */
|
|
646
678
|
export const MERGE_WAIT_MAX_MS = 60 * MIN;
|
|
679
|
+
/** One merge wait's fallback timeout: the old poll's cadence. The event wakes
|
|
680
|
+
* the machine at once when the intake delivers it; without one (the webhook
|
|
681
|
+
* not configured, a delivery lost) the door is still re-asked every chunk, so
|
|
682
|
+
* a merge is never slower than the poll it replaced. */
|
|
683
|
+
export const MERGE_WAIT_CHUNK_MS = 5 * MIN;
|
|
647
684
|
/** A `busy` without the live run's id: nothing to wait on, so a short sleep before the spawn is asked again. */
|
|
648
685
|
export const BUSY_RETRY_MS = 2 * MIN;
|
|
649
686
|
|
|
@@ -789,8 +826,13 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
|
|
|
789
826
|
};
|
|
790
827
|
case "merge":
|
|
791
828
|
return { type: "merge", step: `${unit}/merge/${p.n}`, prNumber: p.pr.number, headSha: p.headSha };
|
|
792
|
-
case "merge-
|
|
793
|
-
return {
|
|
829
|
+
case "merge-wait":
|
|
830
|
+
return {
|
|
831
|
+
type: "wait-checks",
|
|
832
|
+
step: `${unit}/merge/wait/${p.n}`,
|
|
833
|
+
headSha: p.headSha,
|
|
834
|
+
timeoutMs: Math.max(MIN, Math.min(MERGE_WAIT_CHUNK_MS, MERGE_WAIT_MAX_MS - (s.clock - p.since))),
|
|
835
|
+
};
|
|
794
836
|
case "ended":
|
|
795
837
|
return { type: "end", step: `${unit}/end`, ending: s.ending! };
|
|
796
838
|
}
|
|
@@ -960,6 +1002,34 @@ function settleReview(
|
|
|
960
1002
|
},
|
|
961
1003
|
notes,
|
|
962
1004
|
);
|
|
1005
|
+
// The severity gate: an approve carrying a finding at or
|
|
1006
|
+
// above the level in force does not end the unit — the round continues
|
|
1007
|
+
// into the findings step exactly as a request_changes does, and only an
|
|
1008
|
+
// approve whose findings all sit below the level stands as merge-ready.
|
|
1009
|
+
const level = next.input.addressSeverity ?? DEFAULT_ADDRESS_SEVERITY;
|
|
1010
|
+
const gated = findingsAtOrAbove(verdict.findings, level);
|
|
1011
|
+
if (gated.length > 0) {
|
|
1012
|
+
if (mode !== undefined)
|
|
1013
|
+
return end(
|
|
1014
|
+
next,
|
|
1015
|
+
{
|
|
1016
|
+
kind: "stopped",
|
|
1017
|
+
mode,
|
|
1018
|
+
round,
|
|
1019
|
+
reviewRounds: next.reviewRounds,
|
|
1020
|
+
...(facts.finalReply !== undefined ? { finalReply: facts.finalReply } : {}),
|
|
1021
|
+
...(facts.reviewPosted === true ? { postedReview: true } : {}),
|
|
1022
|
+
},
|
|
1023
|
+
notes,
|
|
1024
|
+
);
|
|
1025
|
+
if (next.reviewRounds >= next.input.caps.maxRounds)
|
|
1026
|
+
return end(
|
|
1027
|
+
next,
|
|
1028
|
+
{ kind: "round_cap", maxRounds: next.input.caps.maxRounds, reviewRounds: next.reviewRounds },
|
|
1029
|
+
notes,
|
|
1030
|
+
);
|
|
1031
|
+
return enterRound(next, { index: round.index, kind: "findings" }, notes);
|
|
1032
|
+
}
|
|
963
1033
|
const pr = next.pr!;
|
|
964
1034
|
if (next.input.merge !== "runner")
|
|
965
1035
|
return end(next, { kind: "merge_ready", pr, reviewRounds: next.reviewRounds }, notes);
|
|
@@ -1294,11 +1364,11 @@ export function applyReturn(s: UnitPipelineState, ret: StepReturn): Transition {
|
|
|
1294
1364
|
reviewRounds: s.reviewRounds,
|
|
1295
1365
|
});
|
|
1296
1366
|
return {
|
|
1297
|
-
state: { ...clocked, phase: { at: "merge-
|
|
1367
|
+
state: { ...clocked, phase: { at: "merge-wait", pr: p.pr, headSha: p.headSha, n: p.n, since: p.since } },
|
|
1298
1368
|
notes: [],
|
|
1299
1369
|
};
|
|
1300
1370
|
}
|
|
1301
|
-
case "merge-
|
|
1371
|
+
case "merge-wait":
|
|
1302
1372
|
return {
|
|
1303
1373
|
state: { ...s, phase: { at: "merge", pr: p.pr, headSha: p.headSha, n: p.n + 1, since: p.since } },
|
|
1304
1374
|
notes: [],
|
|
@@ -1383,6 +1453,17 @@ export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts):
|
|
|
1383
1453
|
const declined = [...(s.dispositionsByRound[e.reviewRounds - 1] ?? [])].filter((d) => d.disposition === "declined");
|
|
1384
1454
|
const declinedLine = `Declined findings: ${declined.length > 0 ? declined.map((d) => `${d.findingId}${d.note ? ` — ${d.note}` : ""}`).join("; ") : "none"}`;
|
|
1385
1455
|
const verdictLine = `Verdict: LGTM${s.lastVerdictSummary ? ` — ${s.lastVerdictSummary}` : ""}`;
|
|
1456
|
+
// The level in force and its source, plus the findings it left
|
|
1457
|
+
// below the gate on the approved round — named so a skipped finding is a
|
|
1458
|
+
// stated decision, never a silent one.
|
|
1459
|
+
const level = s.input.addressSeverity ?? DEFAULT_ADDRESS_SEVERITY;
|
|
1460
|
+
const levelLine = `Severity addressed: ${level} and above (set by ${s.input.addressSeveritySource ?? "org"}).`;
|
|
1461
|
+
const lastFindings = s.findingsByRound[e.reviewRounds] ?? [];
|
|
1462
|
+
const skipped = lastFindings.filter((f) => !findingsAtOrAbove([f], level).length);
|
|
1463
|
+
const skippedLine =
|
|
1464
|
+
skipped.length > 0
|
|
1465
|
+
? `Findings below ${level}, left as-is: ${skipped.map((f) => `${f.id} (${f.severity}) — ${f.title}`).join("; ")}`
|
|
1466
|
+
: undefined;
|
|
1386
1467
|
const join = (parts: Array<string | undefined>) => parts.filter(Boolean).join("\n\n");
|
|
1387
1468
|
switch (e.kind) {
|
|
1388
1469
|
case "merged":
|
|
@@ -1391,12 +1472,16 @@ export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts):
|
|
|
1391
1472
|
return [
|
|
1392
1473
|
`✅ Merged after ${rounds}: ${e.pr.url} (squash \`${e.sha.slice(0, 7)}\`) — merged by the plan runner under \`plan:merge\`: the review approved at this head and the guards were green.`,
|
|
1393
1474
|
verdictLine,
|
|
1475
|
+
levelLine,
|
|
1476
|
+
...(skippedLine ? [skippedLine] : []),
|
|
1394
1477
|
declinedLine,
|
|
1395
1478
|
].join("\n");
|
|
1396
1479
|
case "merge_ready":
|
|
1397
1480
|
return [
|
|
1398
1481
|
`✅ Merge-ready after ${rounds}: ${e.pr.url}`,
|
|
1399
1482
|
verdictLine,
|
|
1483
|
+
levelLine,
|
|
1484
|
+
...(skippedLine ? [skippedLine] : []),
|
|
1400
1485
|
declinedLine,
|
|
1401
1486
|
// What the driver read at the approved head when it composed this
|
|
1402
1487
|
// ending (agent-ship item 9): a merge that already happened is named
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// How old a snapshot is, in the words every page that serves one uses (the
|
|
2
|
+
// delivery page, the costs page): shared so the two never drift. Node-free.
|
|
3
|
+
|
|
4
|
+
/** `just now`, `n minutes ago`, `n hours ago`, `n days ago` — the snapshot's age at `nowMs`; empty for an unparsable instant. */
|
|
5
|
+
export function snapshotAgeText(snapshotAt: string, nowMs: number): string {
|
|
6
|
+
const minutes = Math.max(0, Math.floor((nowMs - Date.parse(snapshotAt)) / 60_000));
|
|
7
|
+
if (!Number.isFinite(minutes)) return "";
|
|
8
|
+
const unit = (n: number, word: string): string => `${n} ${word}${n === 1 ? "" : "s"} ago`;
|
|
9
|
+
if (minutes < 1) return "just now";
|
|
10
|
+
if (minutes < 90) return unit(minutes, "minute");
|
|
11
|
+
const hours = Math.round(minutes / 60);
|
|
12
|
+
if (hours < 48) return unit(hours, "hour");
|
|
13
|
+
return unit(Math.round(hours / 24), "day");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** `in n minutes`, `in n hours`, `in n days` — how far off an instant is at `nowMs`; once it has passed,
|
|
17
|
+
* `due now` for the first minute and `due n minutes ago` after (so an overdue take reads as overdue,
|
|
18
|
+
* not as forever imminent); empty for an unparsable instant. */
|
|
19
|
+
export function untilText(at: string, nowMs: number): string {
|
|
20
|
+
const diffMs = Date.parse(at) - nowMs;
|
|
21
|
+
if (!Number.isFinite(diffMs)) return "";
|
|
22
|
+
if (diffMs <= -60_000) return `due ${snapshotAgeText(at, nowMs)}`;
|
|
23
|
+
if (diffMs < 60_000) return "due now";
|
|
24
|
+
const minutes = Math.floor(diffMs / 60_000);
|
|
25
|
+
const unit = (n: number, word: string): string => `in ${n} ${word}${n === 1 ? "" : "s"}`;
|
|
26
|
+
if (minutes < 90) return unit(minutes, "minute");
|
|
27
|
+
const hours = Math.round(minutes / 60);
|
|
28
|
+
if (hours < 48) return unit(hours, "hour");
|
|
29
|
+
return unit(Math.round(hours / 24), "day");
|
|
30
|
+
}
|