@coreplane/switchboard 1.238.0 → 1.240.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 +6 -0
- 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 +223 -65
- package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +8 -1
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +337 -44
- package/dist/assets/deploy/cloudflare-sandbox/wrangler.template.jsonc +19 -0
- package/dist/assets/deploy/secrets.manifest.json +4 -4
- 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 +82 -0
- package/dist/assets/src/core/authz/actor.ts +33 -2
- package/dist/assets/src/core/authz/policy.ts +6 -0
- package/dist/assets/src/core/coordinator/contract.ts +12 -1
- package/dist/assets/src/core/coordinator/driver.ts +13 -0
- package/dist/assets/src/core/costs.ts +1114 -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 +70 -0
- package/dist/assets/src/core/snapshotAge.ts +30 -0
- package/dist/assets/src/core/stateWorkerRef.ts +41 -0
- 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/execution/seedPlan.ts +267 -0
- package/dist/assets/src/mcp/registry.ts +7 -0
- package/dist/assets/web/dist/.vite/manifest.json +507 -427
- package/dist/assets/web/dist/assets/AppShell-CM_fh-WW.js +1 -0
- package/dist/assets/web/dist/assets/CostsPage-BD3w5j_S.js +2 -0
- package/dist/assets/web/dist/assets/DeliveryPage-BtvzKA1m.js +1 -0
- package/dist/assets/web/dist/assets/HomePage-BU6aRCSe.js +2 -0
- package/dist/assets/web/dist/assets/NotFoundPage-DwOX1HaZ.js +1 -0
- package/dist/assets/web/dist/assets/{ResidentDetailPage-CrfCd8fx.js → ResidentDetailPage-CUAOh5d9.js} +1 -1
- package/dist/assets/web/dist/assets/ResidentsIndexPage-B3KUlYy2.js +1 -0
- package/dist/assets/web/dist/assets/RunFoldRow-Dulcbg25.js +1 -0
- package/dist/assets/web/dist/assets/RunRoutePage-DGoGrQZq.js +6 -0
- package/dist/assets/web/dist/assets/RunsIndexPage-37W7OZbC.js +1 -0
- package/dist/assets/web/dist/assets/{RunsTabs-XpCqeWu4.js → RunsTabs-BnkvCBrM.js} +1 -1
- package/dist/assets/web/dist/assets/ScheduledPage-C1y9jgAk.js +1 -0
- package/dist/assets/web/dist/assets/SettingsPage-ggfB-Rqr.js +1 -0
- package/dist/assets/web/dist/assets/SlackMark-VDNs7Vjh.js +1 -0
- package/dist/assets/web/dist/assets/Slideover-CX-LEklF.js +1 -0
- package/dist/assets/web/dist/assets/{StatusDot-DkXALvKf.js → StatusDot-CiByEtoR.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-DtLIyi5P.js → Tooltip-DyRxk8A8.js} +1 -1
- package/dist/assets/web/dist/assets/UnitRoutePage-CnWyOizV.js +1 -0
- package/dist/assets/web/dist/assets/{angular-html-DYtV9uIN.js → angular-html-DAQ_1T1v.js} +1 -1
- package/dist/assets/web/dist/assets/{angular-ts-DF2P25Zp.js → angular-ts-D4kxRVp1.js} +1 -1
- package/dist/assets/web/dist/assets/{apl-Ff96wxko.js → apl-Dy07l64y.js} +1 -1
- package/dist/assets/web/dist/assets/{astro-LbBnZna-.js → astro-DZIbTlc7.js} +1 -1
- package/dist/assets/web/dist/assets/{blade-DltuxeBW.js → blade-CmSXWX3l.js} +1 -1
- package/dist/assets/web/dist/assets/{c-DBg7yuPx.js → c-ggzVdJLK.js} +1 -1
- package/dist/assets/web/dist/assets/{chapel-C83hUAds.js → chapel-Bo1wktsG.js} +1 -1
- package/dist/assets/web/dist/assets/{cobol-DB0yV_2D.js → cobol-DJUmtKM2.js} +1 -1
- package/dist/assets/web/dist/assets/{coffee-Bpih6Kzl.js → coffee-BZWOVz4C.js} +1 -1
- package/dist/assets/web/dist/assets/{cpp-CU00ZeRn.js → cpp-Dtk39yMw.js} +1 -1
- package/dist/assets/web/dist/assets/{crystal-f8ImJkux.js → crystal-CGf_-Zoj.js} +1 -1
- package/dist/assets/web/dist/assets/{css-4VrC4ISo.js → css-CX2fcL4u.js} +1 -1
- package/dist/assets/web/dist/assets/delivery-CTdOqpVK.js +1 -0
- package/dist/assets/web/dist/assets/{dist-DtA7JiH5.js → dist-D8TaC961.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-BYS8FtgQ.js} +1 -1
- package/dist/assets/web/dist/assets/{elixir-wwIYIpJm.js → elixir-fhUpi3nO.js} +1 -1
- package/dist/assets/web/dist/assets/{elm-DyDuM3v3.js → elm-DAQNiwsN.js} +1 -1
- package/dist/assets/web/dist/assets/{erb-DFJBEVrh.js → erb-CWzEys6U.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-9rY3p-Rw.js} +1 -1
- package/dist/assets/web/dist/assets/{glimmer-js-ZFnrm4J3.js → glimmer-js-tY7hy0nE.js} +1 -1
- package/dist/assets/web/dist/assets/{glimmer-ts-XY4Yqshp.js → glimmer-ts-CTrLv5JF.js} +1 -1
- package/dist/assets/web/dist/assets/{glsl-xnR8efx0.js → glsl-BRJbiDfq.js} +1 -1
- package/dist/assets/web/dist/assets/{graphql-D-gO5tnP.js → graphql-B-MtVLh2.js} +1 -1
- package/dist/assets/web/dist/assets/{hack-CSxbW4ax.js → hack-BtIkOypQ.js} +1 -1
- package/dist/assets/web/dist/assets/{haml-D4ar42QT.js → haml-CoOH6gfa.js} +1 -1
- package/dist/assets/web/dist/assets/{handlebars-CMLbDE7o.js → handlebars-DgrRu-GP.js} +1 -1
- package/dist/assets/web/dist/assets/{html-CVekU4Fs.js → html-DZaaI7ca.js} +1 -1
- package/dist/assets/web/dist/assets/{html-derivative-St3HHw_N.js → html-derivative-DgHvT-qZ.js} +1 -1
- package/dist/assets/web/dist/assets/{http-Mon0DWHS.js → http-CvAd51yz.js} +1 -1
- package/dist/assets/web/dist/assets/{hurl-Cbl5IvBs.js → hurl-CXGqAfzx.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-Cse3JGb0.js} +1 -1
- package/dist/assets/web/dist/assets/{java-Bprck1l-.js → java-CIQStIXF.js} +1 -1
- package/dist/assets/web/dist/assets/{javascript-C73G1-UR.js → javascript-WbH8s7E3.js} +1 -1
- package/dist/assets/web/dist/assets/{jinja-C4DuRxYJ.js → jinja-DzyWEVKC.js} +1 -1
- package/dist/assets/web/dist/assets/{jison-B2U03GEC.js → jison-CrT-zCa-.js} +1 -1
- package/dist/assets/web/dist/assets/{json-CvTZUDzx.js → json-lHjpjXMQ.js} +1 -1
- package/dist/assets/web/dist/assets/{jsx-DMWKCzwo.js → jsx-WTwYCHW6.js} +1 -1
- package/dist/assets/web/dist/assets/{julia-Czn7bV_3.js → julia-Bqpk9H7E.js} +1 -1
- package/dist/assets/web/dist/assets/{just-BBxpNfvC.js → just-LdXk1r3m.js} +1 -1
- package/dist/assets/web/dist/assets/{latex-Bw9yg5mr.js → latex-h_y2A5KD.js} +1 -1
- package/dist/assets/web/dist/assets/{liquid-BRCjaydE.js → liquid-CY7bF2_p.js} +1 -1
- package/dist/assets/web/dist/assets/localIso-L06jV29p.js +1 -0
- package/dist/assets/web/dist/assets/{lua-DUu2KIC0.js → lua-oQ7XVZOY.js} +1 -1
- package/dist/assets/web/dist/assets/main-CGO6fQ8k.js +28 -0
- package/dist/assets/web/dist/assets/main-O8UXRuEv.css +1 -0
- package/dist/assets/web/dist/assets/{marko-CZJZIlSQ.js → marko-zv_qZ4QV.js} +1 -1
- package/dist/assets/web/dist/assets/{mdc-BmClq6fd.js → mdc-Dl4kL_YK.js} +1 -1
- package/dist/assets/web/dist/assets/{nginx-Bmt3xSEm.js → nginx-CAHCZj4F.js} +1 -1
- package/dist/assets/web/dist/assets/{nim-Da-i0utK.js → nim-BOtmwCJH.js} +1 -1
- package/dist/assets/web/dist/assets/{org-BvAre2qP.js → org-DspQk3HQ.js} +1 -1
- package/dist/assets/web/dist/assets/{perl-v9WwFFtB.js → perl-CAXmvVl0.js} +1 -1
- package/dist/assets/web/dist/assets/{php-DTU4Zyze.js → php-CMUx4VyM.js} +1 -1
- package/dist/assets/web/dist/assets/{pug-DhTwEqTO.js → pug-B55xVJTv.js} +1 -1
- package/dist/assets/web/dist/assets/{qml-BUD9AC_c.js → qml-DsNKCCRk.js} +1 -1
- package/dist/assets/web/dist/assets/{r-CAnu56n7.js → r-DbhP0VvO.js} +1 -1
- package/dist/assets/web/dist/assets/{razor-Dn0P4gAq.js → razor-BCpE6P-p.js} +1 -1
- package/dist/assets/web/dist/assets/{regexp-CZyBRaVW.js → regexp-BllsjiN4.js} +1 -1
- package/dist/assets/web/dist/assets/{rst-Ckyd5UsY.js → rst-CO5Rpq9V.js} +1 -1
- package/dist/assets/web/dist/assets/{ruby-BBQ62fsx.js → ruby-C2A4OgQq.js} +1 -1
- package/dist/assets/web/dist/assets/{sas-CW35hMxG.js → sas-Butzoyql.js} +1 -1
- package/dist/assets/web/dist/assets/{scss-CpV8pF4J.js → scss-PEUwJBpf.js} +1 -1
- package/dist/assets/web/dist/assets/settingsApi-priOPiwu.js +1 -0
- package/dist/assets/web/dist/assets/{shellscript-CRJvsmE1.js → shellscript-3Yhq0khE.js} +1 -1
- package/dist/assets/web/dist/assets/{shellsession-CyriUZxf.js → shellsession-04Hkb7m3.js} +1 -1
- package/dist/assets/web/dist/assets/{soy-D5QzYUeg.js → soy-BnId73cP.js} +1 -1
- package/dist/assets/web/dist/assets/{sql-CUBf_Mse.js → sql-BKwdc0ch.js} +1 -1
- package/dist/assets/web/dist/assets/sseReplay-DutHNghE.js +9 -0
- package/dist/assets/web/dist/assets/{stata-B-4n4ITz.js → stata-BPPtNkay.js} +1 -1
- package/dist/assets/web/dist/assets/{surrealql-RuWZBLtr.js → surrealql-CDPChcYP.js} +1 -1
- package/dist/assets/web/dist/assets/{svelte-CO5YzCsK.js → svelte-B15DIQ-z.js} +1 -1
- package/dist/assets/web/dist/assets/{templ-DbkWVaBF.js → templ-D-rp_AiB.js} +1 -1
- package/dist/assets/web/dist/assets/{tex-IuxoEcId.js → tex-BnVDCD7p.js} +1 -1
- package/dist/assets/web/dist/assets/{ts-tags-g6dGQPjd.js → ts-tags-cpujdyAR.js} +1 -1
- package/dist/assets/web/dist/assets/{tsx-WnXwiyH2.js → tsx-D1vO4L72.js} +1 -1
- package/dist/assets/web/dist/assets/{twig-Lx1G7PnY.js → twig-oQGnQGRc.js} +1 -1
- package/dist/assets/web/dist/assets/{typescript-BQd7biLF.js → typescript-CKSFpXiH.js} +1 -1
- package/dist/assets/web/dist/assets/{typst-D2lXPHK7.js → typst-DKHFo8BF.js} +1 -1
- package/dist/assets/web/dist/assets/{vue-4wVQD9y9.js → vue-BstokpiS.js} +1 -1
- package/dist/assets/web/dist/assets/{vue-html-DZPaRte4.js → vue-html-DpCp0_Sz.js} +1 -1
- package/dist/assets/web/dist/assets/{vue-vine-DbmZCmu-.js → vue-vine-y5d5wUb1.js} +1 -1
- package/dist/assets/web/dist/assets/{xml-DV16szyo.js → xml-Br2hx5Ax.js} +1 -1
- package/dist/assets/web/dist/assets/{xsl-eo5-1sk0.js → xsl-Bc8Cobap.js} +1 -1
- package/dist/assets/web/dist/assets/{yaml-BXdX8HZf.js → yaml-LOITy_I1.js} +1 -1
- package/dist/cli.js +2586 -1603
- 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,
|
|
@@ -554,6 +554,27 @@ export type CoordinatorNote =
|
|
|
554
554
|
* person can see whether the cap or the child is the problem. */
|
|
555
555
|
export type ShipBudgetSpent = Readonly<Record<"coding" | "review" | "waiting", number>>;
|
|
556
556
|
|
|
557
|
+
/** The severity ship must address before an approve stands (agent-ship item
|
|
558
|
+
* 9's gate): an approve carrying a finding at or above this level continues
|
|
559
|
+
* into the findings step exactly as a request_changes does. Ordered most to
|
|
560
|
+
* least severe. */
|
|
561
|
+
export const ADDRESS_SEVERITIES = ["blocking", "major", "minor", "nit"] as const;
|
|
562
|
+
export type AddressSeverity = (typeof ADDRESS_SEVERITIES)[number];
|
|
563
|
+
/** 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). */
|
|
564
|
+
export type AddressSeveritySource = "org" | "channel" | "user" | "run";
|
|
565
|
+
export const DEFAULT_ADDRESS_SEVERITY: AddressSeverity = "minor";
|
|
566
|
+
export const isAddressSeverity = (v: unknown): v is AddressSeverity =>
|
|
567
|
+
(ADDRESS_SEVERITIES as readonly unknown[]).includes(v);
|
|
568
|
+
|
|
569
|
+
const severityRank = (s: AddressSeverity): number => ADDRESS_SEVERITIES.indexOf(s);
|
|
570
|
+
/** The findings the gate acts on at `level`: at or above it. Every parsed
|
|
571
|
+
* finding carries one of the ladder's four levels — the verdict parser
|
|
572
|
+
* (`parseVerdictInput`) drops an entry with any other severity, `fyi` or none,
|
|
573
|
+
* with a note — so nothing outside the ladder ever reaches the gate. */
|
|
574
|
+
export function findingsAtOrAbove(findings: readonly Finding[], level: AddressSeverity): Finding[] {
|
|
575
|
+
return findings.filter((f) => severityRank(f.severity) <= severityRank(level));
|
|
576
|
+
}
|
|
577
|
+
|
|
557
578
|
export interface UnitPipelineInput {
|
|
558
579
|
unit: { id: string; branch: string };
|
|
559
580
|
repo: string;
|
|
@@ -567,6 +588,12 @@ export interface UnitPipelineInput {
|
|
|
567
588
|
* `runner` (a seeded plan, under its grant) or `person` (a task, or a
|
|
568
589
|
* record without the field). */
|
|
569
590
|
merge: "runner" | "person";
|
|
591
|
+
/** The severity to address: resolved once by the hand-off —
|
|
592
|
+
* directive > user > channel > org — and written on the instance beside
|
|
593
|
+
* `merge`, so the machine reads one value. Absent reads as the default. */
|
|
594
|
+
addressSeverity?: AddressSeverity;
|
|
595
|
+
/** Which layer set the level in force; named in the round header and the ending. */
|
|
596
|
+
addressSeveritySource?: AddressSeveritySource;
|
|
570
597
|
/** The instance's mark (agent-ship item 16): a generated one-unit plan — a
|
|
571
598
|
* `plan` with an id and no `path` — whose unit runs in the requesting
|
|
572
599
|
* thread and is re-issued with the request's own text, never a plan path. */
|
|
@@ -960,6 +987,34 @@ function settleReview(
|
|
|
960
987
|
},
|
|
961
988
|
notes,
|
|
962
989
|
);
|
|
990
|
+
// The severity gate: an approve carrying a finding at or
|
|
991
|
+
// above the level in force does not end the unit — the round continues
|
|
992
|
+
// into the findings step exactly as a request_changes does, and only an
|
|
993
|
+
// approve whose findings all sit below the level stands as merge-ready.
|
|
994
|
+
const level = next.input.addressSeverity ?? DEFAULT_ADDRESS_SEVERITY;
|
|
995
|
+
const gated = findingsAtOrAbove(verdict.findings, level);
|
|
996
|
+
if (gated.length > 0) {
|
|
997
|
+
if (mode !== undefined)
|
|
998
|
+
return end(
|
|
999
|
+
next,
|
|
1000
|
+
{
|
|
1001
|
+
kind: "stopped",
|
|
1002
|
+
mode,
|
|
1003
|
+
round,
|
|
1004
|
+
reviewRounds: next.reviewRounds,
|
|
1005
|
+
...(facts.finalReply !== undefined ? { finalReply: facts.finalReply } : {}),
|
|
1006
|
+
...(facts.reviewPosted === true ? { postedReview: true } : {}),
|
|
1007
|
+
},
|
|
1008
|
+
notes,
|
|
1009
|
+
);
|
|
1010
|
+
if (next.reviewRounds >= next.input.caps.maxRounds)
|
|
1011
|
+
return end(
|
|
1012
|
+
next,
|
|
1013
|
+
{ kind: "round_cap", maxRounds: next.input.caps.maxRounds, reviewRounds: next.reviewRounds },
|
|
1014
|
+
notes,
|
|
1015
|
+
);
|
|
1016
|
+
return enterRound(next, { index: round.index, kind: "findings" }, notes);
|
|
1017
|
+
}
|
|
963
1018
|
const pr = next.pr!;
|
|
964
1019
|
if (next.input.merge !== "runner")
|
|
965
1020
|
return end(next, { kind: "merge_ready", pr, reviewRounds: next.reviewRounds }, notes);
|
|
@@ -1383,6 +1438,17 @@ export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts):
|
|
|
1383
1438
|
const declined = [...(s.dispositionsByRound[e.reviewRounds - 1] ?? [])].filter((d) => d.disposition === "declined");
|
|
1384
1439
|
const declinedLine = `Declined findings: ${declined.length > 0 ? declined.map((d) => `${d.findingId}${d.note ? ` — ${d.note}` : ""}`).join("; ") : "none"}`;
|
|
1385
1440
|
const verdictLine = `Verdict: LGTM${s.lastVerdictSummary ? ` — ${s.lastVerdictSummary}` : ""}`;
|
|
1441
|
+
// The level in force and its source, plus the findings it left
|
|
1442
|
+
// below the gate on the approved round — named so a skipped finding is a
|
|
1443
|
+
// stated decision, never a silent one.
|
|
1444
|
+
const level = s.input.addressSeverity ?? DEFAULT_ADDRESS_SEVERITY;
|
|
1445
|
+
const levelLine = `Severity addressed: ${level} and above (set by ${s.input.addressSeveritySource ?? "org"}).`;
|
|
1446
|
+
const lastFindings = s.findingsByRound[e.reviewRounds] ?? [];
|
|
1447
|
+
const skipped = lastFindings.filter((f) => !findingsAtOrAbove([f], level).length);
|
|
1448
|
+
const skippedLine =
|
|
1449
|
+
skipped.length > 0
|
|
1450
|
+
? `Findings below ${level}, left as-is: ${skipped.map((f) => `${f.id} (${f.severity}) — ${f.title}`).join("; ")}`
|
|
1451
|
+
: undefined;
|
|
1386
1452
|
const join = (parts: Array<string | undefined>) => parts.filter(Boolean).join("\n\n");
|
|
1387
1453
|
switch (e.kind) {
|
|
1388
1454
|
case "merged":
|
|
@@ -1391,12 +1457,16 @@ export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts):
|
|
|
1391
1457
|
return [
|
|
1392
1458
|
`✅ 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
1459
|
verdictLine,
|
|
1460
|
+
levelLine,
|
|
1461
|
+
...(skippedLine ? [skippedLine] : []),
|
|
1394
1462
|
declinedLine,
|
|
1395
1463
|
].join("\n");
|
|
1396
1464
|
case "merge_ready":
|
|
1397
1465
|
return [
|
|
1398
1466
|
`✅ Merge-ready after ${rounds}: ${e.pr.url}`,
|
|
1399
1467
|
verdictLine,
|
|
1468
|
+
levelLine,
|
|
1469
|
+
...(skippedLine ? [skippedLine] : []),
|
|
1400
1470
|
declinedLine,
|
|
1401
1471
|
// What the driver read at the approved head when it composed this
|
|
1402
1472
|
// 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
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The one state Worker (deploy/cloudflare-memory/), as the config names it: any
|
|
2
|
+
// `*.worker` block may carry its base URL and the env var holding its bearer,
|
|
3
|
+
// and a capability that keeps a cache there (a delivery snapshot, a costs
|
|
4
|
+
// snapshot) reads whichever block names it — a cache earns no block of its own.
|
|
5
|
+
//
|
|
6
|
+
// Node-free on purpose: the snapshot stores that import this are shared with
|
|
7
|
+
// the Worker by relative path.
|
|
8
|
+
|
|
9
|
+
/** A `{ baseUrl, tokenEnv? }` reference to the state Worker, as every `*.worker` config block spells it. */
|
|
10
|
+
export interface StateWorkerRef {
|
|
11
|
+
baseUrl: string;
|
|
12
|
+
/** Env var holding the bearer; default MEMORY_TOKEN. */
|
|
13
|
+
tokenEnv?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The config blocks that may name the state Worker — one Worker, referenced from each capability that uses it. */
|
|
17
|
+
export interface StateWorkerBlocks {
|
|
18
|
+
runtimeOverrides?: { worker?: StateWorkerRef };
|
|
19
|
+
runHistory?: { worker?: StateWorkerRef };
|
|
20
|
+
schedules?: { worker?: StateWorkerRef };
|
|
21
|
+
memory?: { worker?: StateWorkerRef };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The env var holding the state Worker's bearer when a block names none. */
|
|
25
|
+
export const DEFAULT_STATE_WORKER_TOKEN_ENV = "MEMORY_TOKEN";
|
|
26
|
+
|
|
27
|
+
/** The slice of the process's `Secrets` a store builder reads (src/secrets.ts; named
|
|
28
|
+
* structurally so the builders stay free of the bot's Node-only imports for the Worker). */
|
|
29
|
+
export interface SecretReader {
|
|
30
|
+
named(name: string): { reveal(): string } | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The first `*.worker` block that names the state Worker, in the order the blocks are listed above. */
|
|
34
|
+
export function stateWorkerOf(config: StateWorkerBlocks): StateWorkerRef | undefined {
|
|
35
|
+
return [config.runtimeOverrides, config.runHistory, config.schedules, config.memory].find(
|
|
36
|
+
(block) => block?.worker?.baseUrl,
|
|
37
|
+
)?.worker;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The names a `*.worker` block may carry the state Worker under, for a warning that says where to put one. */
|
|
41
|
+
export const STATE_WORKER_BLOCK_NAMES = "runHistory.worker, runtimeOverrides.worker, schedules.worker or memory.worker";
|