@augurworks/augur 0.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/CANVAS.md +858 -0
  2. package/INSTALL.md +603 -0
  3. package/LICENSE +27 -0
  4. package/README.md +150 -0
  5. package/TRADEMARKS.md +72 -0
  6. package/agents/README.md +65 -0
  7. package/agents/canon-extract.md +117 -0
  8. package/agents/canon.md +151 -0
  9. package/agents/canvas.md +88 -0
  10. package/agents/currency.md +74 -0
  11. package/agents/drafts.md +92 -0
  12. package/agents/identity.md +148 -0
  13. package/agents/prototype-contract.md +69 -0
  14. package/agents/publishing.md +324 -0
  15. package/agents/review-feedback.md +76 -0
  16. package/agents/space-json.md +132 -0
  17. package/agents/ui-skill.md +101 -0
  18. package/agents/working-marks.md +86 -0
  19. package/brand/augur-eye.svg +3 -0
  20. package/brand/augur-icon.svg +41 -0
  21. package/brand/augur-mark.svg +5 -0
  22. package/brand/lentianova-bulky.otf +0 -0
  23. package/brand/lentianova-median.otf +0 -0
  24. package/brand/lentianova.woff2 +0 -0
  25. package/build.js +8409 -0
  26. package/changelog.md +133 -0
  27. package/fonts/inter-latin-wght-normal.woff2 +0 -0
  28. package/package.json +57 -0
  29. package/pitis/PETS.md +121 -0
  30. package/pitis/README.md +117 -0
  31. package/pitis/index.html +266 -0
  32. package/pitis/piti.build.js +81 -0
  33. package/pitis/piti.js +929 -0
  34. package/pitis/roast-agent.md +185 -0
  35. package/realtime/src/index.js +119 -0
  36. package/realtime/wrangler.example.toml +44 -0
  37. package/scripts/adopt.mjs +106 -0
  38. package/scripts/board-snapshot.mjs +197 -0
  39. package/scripts/build-seed-pack.mjs +33 -0
  40. package/scripts/bundle-rekey.mjs +112 -0
  41. package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
  42. package/scripts/canon-extract.mjs +578 -0
  43. package/scripts/canon.mjs +599 -0
  44. package/scripts/canvas-screen.mjs +333 -0
  45. package/scripts/check-local.mjs +136 -0
  46. package/scripts/clawd-art.mjs +75 -0
  47. package/scripts/clawd-canvas.mjs +741 -0
  48. package/scripts/cli.mjs +100 -0
  49. package/scripts/clone.mjs +422 -0
  50. package/scripts/close.mjs +30 -0
  51. package/scripts/connect.mjs +103 -0
  52. package/scripts/deploy.mjs +157 -0
  53. package/scripts/dev.mjs +54 -0
  54. package/scripts/doc-lint.mjs +143 -0
  55. package/scripts/export.mjs +300 -0
  56. package/scripts/fork.mjs +64 -0
  57. package/scripts/freeze.mjs +74 -0
  58. package/scripts/frontdoor-parity.mjs +180 -0
  59. package/scripts/hook.mjs +67 -0
  60. package/scripts/identity-rekey.mjs +112 -0
  61. package/scripts/init.mjs +122 -0
  62. package/scripts/land.mjs +40 -0
  63. package/scripts/lib/adapters.mjs +156 -0
  64. package/scripts/lib/board-snapshot.mjs +511 -0
  65. package/scripts/lib/draft.mjs +385 -0
  66. package/scripts/lib/graduate.mjs +276 -0
  67. package/scripts/lib/instance.mjs +74 -0
  68. package/scripts/lib/marks.mjs +107 -0
  69. package/scripts/lib/materialize.mjs +138 -0
  70. package/scripts/lib/merge3.mjs +124 -0
  71. package/scripts/lib/offline-posture.mjs +120 -0
  72. package/scripts/lib/offline-respawn.mjs +18 -0
  73. package/scripts/lib/offline-wrangler.mjs +78 -0
  74. package/scripts/lib/publish-compose.mjs +12 -0
  75. package/scripts/lib/publish-conflict.mjs +78 -0
  76. package/scripts/lib/publish-evidence.mjs +144 -0
  77. package/scripts/lib/refine-compare.mjs +140 -0
  78. package/scripts/lib/refine-ledger.mjs +150 -0
  79. package/scripts/lib/refine-png.mjs +149 -0
  80. package/scripts/lib/seed-pack-build.mjs +141 -0
  81. package/scripts/lib/state-compare.mjs +68 -0
  82. package/scripts/lib/store.mjs +122 -0
  83. package/scripts/login.mjs +84 -0
  84. package/scripts/mark.mjs +112 -0
  85. package/scripts/migrate-board-keys.mjs +164 -0
  86. package/scripts/migrate.mjs +265 -0
  87. package/scripts/no-foreign-vocabulary.mjs +216 -0
  88. package/scripts/no-product-names.mjs +157 -0
  89. package/scripts/no-tenant-globals.mjs +1134 -0
  90. package/scripts/offline.mjs +247 -0
  91. package/scripts/og.mjs +170 -0
  92. package/scripts/one-tenant-resolver.mjs +146 -0
  93. package/scripts/open.mjs +63 -0
  94. package/scripts/publish.mjs +1041 -0
  95. package/scripts/read.mjs +31 -0
  96. package/scripts/refine.mjs +455 -0
  97. package/scripts/release-drift.mjs +138 -0
  98. package/scripts/restore.mjs +322 -0
  99. package/scripts/review.mjs +125 -0
  100. package/scripts/save.mjs +21 -0
  101. package/scripts/shell-lint.mjs +189 -0
  102. package/scripts/ship.mjs +460 -0
  103. package/scripts/shoot.mjs +264 -0
  104. package/scripts/smoke-invite.mjs +113 -0
  105. package/scripts/state-inventory.mjs +223 -0
  106. package/scripts/status.mjs +177 -0
  107. package/scripts/sync.mjs +27 -0
  108. package/scripts/tenant-do-rehearsal.mjs +1314 -0
  109. package/scripts/ui-copy-lint.mjs +202 -0
  110. package/scripts/watch.mjs +32 -0
  111. package/scripts/wrangler-preflight.mjs +246 -0
  112. package/seed/CANON.md +43 -0
  113. package/seed/README.md +119 -0
  114. package/seed/prototype-status.json +9 -0
  115. package/seed/registry.json +88 -0
  116. package/seed/skills/starter-ui/SKILL.md +52 -0
  117. package/seed/skills/starter-ui/skill.json +11 -0
  118. package/seed/skills/starter-ui/starter-tokens.css +73 -0
  119. package/seed/skills/starter-ui/starter-ui.css +318 -0
  120. package/seed/skills/starter-ui/starter-ui.js +50 -0
  121. package/seed/space.json +5 -0
  122. package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
  123. package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
  124. package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
  125. package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
  126. package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
  127. package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
  128. package/seed/threads.json +52 -0
  129. package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
  130. package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
  131. package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
  132. package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
  133. package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
  134. package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
  135. package/src/_worker.js +12668 -0
  136. package/src/board-key.mjs +57 -0
  137. package/src/board-room.mjs +881 -0
  138. package/src/bundle-keys.mjs +138 -0
  139. package/src/canon/collect-in-browser.js +215 -0
  140. package/src/canon/emit.mjs +232 -0
  141. package/src/canon/observe.mjs +260 -0
  142. package/src/canon/schema.mjs +451 -0
  143. package/src/canvas/DSEG-LICENSE.txt +95 -0
  144. package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
  145. package/src/canvas/canvas.css +885 -0
  146. package/src/canvas/canvas.js +5780 -0
  147. package/src/canvas/capture.js +485 -0
  148. package/src/chrome/appchrome.mjs +568 -0
  149. package/src/currency.mjs +203 -0
  150. package/src/delete-confirmation.mjs +254 -0
  151. package/src/drafts/drafts.js +326 -0
  152. package/src/entry.js +58 -0
  153. package/src/galleries.mjs +400 -0
  154. package/src/health-cron.mjs +173 -0
  155. package/src/identity.json +1 -0
  156. package/src/kv-codec.mjs +135 -0
  157. package/src/kv-identity.mjs +259 -0
  158. package/src/mail.mjs +599 -0
  159. package/src/provenance.mjs +74 -0
  160. package/src/publish-compose.mjs +363 -0
  161. package/src/publish-fork.mjs +221 -0
  162. package/src/publish-units.mjs +44 -0
  163. package/src/purge.mjs +81 -0
  164. package/src/review/COMMENTING-UX.md +306 -0
  165. package/src/review/cat.png +0 -0
  166. package/src/review/comment-cursor.svg +7 -0
  167. package/src/review/comments.js +1738 -0
  168. package/src/room-ticket.mjs +96 -0
  169. package/src/roster-chip.mjs +32 -0
  170. package/src/seed-pack.mjs +261 -0
  171. package/src/state-inventory.mjs +249 -0
  172. package/src/sw-logic.mjs +48 -0
  173. package/src/tenant-cache.mjs +141 -0
  174. package/src/tenant-context.mjs +386 -0
  175. package/src/tenant-do.js +2940 -0
  176. package/src/tenant-host.mjs +144 -0
  177. package/src/tenant-quotas.mjs +140 -0
  178. package/src/unit-core.mjs +103 -0
  179. package/src/unit-object.mjs +338 -0
  180. package/templates/README.md +67 -0
  181. package/templates/shell/deploy.yml +145 -0
  182. package/templates/shell/engine-bump.yml +142 -0
  183. package/templates/shell/health.yml +233 -0
  184. package/templates/shell/kv-backup.yml +201 -0
  185. package/templates/shell/roster-update.yml +110 -0
  186. package/templates/shell/space-preflight.yml +35 -0
  187. package/templates/shell/space-rebake.yml +79 -0
  188. package/templates/shell/store-backup.yml +152 -0
  189. package/templates/shell/wrangler.example.toml +266 -0
  190. package/templates/space/publish.yml +91 -0
@@ -0,0 +1,400 @@
1
+ // galleries.mjs — the pages a workspace DERIVES, rendered at serve time from the live store.
2
+ //
3
+ // docs/drafts-that-land.md §6.4. The gallery, each opportunity's index, the playground, the
4
+ // library tiers and the search index used to be baked by a client's build and shipped with a
5
+ // publish, so a landing could not appear on them until somebody published a tree. Here they
6
+ // are a pure function of what the store holds: the live manifest (units, files, per-file
7
+ // provenance), the status overlay, the roster (ids → faces) and the design-system catalog.
8
+ // The worker calls this where drafts are served; the markup keeps the card contract the
9
+ // chrome bundle's scripts read (class names and data-* attributes), so pins, renames, status
10
+ // clicks, currency, faces and the finder keep working on a derived page exactly as they did
11
+ // on a baked one.
12
+ //
13
+ // ⚠️ NO NODE IMPORTS, NO I/O. The worker runs this per request.
14
+ import { renderAppChrome, renderSpaceContextScript, CHROME_MARK_START, CHROME_MARK_END, UI_VERSION, escAttr, titleCase, fmtDate, relTime } from "./chrome/appchrome.mjs";
15
+ import { authoredUnits } from "./publish-units.mjs";
16
+ import { unitProvenance, unitKey, STATUS_LABELS } from "./currency.mjs";
17
+
18
+ export const TIERS = Object.freeze(["base", "components", "patterns", "pages"]);
19
+ const TIER_TITLE = Object.freeze({ base: "Base", components: "Components", patterns: "Patterns", pages: "Pages" });
20
+
21
+ const STATUS_META = Object.freeze({
22
+ "in-progress": { label: STATUS_LABELS["in-progress"], cls: "is-wip" },
23
+ "dev-ready": { label: STATUS_LABELS["dev-ready"], cls: "is-ready" },
24
+ ignore: { label: STATUS_LABELS.ignore, cls: "is-ignore" },
25
+ });
26
+ const STATUS_ICONS = Object.freeze({
27
+ "dev-ready": '<svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="9" fill="#17935a"/><path d="M5.8 10.4l2.7 2.7 5.7-6" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
28
+ "in-progress": '<svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="8" fill="none" stroke="#1c1c22" stroke-width="2.2"/><path d="M10 2.8a7.2 7.2 0 0 1 0 14.4z" fill="#1c1c22"/></svg>',
29
+ ignore: '<svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="8" fill="none" stroke="#aeb3bd" stroke-width="2.2"/><line x1="6.4" y1="10" x2="13.6" y2="10" stroke="#aeb3bd" stroke-width="2.2" stroke-linecap="round"/></svg>',
30
+ });
31
+ const STATUS_RANK = Object.freeze({ "dev-ready": 0, "in-progress": 1, ignore: 2 });
32
+ const IC_STAR = `<svg class="pin-star" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/></svg>`;
33
+ // Spelled out rather than built with `ic()`: the module-scope lint reads a call initializer
34
+ // as state it cannot prove pure, and a literal costs nothing.
35
+ const IC_PLUS = `<svg class="gvic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M5 12h14"/></svg>`;
36
+ const EMOJI_POOL = Object.freeze(["🗳️", "🏛️", "📊", "🧭", "🛰️", "🧩", "🪧", "🌳", "🚲", "📣", "🗺️", "🧪", "💡", "🔭", "🪟", "🧱", "🎛️", "🛣️", "🧰", "📐", "🧮", "🗂️", "🔔", "🏘️", "🌍", "💬", "📝", "🚏", "🏙️", "🌿", "🎚️", "🧷"]);
37
+
38
+ const dec = (s) => { try { return decodeURIComponent(String(s)); } catch (e) { return String(s); } };
39
+ const enc = (s) => encodeURIComponent(s);
40
+ const plural = (n, word) => `${n} ${n === 1 ? word : word.endsWith("y") ? word.slice(0, -1) + "ies" : word + "s"}`;
41
+ function protoEmoji(slug) {
42
+ let h = 0;
43
+ for (let i = 0; i < slug.length; i++) h = (h * 31 + slug.charCodeAt(i)) >>> 0;
44
+ return EMOJI_POOL[h % EMOJI_POOL.length];
45
+ }
46
+ const protoName = (slug) => `${protoEmoji(slug)} ${titleCase(slug)}`;
47
+
48
+ // ── the site model ───────────────────────────────────────────────────────────
49
+ /** Which list a unit belongs to, from its path alone. */
50
+ export function unitHome(unit) {
51
+ const segs = dec(unit).split("/").filter(Boolean);
52
+ if (segs.length !== 2) return null;
53
+ if (segs[0] === "playground") return { kind: "playground", name: segs[1] };
54
+ if (TIERS.includes(segs[0])) return { kind: "tier", tier: segs[0], name: segs[1] };
55
+ if (segs[0].startsWith("_") || segs[0] === "skills") return null;
56
+ return { kind: "opportunity", opp: segs[0], name: segs[1] };
57
+ }
58
+
59
+ function unitEntry(manifest, unit, home, { statuses, baseline, people, now }) {
60
+ const files = manifest.files || {};
61
+ const prefix = dec(unit);
62
+ const inUnit = Object.keys(files).filter((p) => dec(p).startsWith(prefix));
63
+ const rel = (p) => dec(p).slice(prefix.length);
64
+ const hasIndex = inUnit.some((p) => rel(p) === "index.html");
65
+ const firstHtml = inUnit.map(rel).filter((r) => !r.includes("/") && r.endsWith(".html")).sort()[0] || null;
66
+ const file = hasIndex ? `${unit}index.html` : firstHtml ? `${unit}${enc(firstHtml)}` : null;
67
+ const { editedAt, by } = unitProvenance(manifest, unit);
68
+ const key = unitKey(unit);
69
+ const status = (statuses && Object.prototype.hasOwnProperty.call(statuses, key) && statuses[key])
70
+ || (baseline && baseline[key]) || null;
71
+ const ids = [...new Set(inUnit.map((p) => files[p] && files[p].by).filter(Boolean))];
72
+ const editors = ids.map((id) => people(id)).filter(Boolean);
73
+ return {
74
+ name: home.name, unit, href: unit, file,
75
+ poster: inUnit.some((p) => rel(p) === "preview.webp"),
76
+ editedAt: editedAt || null,
77
+ mtimeMs: editedAt ? Date.parse(editedAt) : 0,
78
+ status, editors, by,
79
+ };
80
+ }
81
+
82
+ const byRecency = (a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name);
83
+ const byStatusThenRecency = (a, b) => {
84
+ const ra = a.status in STATUS_RANK ? STATUS_RANK[a.status] : 2;
85
+ const rb = b.status in STATUS_RANK ? STATUS_RANK[b.status] : 2;
86
+ return ra - rb || byRecency(a, b);
87
+ };
88
+
89
+ /**
90
+ * Everything the derived pages need, from the live manifest and the overlays.
91
+ * `people(id) → {id, name, initials, color} | null` resolves a recorded author id.
92
+ */
93
+ export function siteModel({ manifest, statuses = {}, baseline = {}, people = () => null, now = Date.now() }) {
94
+ const opps = new Map(), playground = [], tiers = { base: [], components: [], patterns: [], pages: [] };
95
+ const units = [];
96
+ for (const unit of authoredUnits(manifest || {})) {
97
+ const home = unitHome(unit);
98
+ if (!home) continue;
99
+ const e = unitEntry(manifest, unit, home, { statuses, baseline, people, now });
100
+ units.push({ ...e, home });
101
+ if (home.kind === "playground") { playground.push({ ...e, status: e.status || "in-progress" }); continue; }
102
+ if (home.kind === "tier") { tiers[home.tier].push(e); continue; }
103
+ if (!opps.has(home.opp)) opps.set(home.opp, { name: home.opp, prototypes: [] });
104
+ opps.get(home.opp).prototypes.push(e);
105
+ }
106
+ const opportunities = [...opps.values()].map((o) => {
107
+ o.prototypes.sort(byStatusThenRecency);
108
+ const seen = new Map();
109
+ for (const p of o.prototypes) for (const u of p.editors) if (!seen.has(u.id)) seen.set(u.id, u);
110
+ return { ...o, people: [...seen.values()], mtimeMs: Math.max(0, ...o.prototypes.map((p) => p.mtimeMs)) };
111
+ }).sort(byRecency);
112
+ playground.sort(byStatusThenRecency);
113
+ for (const t of TIERS) tiers[t].sort(byRecency);
114
+ return { opportunities, playground, tiers, hasPlayground: playground.length > 0, units };
115
+ }
116
+
117
+ /** What a request path names on the derived surface, or null for anything else. */
118
+ export function derivedPathKind(pathname, model) {
119
+ const p = dec(pathname);
120
+ if (p === "/" || p === "/index.html") return { kind: "root" };
121
+ if (p === "/__search.json") return { kind: "search" };
122
+ const m = /^\/([^/]+)\/?(index\.html)?$/.exec(p);
123
+ if (!m) return null;
124
+ const seg = m[1];
125
+ if (seg === "playground") return model.hasPlayground || p.endsWith("/") ? { kind: "playground" } : null;
126
+ if (TIERS.includes(seg)) return { kind: seg === "components" ? "components" : "tier", tier: seg };
127
+ if (model.opportunities.some((o) => o.name === seg)) return { kind: "opportunity", name: seg, slash: p.endsWith("/") || p.endsWith("index.html") };
128
+ return null;
129
+ }
130
+
131
+ // ── markup ───────────────────────────────────────────────────────────────────
132
+ const media = (href, hasPoster) => hasPoster
133
+ ? `<img class="preview-img" src="${href}preview.webp" alt="" aria-hidden="true" loading="lazy" decoding="async" width="768" height="480" />`
134
+ : `<div class="preview-ph" aria-hidden="true"></div>`;
135
+ const preview = (href, hasPoster) => `<div class="preview${hasPoster ? "" : " preview--ph"}">${media(href, hasPoster)}</div>`;
136
+ const filterEmpty = () => `<p class="filter-empty" data-filter-empty hidden>No matches.</p>`;
137
+ const pinStar = (key, href) => `<button type="button" class="pin-btn" data-pin-key="${key}" data-pin-href="${href}" aria-pressed="false" aria-label="Pin to sidebar" title="Pin to sidebar">${IC_STAR}</button>`;
138
+ const newCanvasBtn = (dir) => `<button type="button" class="folderbar__new" data-new-canvas="${dir}" hidden>${IC_PLUS}New canvas</button>`;
139
+ const emptyState = (...paras) => paras.map((p) => `<p class="empty">${p}</p>`).join("");
140
+ const emptyHead = (title, keepOnMobile = false) =>
141
+ `<header class="folderbar${keepOnMobile ? " folderbar--keep" : ""}"><h1 class="folderbar__title">${title}</h1><span class="folderbar__count">0</span><span class="folderbar__rule"></span></header>`;
142
+ const ghostLine = (w) => `<span class="ghost-line" style="width:${w}"></span>`;
143
+ const ghosts = (inner) => `<div class="ghosts" aria-hidden="true">${inner}</div>`;
144
+ const ghostFolderGrid = (n = 6) => ghosts(`<div class="opp-grid">${Array.from({ length: n }, () => `<div class="card-opp"><div class="preview preview--pending"></div><div class="proto-meta"><div class="proto-text">${ghostLine("58%")}${ghostLine("30%")}</div></div></div>`).join("")}</div>`);
145
+ const ghostCardGrid = (n = 8) => ghosts(`<div class="page-grid">${Array.from({ length: n }, () => `<div class="card-proto is-pending"><div class="preview preview--pending"></div><div class="proto-meta">${ghostLine("56%")}</div></div>`).join("")}</div>`);
146
+ const ghostCompTable = (n = 4) => ghosts(`<table class="comp-table"><thead><tr><th>Preview</th><th>Component</th><th>What it is</th><th class="comp-status">Status</th></tr></thead><tbody>${Array.from({ length: n }, () => `<tr><td><span class="ghost-thumb"></span></td><td><div class="comp-name">${ghostLine("110px")}</div><div class="comp-badges"><span class="ghost-chip" style="width:54px"></span><span class="ghost-chip" style="width:40px"></span></div></td><td><div class="comp-desc">${ghostLine("100%")}${ghostLine("62%")}</div></td><td class="comp-status"><span class="ghost-dot"></span></td></tr>`).join("")}</tbody></table>`);
147
+
148
+ function faceChip(u, cls) {
149
+ const ini = escAttr((u.initials || (u.name || "?").slice(0, 2)).toUpperCase());
150
+ const label = escAttr(u.name || "Someone");
151
+ return `<span class="${cls}" style="background-color:${u.color || "#4f46e5"}" data-person="${escAttr(u.id)}" title="${label}" aria-label="${label}">${ini}</span>`;
152
+ }
153
+ function facePile(people, cap = 5) {
154
+ if (!people || !people.length) return "";
155
+ const shown = people.slice(0, cap), extra = people.length - shown.length;
156
+ const chips = shown.map((u) => faceChip(u, "proto-editor opp-face")).join("");
157
+ const more = extra > 0 ? `<span class="proto-editor opp-face opp-face--more" title="+${extra} more">+${extra}</span>` : "";
158
+ return `<span class="opp-people" role="group" aria-label="Contributors">${chips}${more}</span>`;
159
+ }
160
+ function currencyLine(key, p, now) {
161
+ const cur = STATUS_META[p.status] ? p.status : "ignore";
162
+ const word = cur === "ignore" ? "" : `<span class="proto-state ${STATUS_META[cur].cls}">${STATUS_META[cur].label}</span> &middot; `;
163
+ const when = p.mtimeMs ? `<span class="proto-when" title="${fmtDate(p.mtimeMs)}">${relTime(p.mtimeMs, now)}</span>` : `<span class="proto-when"></span>`;
164
+ return `<div class="proto-date" data-currency="${escAttr(key)}">${word}${when}</div>`;
165
+ }
166
+ function statusChip(status, key) {
167
+ const cur = STATUS_META[status] ? status : "ignore";
168
+ const aria = `Status: ${STATUS_META[cur].label}. Click to change.`;
169
+ return `<button type="button" class="status-chip ${STATUS_META[cur].cls}" data-status-key="${key}" data-status="${cur}" aria-label="${aria}" title="${aria}">${STATUS_ICONS[cur]}</button>`;
170
+ }
171
+ const compStatusChip = (name) => {
172
+ const aria = "Validation: In progress. Click to mark reviewed.";
173
+ return `<button type="button" class="status-chip is-wip" data-comp-status-key="components/${name}" data-status="in-progress" aria-label="${aria}" title="${aria}">${STATUS_ICONS["in-progress"]}</button>`;
174
+ };
175
+ function metaBadges(meta) {
176
+ const layer = meta.layer ? `<span class="cbadge cbadge--layer-${escAttr(meta.layer)}">${escAttr(meta.layer)}</span>` : "";
177
+ const surf = meta.surface ? `<span class="cbadge cbadge--surf">${escAttr(meta.surface)}</span>` : "";
178
+ const cat = meta.category ? `<span class="cbadge cbadge--cat">${escAttr(meta.category)}</span>` : "";
179
+ const stat = meta.status ? `<span class="cbadge cbadge--st-${escAttr(meta.status)}">${escAttr(meta.status)}</span>` : "";
180
+ return `<div class="comp-badges">${layer}${surf}${cat}${stat}</div>`;
181
+ }
182
+
183
+ /** The page skeleton the build used to emit, with the CURRENT chrome. */
184
+ export function shell({ title, body, activeTab = "prototypes", wrapClass = "", ctx }) {
185
+ const state = { spaces: ctx.spaces || [], activeSpace: ctx.activeSpace || "", opportunities: [], hasPlayground: !!ctx.hasPlayground };
186
+ const css = ctx.chrome && ctx.chrome.css ? `/${ctx.chrome.css}` : "";
187
+ const js = ctx.chrome && ctx.chrome.js ? `/${ctx.chrome.js}` : "";
188
+ return `<!doctype html>
189
+ <html lang="en">
190
+ <head>
191
+ <meta charset="utf-8" />
192
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
193
+ <meta name="robots" content="noindex, nofollow" />
194
+ <title>${escAttr(title)}</title>
195
+ <link rel="icon" type="image/png" href="/augur-mark.png?v=${UI_VERSION}" />
196
+ <link rel="apple-touch-icon" href="/augur-mark.png?v=${UI_VERSION}" />
197
+ <link rel="manifest" href="/manifest.webmanifest" />
198
+ <meta name="theme-color" content="#2C2150" />
199
+ <link rel="preload" href="/fonts/inter-latin-wght-normal.woff2" as="font" type="font/woff2" crossorigin />
200
+ ${js ? ` <link rel="preload" href="${js}" as="script" />\n` : ""}${css ? ` <link rel="stylesheet" href="${css}" />\n` : ""}</head>
201
+ <body>
202
+ ${CHROME_MARK_START(state.activeSpace, activeTab, state.hasPlayground)}${renderAppChrome(activeTab, state, {})}${CHROME_MARK_END}
203
+ <div class="wrap${wrapClass ? " " + wrapClass : ""}">
204
+ ${body}
205
+ </div>
206
+ <script>${renderSpaceContextScript(state)}
207
+ </script>
208
+ ${js ? ` <script defer src="${js}"></script>\n` : ""} <script type="speculationrules">${JSON.stringify({ prefetch: [{ where: { and: [{ href_matches: "/*" }, { not: { href_matches: "/__*" } }] }, eagerness: "moderate" }] })}</script>
209
+ </body>
210
+ </html>
211
+ `;
212
+ }
213
+
214
+ const folderbar = (title, count, extra = "") => `<header class="folderbar"><h1 class="folderbar__title">${title}</h1><span class="folderbar__count">${count}</span><span class="folderbar__rule"></span>${extra}</header>`;
215
+
216
+ export function renderRootIndex(model, ctx) {
217
+ const label = ctx.projectsLabel || "Projects";
218
+ if (!model.opportunities.length) {
219
+ return shell({ title: "Augur", wrapClass: "wrap--wide", ctx: { ...ctx, hasPlayground: model.hasPlayground },
220
+ body: emptyHead(label) + ghostFolderGrid() + emptyState("Ask your agent for a clickable prototype and it shows up here, ready to send.") });
221
+ }
222
+ const cards = model.opportunities.map((opp) => {
223
+ const oppPath = `${enc(opp.name)}/`;
224
+ const cover = opp.prototypes[0];
225
+ const coverSrc = cover ? cover.href : "";
226
+ return `
227
+ <div class="card-opp" data-fitem data-fkey="${titleCase(opp.name)}">
228
+ <a class="card-cover-link" href="${oppPath}" aria-label="Open ${titleCase(opp.name)}"></a>
229
+ ${preview(coverSrc, cover && cover.poster)}
230
+ <div class="opp-meta">
231
+ <div class="opp-name-row"><div class="proto-name">${titleCase(opp.name)}</div>${facePile(opp.people)}</div>
232
+ <div class="proto-date">${plural(opp.prototypes.length, "prototype")} &middot; <span class="proto-when" data-currency-folder="${escAttr(opp.name)}" title="${opp.mtimeMs ? fmtDate(opp.mtimeMs) : ""}">${opp.mtimeMs ? relTime(opp.mtimeMs, ctx.now) : ""}</span></div>
233
+ </div>
234
+ </div>`;
235
+ }).join("");
236
+ return shell({ title: "Augur", wrapClass: "wrap--wide", ctx: { ...ctx, hasPlayground: model.hasPlayground },
237
+ body: `${folderbar(label, model.opportunities.length)}<div data-fgroup><div class="opp-grid">${cards}</div></div>${filterEmpty()}` });
238
+ }
239
+
240
+ export function renderOpportunityIndex(model, oppName, ctx) {
241
+ const opp = model.opportunities.find((o) => o.name === oppName);
242
+ if (!opp) return null;
243
+ const label = (ctx.projectsLabel || "Projects").toLowerCase();
244
+ const cards = opp.prototypes.map((p) => {
245
+ const download = p.file ? `<button type="button" data-dl="${p.file}" data-dlname="${enc(p.name)}.html" aria-label="Download HTML" hidden></button>` : "";
246
+ const pinKey = `/${enc(opp.name)}/${enc(p.name)}/`;
247
+ const dname = protoName(p.name);
248
+ const key = `${opp.name}/${p.name}`;
249
+ return `
250
+ <div class="card-proto" data-fitem data-fkey="${titleCase(p.name)}" data-rename-key="${key}" data-default-name="${dname}" data-del-space="${escAttr(ctx.activeSpace || "")}" data-del-path="${opp.name}/prototypes/${p.name}">
251
+ <div class="preview">
252
+ ${media(p.href, p.poster)}
253
+ <a class="preview-link" href="${p.href}" aria-label="Open ${titleCase(p.name)}"></a>
254
+ <div class="preview-actions">
255
+ ${download}
256
+ ${pinStar(pinKey, pinKey)}
257
+ </div>
258
+ ${statusChip(p.status, key)}
259
+ </div>
260
+ <div class="proto-meta">
261
+ <div class="proto-text">
262
+ <div class="proto-name">${dname}</div>
263
+ ${currencyLine(key, p, ctx.now)}
264
+ </div>
265
+ ${facePile(p.editors)}
266
+ </div>
267
+ </div>`;
268
+ }).join("");
269
+ const up = `<a class="folderbar__up" href="/" aria-label="All ${label}" title="All ${label}"><svg viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M10 3L5 8l5 5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></a>`;
270
+ return shell({ title: titleCase(opp.name), activeTab: opp.name, wrapClass: "wrap--wide", ctx: { ...ctx, hasPlayground: model.hasPlayground },
271
+ body: `<header class="folderbar">${up}<h1 class="folderbar__title">${titleCase(opp.name)}</h1><span class="folderbar__count">${opp.prototypes.length}</span><span class="folderbar__rule"></span>${newCanvasBtn(`/${opp.name}/`)}</header><div data-fgroup><div class="page-grid is-3up">${cards}</div></div>${filterEmpty()}` });
272
+ }
273
+
274
+ export function renderPlaygroundIndex(model, ctx) {
275
+ const c = { ...ctx, hasPlayground: model.hasPlayground };
276
+ if (!model.playground.length) {
277
+ return shell({ title: "Playground", activeTab: "playground", wrapClass: "wrap--wide", ctx: c,
278
+ body: emptyHead("Playground") + ghostFolderGrid() + emptyState("Ask your agent for something quick that doesn't need a project.") });
279
+ }
280
+ const cards = model.playground.map((p) => {
281
+ const folder = `${enc(p.name)}/`;
282
+ const pinKey = `/playground/${enc(p.name)}/`;
283
+ const dname = protoName(p.name);
284
+ const key = `playground/${p.name}`;
285
+ return `
286
+ <div class="card-opp" data-fitem data-fkey="${titleCase(p.name)}" data-rename-key="${key}" data-default-name="${dname}" data-del-space="${escAttr(ctx.activeSpace || "")}" data-del-path="playground/${p.name}">
287
+ <a class="card-cover-link" href="${folder}" aria-label="Open ${titleCase(p.name)}"></a>
288
+ <div class="preview">
289
+ ${media(p.href, p.poster)}
290
+ ${statusChip(p.status, key)}
291
+ </div>
292
+ <div class="preview-actions">${pinStar(pinKey, pinKey)}</div>
293
+ <div class="proto-meta">
294
+ <div class="proto-text">
295
+ <div class="proto-name">${dname}</div>
296
+ ${currencyLine(key, p, ctx.now)}
297
+ </div>
298
+ ${facePile(p.editors)}
299
+ </div>
300
+ </div>`;
301
+ }).join("");
302
+ return shell({ title: "Playground", activeTab: "playground", wrapClass: "wrap--wide", ctx: c,
303
+ body: `${folderbar("Playground", model.playground.length, newCanvasBtn("/playground/"))}<div data-fgroup><div class="opp-grid">${cards}</div></div>${filterEmpty()}` });
304
+ }
305
+
306
+ const TIER_COPY = Object.freeze({
307
+ base: { addHint: 'The atoms every screen here borrows: buttons, inputs, cards, badges, modal, icons. Components and Patterns are built out of these, and all of them wear <a href="/tokens/">Tokens</a>.', empty: "Ask your agent for your buttons, inputs and cards, one page each." },
308
+ patterns: { addHint: "Layouts that keep coming back: several Components arranged the way real screens arrange them again and again.", empty: 'Build a few <a href="/pages/">Pages</a> first, then ask your agent to pull out what repeats.' },
309
+ pages: { addHint: "", empty: "Ask your agent to build a whole screen out of your design system." },
310
+ });
311
+
312
+ export function renderTierIndex(model, tier, ctx) {
313
+ if (!TIER_COPY[tier]) return null;
314
+ const items = model.tiers[tier] || [];
315
+ const title = TIER_TITLE[tier];
316
+ const c = { ...ctx, hasPlayground: model.hasPlayground };
317
+ if (!items.length) {
318
+ return shell({ title, activeTab: tier, wrapClass: "wrap--wide", ctx: c, body: emptyHead(title, true) + ghostCardGrid() + emptyState(TIER_COPY[tier].empty) });
319
+ }
320
+ const card = (p) => `
321
+ <div class="card-proto" data-fitem data-fkey="${titleCase(p.name)}" data-rename-key="${tier}/${p.name}" data-default-name="${titleCase(p.name)}">
322
+ <div class="preview">
323
+ ${media(p.href, p.poster)}
324
+ <a class="preview-link" href="${p.href}" aria-label="Open ${titleCase(p.name)}"></a>
325
+ </div>
326
+ <div class="proto-meta">
327
+ <div class="proto-name">${titleCase(p.name)}</div>
328
+ </div>
329
+ </div>`;
330
+ const hint = TIER_COPY[tier].addHint ? `<p class="tier-hint">${TIER_COPY[tier].addHint}</p>` : "";
331
+ return shell({ title, activeTab: tier, wrapClass: "wrap--wide", ctx: c,
332
+ body: `${folderbar(title, items.length)}${hint}<div class="page-grid">${items.map(card).join("")}</div>${filterEmpty()}` });
333
+ }
334
+
335
+ /** The catalog a components table reads: registry.json → {slug → {name, classes, desc, meta}}. */
336
+ export function catalogFrom(registry) {
337
+ const out = {};
338
+ for (const it of (registry && registry.items) || []) {
339
+ if (!it || !it.name || it.type === "page") continue;
340
+ const fams = it.classes || (it.class ? [it.class] : []);
341
+ out[it.name] = {
342
+ name: it.label || titleCase(it.name),
343
+ classes: (it.cssClasses || fams).map((f) => "." + f).join(" / "),
344
+ desc: it.description || "",
345
+ meta: it.meta || null,
346
+ };
347
+ }
348
+ return out;
349
+ }
350
+
351
+ export function renderComponentsIndex(model, catalog, ctx) {
352
+ const items = model.tiers.components || [];
353
+ const c = { ...ctx, hasPlayground: model.hasPlayground };
354
+ if (!items.length) {
355
+ return shell({ title: "Components", activeTab: "components", wrapClass: "wrap--wide", ctx: c,
356
+ body: emptyHead("Components", true) + ghostCompTable() + emptyState("Ask your agent for a component you keep rebuilding, like a search field.") });
357
+ }
358
+ const rows = items.map((it) => {
359
+ const blurb = (catalog && catalog[it.name]) || { name: "", classes: "", desc: "", meta: null };
360
+ const dname = blurb.name || titleCase(it.name);
361
+ const classes = blurb.classes ? `<code>${escAttr(blurb.classes)}</code>` : "";
362
+ const badges = blurb.meta ? metaBadges(blurb.meta) : "";
363
+ const tags = blurb.meta && blurb.meta.tags && blurb.meta.tags.length ? `<div class="comp-tags">${blurb.meta.tags.map((t) => `<span>#${escAttr(t)}</span>`).join("")}</div>` : "";
364
+ const metaKey = blurb.meta ? [blurb.meta.surface, blurb.meta.category, blurb.meta.status, ...(blurb.meta.tags || [])].filter(Boolean).join(" ") : "";
365
+ const fkey = `${dname} ${blurb.classes} ${blurb.desc} ${metaKey}`.replace(/<[^>]+>/g, " ").replace(/"/g, "");
366
+ return `
367
+ <tr data-fitem data-fkey="${escAttr(fkey)}" data-rename-key="components/${it.name}" data-default-name="${escAttr(dname)}">
368
+ <td>
369
+ <a class="comp-thumb" href="${it.href}" aria-label="Open ${escAttr(dname)}">
370
+ ${media(it.href, it.poster)}
371
+ </a>
372
+ </td>
373
+ <td><div class="comp-name"><span class="proto-name">${escAttr(dname)}</span>${classes}</div>${badges}${tags}</td>
374
+ <td><div class="comp-desc" data-desc-key="components/${it.name}#desc">${escAttr(blurb.desc)}</div></td>
375
+ <td class="comp-status">${compStatusChip(it.name)}</td>
376
+ </tr>`;
377
+ }).join("");
378
+ return shell({ title: "Components", activeTab: "components", wrapClass: "wrap--wide", ctx: c,
379
+ body: `${folderbar("Components", items.length)}<table class="comp-table">
380
+ <thead><tr><th>Preview</th><th>Component</th><th>What it is</th><th class="comp-status">Status</th></tr></thead>
381
+ <tbody>${rows}</tbody>
382
+ </table>${filterEmpty()}` });
383
+ }
384
+
385
+ /** The rail's finder index — every navigable thing, the shape the chrome bundle reads. */
386
+ export function searchIndex(model, ctx) {
387
+ const idx = [{ t: ctx.projectsLabel || "Projects", y: "Index", u: "/" }];
388
+ if (model.hasPlayground) idx.push({ t: "Playground", y: "Index", u: "/playground/" });
389
+ for (const t of ["pages", "components", "base", "patterns"]) if (model.tiers[t].length) idx.push({ t: TIER_TITLE[t], y: "Index", u: `/${t}/` });
390
+ const thumb = (p) => (p.poster ? { th: `${p.href}preview.webp` } : {});
391
+ for (const opp of model.opportunities) {
392
+ const cover = opp.prototypes[0];
393
+ idx.push({ t: titleCase(opp.name), y: "Folder", u: `/${enc(opp.name)}/`, ...(cover ? thumb(cover) : {}) });
394
+ for (const p of opp.prototypes) idx.push({ t: titleCase(p.name), y: "Prototype", g: titleCase(opp.name), u: p.href, k: `${opp.name}/${p.name}`, ...thumb(p) });
395
+ }
396
+ for (const p of model.playground) idx.push({ t: titleCase(p.name), y: "Playground", u: p.href, k: `playground/${p.name}`, ...thumb(p) });
397
+ for (const p of model.tiers.pages) idx.push({ t: titleCase(p.name), y: "Page", u: p.href, k: `pages/${p.name}`, ...thumb(p) });
398
+ for (const p of model.tiers.components) idx.push({ t: titleCase(p.name), y: "Component", u: p.href, k: `components/${p.name}`, ...thumb(p) });
399
+ return idx;
400
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The health checks an instance can run ON ITSELF, from a cron inside its own worker.
3
+ *
4
+ * `A-degithub-runtime`. The acceptance test on that item is "Actions minutes consumed by
5
+ * normal operation: zero", and the canary is the last scheduled job standing between an
6
+ * instance and that number. Today it is `health.yml`, which needs a GitHub runner, a PAT
7
+ * with read on every space repo, and an org whose billing is current — and the 23 August
8
+ * billing outage killed all three for two days while the instances themselves carried on
9
+ * publishing and serving, which is the evidence this item was written from.
10
+ *
11
+ * ─────────────────────────────────────────────────────────────────────────────────────
12
+ * ⛔ WHAT THIS DELIBERATELY DOES NOT CHECK, and why the omission is the design.
13
+ *
14
+ * `health.yml` step (g) asks whether a gated path is actually gated. THIS FILE MUST NEVER
15
+ * ASK THAT. A worker probing its own front door is a detector living inside the thing it
16
+ * detects: the request re-enters this same deployment, so a misconfiguration that makes
17
+ * the platform serve assets before the worker is exactly the condition under which the
18
+ * worker's own answer stops being worth anything. This codebase has already paid for that
19
+ * lesson once — the account store's rewind detector was built and DELETED because it tried
20
+ * to detect a rewind using state inside the thing that was rewound, and a detector whose
21
+ * "all clear" is indistinguishable from "detector broken" is worse than none.
22
+ *
23
+ * So the split is by WHO CAN ANSWER HONESTLY:
24
+ * · Is my content current, and is my engine current? ← this file. The worker holds
25
+ * every input directly, and a wrong answer is a wrong answer, not a false all-clear.
26
+ * · Am I gated? ← `scripts/frontdoor-parity.mjs`,
27
+ * run from outside, and it stays outside.
28
+ * ─────────────────────────────────────────────────────────────────────────────────────
29
+ *
30
+ * Everything here is a PURE FUNCTION of a build stamp, a config and a clock, plus at most
31
+ * one outbound fetch. No storage, no bindings, no request. That is what makes it testable
32
+ * without workerd and what keeps the scheduled handler in _worker.js three lines long.
33
+ */
34
+
35
+ /** Grace windows, in seconds, matching `templates/shell/health.yml` so the two agree. */
36
+ export const HEALTH_GRACE = Object.freeze({
37
+ /** A working-tree publish is fine during a session and not fine left standing. */
38
+ dirty: 21600, // 6h
39
+ /** Longer than the space self-heal poll (6h), so (f) only fires on a genuine miss. */
40
+ rebake: 28800, // 8h
41
+ /** A release older than this, with the instance behind it, is worth saying out loud. */
42
+ staleEngineDays: 30,
43
+ });
44
+
45
+ const secondsSince = (iso, now) => {
46
+ const t = Date.parse(iso || "");
47
+ return Number.isFinite(t) ? Math.round((now - t) / 1000) : null;
48
+ };
49
+
50
+ const ok = (name, detail) => ({ name, ok: true, detail });
51
+ const bad = (name, detail) => ({ name, ok: false, detail });
52
+ const skip = (name, detail) => ({ name, skip: true, detail });
53
+
54
+ /**
55
+ * A working-tree publish that has outlived its window.
56
+ *
57
+ * The bytes a dirty publish serves exist in NO repository, so they cannot be rebuilt,
58
+ * reviewed or rolled forward. That is fine for an hour of active work and not fine left
59
+ * standing, and it is invisible to everyone whose own clone looks right.
60
+ */
61
+ export function checkDirtyPublishes(stamp, now, grace = HEALTH_GRACE.dirty) {
62
+ const out = [];
63
+ for (const [id, s] of Object.entries((stamp && stamp.spaces) || {})) {
64
+ if (!s.dirty) { out.push(ok(`clean ${id}`, `published from ${String(s.sha || "?").slice(0, 12)}`)); continue; }
65
+ const age = secondsSince(s.publishedAt, now);
66
+ if (age === null) { out.push(bad(`dirty ${id}`, "serving a working-tree publish with no timestamp, so its age cannot be judged")); continue; }
67
+ if (age <= grace) { out.push(ok(`dirty ${id}`, `working-tree publish ${age}s ago — inside the ${grace}s window`)); continue; }
68
+ out.push(bad(`dirty ${id}`,
69
+ `serving a working-tree publish (base ${String(s.sha || "?").slice(0, 12)}) for ${age}s — those exact bytes exist in no repository. Commit the work and publish again from the ${id} clone.`));
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /**
75
+ * Chrome-bake drift: a space still serving page furniture from an older engine.
76
+ *
77
+ * `engine.sha` moves on every engine deploy; a space's `builtWithEngine` moves only when
78
+ * that space republishes. The gap is invisible from the top-level number alone, which is
79
+ * why the stamp publishes both.
80
+ */
81
+ export function checkBakeDrift(stamp, now, grace = HEALTH_GRACE.rebake) {
82
+ const engineSha = stamp && stamp.engine && stamp.engine.sha;
83
+ if (!engineSha) return [skip("bake drift", "no engine sha in the stamp — nothing to compare against")];
84
+ const engineAge = secondsSince(stamp.engine.publishedAt, now);
85
+ const out = [];
86
+ for (const [id, s] of Object.entries((stamp.spaces) || {})) {
87
+ if (!s.builtWithEngine) { out.push(skip(`bake ${id}`, "published before the bake stamp existed")); continue; }
88
+ if (s.builtWithEngine === engineSha) { out.push(ok(`bake ${id}`, "chrome is current")); continue; }
89
+ // The drift is only a FINDING once the self-heal has had its chance. Before that it is
90
+ // the ordinary state of the minutes after an engine bump.
91
+ if (engineAge !== null && engineAge <= grace) {
92
+ out.push(ok(`bake ${id}`, `chrome is ${String(s.builtWithEngine).slice(0, 12)} against engine ${String(engineSha).slice(0, 12)}, and the bump is only ${engineAge}s old`));
93
+ continue;
94
+ }
95
+ out.push(bad(`bake ${id}`,
96
+ `serving chrome baked with ${String(s.builtWithEngine).slice(0, 12)} while the engine is ${String(engineSha).slice(0, 12)} — the re-bake was missed. Republish ${id}.`));
97
+ }
98
+ return out;
99
+ }
100
+
101
+ /**
102
+ * ⚠️ THE ONE CHECK THAT ASKS SOMETHING OUTSIDE, and it asks for a RELEASE, not a tag.
103
+ *
104
+ * `release-drift.mjs` exists because this distinction already cost a release: engine-bump
105
+ * in release mode reads `releases/latest`, so a tag with no release behind it moves
106
+ * nobody. An instance comparing itself against tags would report itself current while
107
+ * every self-hoster was being offered something months older.
108
+ *
109
+ * Unauthenticated on purpose — the engine repo is public, this runs at most a few times a
110
+ * day, and a token here would be a credential an instance holds for GitHub, which is the
111
+ * dependency the whole item exists to remove. A rate-limited or unreachable answer is a
112
+ * SKIP, never a failure: GitHub being down is not this instance being unhealthy.
113
+ */
114
+ export async function checkEngineStaleness(stamp, { fetchImpl = fetch, now = Date.now(), repo = "andratwiro/augur", maxAgeDays = HEALTH_GRACE.staleEngineDays } = {}) {
115
+ const mine = stamp && stamp.engine && stamp.engine.version;
116
+ if (!mine) return skip("engine staleness", "this build stamped no engine version, so there is nothing to compare");
117
+ let latest, publishedAt;
118
+ try {
119
+ const res = await fetchImpl(`https://api.github.com/repos/${repo}/releases/latest`, {
120
+ headers: { accept: "application/vnd.github+json", "user-agent": "augur-health-cron" },
121
+ });
122
+ if (!res.ok) return skip("engine staleness", `GitHub answered ${res.status} — not asking again until the next run`);
123
+ const doc = await res.json();
124
+ latest = doc && doc.tag_name;
125
+ publishedAt = doc && doc.published_at;
126
+ if (!latest) return skip("engine staleness", "no tag_name on the latest release");
127
+ } catch (e) {
128
+ return skip("engine staleness", `could not reach GitHub (${String((e && e.message) || e).slice(0, 80)})`);
129
+ }
130
+ const norm = (v) => String(v || "").replace(/^v/, "");
131
+ if (norm(latest) === norm(mine)) return ok("engine staleness", `running ${mine}, which is the newest release`);
132
+ const age = secondsSince(publishedAt, now);
133
+ const days = age === null ? null : Math.floor(age / 86400);
134
+ // Behind, but only recently. Naming that as a failure would make every instance red for
135
+ // the hours between a release and its own bump, which is the normal state.
136
+ if (days !== null && days < maxAgeDays) {
137
+ return ok("engine staleness", `running ${mine}; ${latest} is ${days}d old and the bump has not arrived yet`);
138
+ }
139
+ return bad("engine staleness",
140
+ `running ${mine} while ${latest}${days === null ? "" : ` (${days}d old)`} is the newest release. If your own CI is broken this is the only place that will say so.`);
141
+ }
142
+
143
+ /**
144
+ * The whole report. `checks` is a flat list so a caller never has to know the shape of
145
+ * each family, and `failures` is derived rather than counted by hand.
146
+ *
147
+ * ⚠️ IT RETURNS A REPORT EVEN WHEN IT FAILS TO PRODUCE ONE. A cron that threw would leave
148
+ * the last report standing and reading as current, which is the failure this whole file
149
+ * exists to avoid — "healthy" and "nobody looked" must never be the same answer.
150
+ */
151
+ export async function runHealth({ stamp, now = Date.now(), fetchImpl = fetch, repo, grace = HEALTH_GRACE } = {}) {
152
+ const checks = [];
153
+ try {
154
+ if (!stamp) {
155
+ checks.push(bad("build stamp", "the instance could not compose its own build stamp — nothing below could be computed"));
156
+ } else {
157
+ checks.push(...checkDirtyPublishes(stamp, now, grace.dirty));
158
+ checks.push(...checkBakeDrift(stamp, now, grace.rebake));
159
+ checks.push(await checkEngineStaleness(stamp, { fetchImpl, now, repo, maxAgeDays: grace.staleEngineDays }));
160
+ }
161
+ } catch (e) {
162
+ checks.push(bad("health cron", `the run itself threw: ${String((e && e.message) || e).slice(0, 200)}`));
163
+ }
164
+ const failures = checks.filter((c) => c.ok === false).length;
165
+ return {
166
+ at: new Date(now).toISOString(),
167
+ ok: failures === 0,
168
+ failures,
169
+ // Every check, passing ones included. A report that listed only failures could not be
170
+ // told apart from a report that ran nothing.
171
+ checks,
172
+ };
173
+ }
@@ -0,0 +1 @@
1
+ []