Public: anyone can read this; the crew can change it.
Home's spend tile shows the forge's own ceiling and the time it was hit, and the terminal dashboard says the same #5818
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/5798-home-s-spend-tile-shows-the-forge-s-own → main 1 commit fork 1 thread · 1 unresolved
10 files
changed +128
−22
src/crew/autonomy.ts +3 −3
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | |
| 15 | 15 | import type { Database } from "bun:sqlite"; |
| 16 | 16 | import type { App } from "../app.ts"; |
| 17 | import { ceilingReached, forgeCeiling } from "../flow/ceiling.ts"; | |
| 17 | 18 | import { |
| 18 | 19 | addComment, |
| 19 | 20 | assignIssue, |
| @@ -279,7 +280,7 @@ export function autonomyStatus( | ||
| 279 | 280 | ): AutonomyStatus { |
| 280 | 281 | const spent = spentToday(app.db, repo.id, now); |
| 281 | 282 | const crew = listCrewRoles(app.db, repo.id); |
| 282 | const forgeCap = app.config.crewCeilingUsd; | |
| 283 | const forgeCap = forgeCeiling(app.config); | |
| 283 | 284 | const paused: Paused | null = |
| 284 | 285 | repo.autonomy === "off" |
| 285 | 286 | ? null |
| @@ -289,8 +290,7 @@ export function autonomyStatus( | ||
| 289 | 290 | ? "no crew" |
| 290 | 291 | : spent >= repo.autonomyCeilingUsd |
| 291 | 292 | ? "ceiling" |
| 292 | : forgeCap !== undefined && | |
| 293 | spentTodayEverywhere(app.db, now) >= forgeCap | |
| 293 | : ceilingReached(spentTodayEverywhere(app.db, now), forgeCap) | |
| 294 | 294 | ? "forge ceiling" |
| 295 | 295 | : null; |
| 296 | 296 | const tomorrow = startOfTodayUtc(now); |
src/flow/ceiling.ts added +39
| @@ -0,0 +1,39 @@ | ||
| 1 | /** | |
| 2 | * The forge's own daily crew ceiling (#5798): the one rule for reading the cap | |
| 3 | * and for when it was hit. `crew/autonomy.ts`, the tiles and the terminal all | |
| 4 | * ask here, so they cannot disagree. Per-account ceilings are a different | |
| 5 | * thing and never folded into this number. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import { startOfTodayUtc } from "../crew/autonomy.ts"; | |
| 10 | ||
| 11 | /** The forge cap in dollars, or null when none is set. */ | |
| 12 | export function forgeCeiling(config: { crewCeilingUsd?: number }): number | null { | |
| 13 | return config.crewCeilingUsd ?? null; | |
| 14 | } | |
| 15 | ||
| 16 | /** Spend has reached the cap: the very test that pauses the builders. */ | |
| 17 | export function ceilingReached(spentUsd: number, cap: number | null): boolean { | |
| 18 | return cap !== null && spentUsd >= cap; | |
| 19 | } | |
| 20 | ||
| 21 | /** | |
| 22 | * When the run that carried today's crew spend across the cap was created | |
| 23 | * (ISO), or null while under it or with no cap. | |
| 24 | */ | |
| 25 | export function ceilingHitAt(db: Database, now: Date, cap: number | null): string | null { | |
| 26 | if (cap === null) return null; | |
| 27 | const rows = db | |
| 28 | .query<{ at: string; cost: number | null }, [string]>( | |
| 29 | `SELECT created_at AS at, cost_usd AS cost FROM run | |
| 30 | WHERE kind = 'agent' AND created_at >= ?1 ORDER BY created_at, rowid`, | |
| 31 | ) | |
| 32 | .all(startOfTodayUtc(now).toISOString()); | |
| 33 | let sum = 0; | |
| 34 | for (const r of rows) { | |
| 35 | sum += r.cost ?? 0; | |
| 36 | if (ceilingReached(sum, cap)) return r.at; | |
| 37 | } | |
| 38 | return null; | |
| 39 | } |
src/flow/kpi_words.ts +20 −1
| @@ -50,6 +50,25 @@ export function spendFull(s: Kpis["spend"]): string { | ||
| 50 | 50 | return s.ceilingUsd === null ? cents(s.todayUsd) : `${cents(s.todayUsd)} of ${cents(s.ceilingUsd)}`; |
| 51 | 51 | } |
| 52 | 52 | |
| 53 | /** The `HH:MMZ` of an ISO time. */ | |
| 54 | const hhmmZ = (iso: string): string => `${iso.slice(11, 16)}Z`; | |
| 55 | ||
| 56 | /** The forge ceiling's state in words, shared by the tile and the terminal row. */ | |
| 57 | export function spendCeilingWords(s: Kpis["spend"]): { note: string; title: string; attn: boolean } { | |
| 58 | if (s.ceilingUsd === null) return { note: "no forge ceiling", title: "No forge ceiling is set", attn: false }; | |
| 59 | if (s.todayUsd >= s.ceilingUsd) { | |
| 60 | const note = s.hitAt ? `ceiling hit ${hhmmZ(s.hitAt)}, builders idle` : "ceiling hit, builders idle"; | |
| 61 | return { note, title: note, attn: true }; | |
| 62 | } | |
| 63 | return { note: "of the forge ceiling", title: "Crew spend today (UTC), of the forge ceiling", attn: false }; | |
| 64 | } | |
| 65 | ||
| 66 | /** The terminal's tail on the spend line: only the two states worth a word. */ | |
| 67 | function spendLineNote(s: Kpis["spend"]): string { | |
| 68 | const w = spendCeilingWords(s); | |
| 69 | return w.attn || s.ceilingUsd === null ? `, ${w.note}` : ""; | |
| 70 | } | |
| 71 | ||
| 53 | 72 | const DAY_MS = 24 * 3_600_000; |
| 54 | 73 | |
| 55 | 74 | /** Past a day, or with no backup at all, the last off-box backup is overdue. */ |
| @@ -118,7 +137,7 @@ export function kpiLines(k: Kpis, live: { sha: string; since: string } | null, n | ||
| 118 | 137 | `Live version ${live === null ? "— nothing live yet" : `${live.sha.slice(0, 7)}, live ${live.since}`}`, |
| 119 | 138 | `Median check time ${checkTime(k.medianCheckMs)}`, |
| 120 | 139 | `Builders running ${buildersValue(k.builders)}${k.builders.slots === 0 ? ", no slots set" : ""}`, |
| 121 | `Crew spend today ${spendValue(k.spend)}${k.spend.ceilingUsd === null ? ", no ceiling" : ""}`, | |
| 140 | `Crew spend today ${spendValue(k.spend)}${spendLineNote(k.spend)}`, | |
| 122 | 141 | `Last off-box backup ${backupAge(k.backupAt, now)}${backupOverdue(k.backupAt, now) ? ", overdue" : ""}`, |
| 123 | 142 | `Visitors ${visitorsValue(k.reach)}, ${visitorsNote(k.reach)} ${trendText(k.reach.visitorDays)} 30 days`, |
| 124 | 143 | `Accounts ${usersValue(k.reach)} signed in today`, |
src/flow/kpis.ts +3 −2
| @@ -15,6 +15,7 @@ | ||
| 15 | 15 | |
| 16 | 16 | import type { Database } from "bun:sqlite"; |
| 17 | 17 | import { spentTodayEverywhere } from "../crew/autonomy.ts"; |
| 18 | import { ceilingHitAt, ceilingReached } from "./ceiling.ts"; | |
| 18 | 19 | import { lastOffBox } from "../snapshot/off_box.ts"; |
| 19 | 20 | import { readReach, type Reach } from "./reach.ts"; |
| 20 | 21 | import { HOURS, median } from "./throughput.ts"; |
| @@ -33,7 +34,7 @@ export interface Kpis { | ||
| 33 | 34 | /** Crew builders running now, of the slots the autonomous repositories are set to. */ |
| 34 | 35 | readonly builders: { readonly running: number; readonly slots: number }; |
| 35 | 36 | /** What the crew's runs cost since UTC midnight, and the forge's daily ceiling (null: none set). */ |
| 36 | readonly spend: { readonly todayUsd: number; readonly ceilingUsd: number | null }; | |
| 37 | readonly spend: { readonly todayUsd: number; readonly ceilingUsd: number | null; /** When the run that crossed the cap was created (ISO); null while under it. */ readonly hitAt?: string | null }; | |
| 37 | 38 | /** When a snapshot was last known off the machine (a bucket destination or the mirror, `off_box.ts`); null when never. */ |
| 38 | 39 | readonly backupAt: string | null; |
| 39 | 40 | /** Accounts, visitors and page speed (`reach.ts`). */ |
| @@ -100,7 +101,7 @@ export function readKpis( | ||
| 100 | 101 | backupAt, |
| 101 | 102 | builders, |
| 102 | 103 | reach: readReach(db, now), |
| 103 | spend: { todayUsd, ceilingUsd: forge.ceilingUsd }, | |
| 104 | spend: { todayUsd, ceilingUsd: forge.ceilingUsd, hitAt: ceilingReached(todayUsd, forge.ceilingUsd) ? ceilingHitAt(db, now, forge.ceilingUsd) : null }, | |
| 104 | 105 | landedHours, |
| 105 | 106 | landed: landedHours.reduce((a, b) => a + b, 0), |
| 106 | 107 | live: live ?? null, |
src/tui/screens/dashboard.ts +2 −1
| @@ -97,6 +97,7 @@ import { issueScreen } from "./issue.ts"; | ||
| 97 | 97 | import { listPinned } from "../../actions/board-pins.ts"; |
| 98 | 98 | import { migrateScreen } from "./migrate.ts"; |
| 99 | 99 | import { newRepoScreen } from "./newrepo.ts"; |
| 100 | import { forgeCeiling } from "../../flow/ceiling.ts"; | |
| 100 | 101 | import { pullScreen } from "./pull.ts"; |
| 101 | 102 | import { repoScreen } from "./repo.ts"; |
| 102 | 103 | import { local } from "./where.ts"; |
| @@ -107,7 +108,7 @@ const FIGURES_ROOM = 6; | ||
| 107 | 108 | |
| 108 | 109 | /** The forge-wide daily ceiling, from config, for the spend figure; the data directory, for the backup's age. */ |
| 109 | 110 | export const kpiForge = (app: { config: { crewCeilingUsd?: number; dataDir: string } }) => ({ |
| 110 | ceilingUsd: app.config.crewCeilingUsd ?? null, | |
| 111 | ceilingUsd: forgeCeiling(app.config), | |
| 111 | 112 | dataDir: app.config.dataDir, |
| 112 | 113 | }); |
| 113 | 114 |
src/web/studio/home.ts +3 −2
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * best of what is public (`actions/front.ts`). |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { forgeCeiling } from "../../flow/ceiling.ts"; | |
| 9 | 10 | import { liveLine } from "../pages/releases.ts"; |
| 10 | 11 | import { |
| 11 | 12 | frontPage, |
| @@ -253,8 +254,8 @@ export async function studioHome( | ||
| 253 | 254 | const rendering = performance.now(); |
| 254 | 255 | const ask = firstSteps ?? everydayCard(ctx, user); |
| 255 | 256 | const figures = user.isAdmin && steps === undefined; |
| 256 | const ceilingUsd = ctx.app.config.crewCeilingUsd; | |
| 257 | const kpiOpts = { dataDir: ctx.app.config.dataDir, ...(ceilingUsd === undefined ? {} : { ceilingUsd }) }; | |
| 257 | const ceilingUsd = forgeCeiling(ctx.app.config); | |
| 258 | const kpiOpts = { dataDir: ctx.app.config.dataDir, ...(ceilingUsd === null ? {} : { ceilingUsd }) }; | |
| 258 | 259 | const body = html`<h1 class="sr-only">Home</h1> |
| 259 | 260 | ${ask} ${address} ${figures |
| 260 | 261 | ? timePhase(ctx.request, "kpis", () => kpiRow(ctx.app.db, kpiOpts), () => kpiCache(ctx.app.db)) |
src/web/studio/kpi_row.ts +6 −3
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import type { Database } from "bun:sqlite"; |
| 11 | import { backupAge, backupFull, backupOverdue, buildersValue, checkTime, reactNote, speedWords, spendFull, spendValue, usersValue, visitorsNote, visitorsValue, waitingWords } from "../../flow/kpi_words.ts"; | |
| 11 | import { backupAge, backupFull, backupOverdue, buildersValue, checkTime, reactNote, speedWords, spendCeilingWords, spendFull, spendValue, usersValue, visitorsNote, visitorsValue, waitingWords } from "../../flow/kpi_words.ts"; | |
| 12 | 12 | import type { Kpis } from "../../flow/kpis.ts"; |
| 13 | 13 | import { cachedKpis } from "./home_cache.ts"; |
| 14 | 14 | import { escapeHtml, html, raw, type Html } from "../html.ts"; |
| @@ -91,6 +91,7 @@ export function liveNow(): { sha: string; at: string } | null { | ||
| 91 | 91 | |
| 92 | 92 | export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now: Date = new Date()): Html { |
| 93 | 93 | const overdue = backupOverdue(k.backupAt, now); |
| 94 | const spendWords = spendCeilingWords(k.spend); | |
| 94 | 95 | const rate = k.landed / k.landedHours.length; |
| 95 | 96 | return html`<section class="stats mb" aria-label="The last 24 hours, as of this page load"> |
| 96 | 97 | ${tile( |
| @@ -131,8 +132,10 @@ export function kpiRowFor(k: Kpis, live: { sha: string; at: string } | null, now | ||
| 131 | 132 | "/settings/crew", |
| 132 | 133 | spendValue(k.spend), |
| 133 | 134 | spendFull(k.spend), |
| 134 | k.spend.ceilingUsd === null ? "no ceiling" : "of the daily ceiling", | |
| 135 | k.spend.ceilingUsd === null ? "No daily ceiling is set" : "Crew spend today (UTC), of the daily ceiling", | |
| 135 | spendWords.note, | |
| 136 | spendWords.title, | |
| 137 | null, | |
| 138 | spendWords.attn ? "attn" : null, | |
| 136 | 139 | )} |
| 137 | 140 | ${tile( |
| 138 | 141 | "Last off-box backup", |
test/home-kpi-page.test.ts +3 −3
| @@ -76,7 +76,7 @@ test("home renders the nine tiles; only an admin sees them", async () => { | ||
| 76 | 76 | expect((a.match(/class="stat" href="\/boards\/reach"/g) ?? []).length).toBe(3); |
| 77 | 77 | |
| 78 | 78 | // The figures count every repository, so a non-admin sees none. |
| 79 | expect(a).toContain("no ceiling"); | |
| 79 | expect(a).toContain("no forge ceiling"); | |
| 80 | 80 | expect(a).toContain("last 30 days"); |
| 81 | 81 | expect(await home(bob)).not.toContain('class="stats mb"'); |
| 82 | 82 | expect(await home(bob)).not.toContain("last 30 days"); |
| @@ -104,8 +104,8 @@ test("home passes the forge's daily ceiling to the spend tile", async () => { | ||
| 104 | 104 | }); |
| 105 | 105 | const body = await res.text(); |
| 106 | 106 | expect(body).toContain("of $40"); |
| 107 | expect(body).toContain("of the daily ceiling"); | |
| 108 | expect(body).not.toContain("no ceiling"); | |
| 107 | expect(body).toContain("of the forge ceiling"); | |
| 108 | expect(body).not.toContain("no forge ceiling"); | |
| 109 | 109 | } finally { |
| 110 | 110 | s.stop(); |
| 111 | 111 | capped.close(); |
test/home-kpi-row.test.ts +42 −6
| @@ -141,7 +141,7 @@ describe("the terminal's figures", () => { | ||
| 141 | 141 | expect(lines).toHaveLength(9); |
| 142 | 142 | expect(lines).toContain("Last off-box backup never, overdue"); |
| 143 | 143 | expect(lines[4]).toBe("Crew spend today $12 of $40"); |
| 144 | expect(kpiLines({ ...k, spend: { todayUsd: 0.4, ceilingUsd: null } }, null)[4]).toBe("Crew spend today $0.40, no ceiling"); | |
| 144 | expect(kpiLines({ ...k, spend: { todayUsd: 0.4, ceilingUsd: null } }, null)[4]).toBe("Crew spend today $0.40, no forge ceiling"); | |
| 145 | 145 | expect(lines[3]).toBe("Builders running 2 of 4"); |
| 146 | 146 | expect(kpiLines({ ...k, builders: { running: 0, slots: 0 } }, null)[3]).toBe("Builders running —, no slots set"); |
| 147 | 147 | expect(lines[0]).toContain("Shipped per hour 0.3"); |
| @@ -164,8 +164,8 @@ describe("the spend tile", () => { | ||
| 164 | 164 | test("shows today's spend of the ceiling, the cents in the title, linking to the crew page", () => { |
| 165 | 165 | const out = row({ todayUsd: 12.34, ceilingUsd: 40 }); |
| 166 | 166 | expect(out).toContain('class="stat-value num" title="$12.34 of $40.00">$12 of $40<'); |
| 167 | expect(out).toContain('aria-label="Crew spend today: $12.34 of $40.00, Crew spend today (UTC), of the daily ceiling"'); | |
| 168 | expect(out).toContain(">of the daily ceiling<"); | |
| 167 | expect(out).toContain('aria-label="Crew spend today: $12.34 of $40.00, Crew spend today (UTC), of the forge ceiling"'); | |
| 168 | expect(out).toContain(">of the forge ceiling<"); | |
| 169 | 169 | }); |
| 170 | 170 | |
| 171 | 171 | test("a small spend shows cents, not $0", () => { |
| @@ -175,7 +175,7 @@ describe("the spend tile", () => { | ||
| 175 | 175 | test("without a ceiling it shows the spend alone and says no ceiling", () => { |
| 176 | 176 | const out = row({ todayUsd: 12.34, ceilingUsd: null }); |
| 177 | 177 | expect(out).toContain('title="$12.34">$12<'); |
| 178 | expect(out).toContain(">no ceiling<"); | |
| 178 | expect(out).toContain(">no forge ceiling<"); | |
| 179 | 179 | expect(out).not.toContain(" of $"); |
| 180 | 180 | }); |
| 181 | 181 | }); |
| @@ -196,8 +196,8 @@ describe("the spend figure from the database", () => { | ||
| 196 | 196 | ('1','agent',5,'2026-10-19T00:00:00.000Z'), ('2','agent',7.5,'2026-10-19T14:00:00.000Z'), |
| 197 | 197 | ('3','agent',100,'2026-10-18T23:59:59.000Z'), ('4','check',9,'2026-10-19T10:00:00.000Z')`, |
| 198 | 198 | ); |
| 199 | expect(readKpis(db, now, { ceilingUsd: 40 }).spend).toEqual({ todayUsd: 12.5, ceilingUsd: 40 }); | |
| 200 | expect(readKpis(db, now).spend).toEqual({ todayUsd: 12.5, ceilingUsd: null }); | |
| 199 | expect(readKpis(db, now, { ceilingUsd: 40 }).spend).toEqual({ todayUsd: 12.5, ceilingUsd: 40, hitAt: null }); | |
| 200 | expect(readKpis(db, now).spend).toEqual({ todayUsd: 12.5, ceilingUsd: null, hitAt: null }); | |
| 201 | 201 | }); |
| 202 | 202 | }); |
| 203 | 203 | |
| @@ -398,3 +398,39 @@ describe("home waiting line", () => { | ||
| 398 | 398 | expect(body).not.toContain('href="/review"'); |
| 399 | 399 | }, 30_000); |
| 400 | 400 | }); |
| 401 | ||
| 402 | describe("the forge ceiling hit (#5798)", () => { | |
| 403 | const base = { landedHours: hours(0), landed: 0, live: null, backupAt: null, reach: NO_REACH, medianCheckMs: null, builders: { running: 0, slots: 0 } }; | |
| 404 | const over = { todayUsd: 601, ceilingUsd: 600, hitAt: "2026-10-25T14:03:20.000Z" }; | |
| 405 | ||
| 406 | test("past the cap the tile is attention-toned and names the time; the terminal says the same words", () => { | |
| 407 | const out = kpiRowFor({ ...base, spend: over }, null).toString(); | |
| 408 | expect(out).toContain("ceiling hit 14:03Z, builders idle"); | |
| 409 | expect(out).toContain("attn"); | |
| 410 | expect(kpiLines({ ...base, spend: over }, null)[4]).toBe("Crew spend today $601 of $600, ceiling hit 14:03Z, builders idle"); | |
| 411 | }); | |
| 412 | ||
| 413 | test("under the cap there is no hit; with no cap both doors say no forge ceiling", () => { | |
| 414 | expect(kpiRowFor({ ...base, spend: { todayUsd: 5, ceilingUsd: 600, hitAt: null } }, null).toString()).not.toContain("ceiling hit"); | |
| 415 | expect(kpiRowFor({ ...base, spend: { todayUsd: 5, ceilingUsd: null } }, null).toString()).toContain("no forge ceiling"); | |
| 416 | expect(kpiLines({ ...base, spend: { todayUsd: 5, ceilingUsd: null } }, null)[4]).toContain("no forge ceiling"); | |
| 417 | }); | |
| 418 | ||
| 419 | test("the hit time is when the run that crossed the cap was created", async () => { | |
| 420 | const { readKpis } = await import("../src/flow/kpis.ts"); | |
| 421 | const db = new Database(":memory:"); | |
| 422 | db.run("CREATE TABLE repo (autonomy TEXT, autonomy_slots INTEGER, default_branch TEXT, id TEXT)"); | |
| 423 | db.run("CREATE TABLE run (id TEXT, kind TEXT, state TEXT, started_at TEXT, finished_at TEXT, serving_at TEXT, env TEXT, commit_oid TEXT, cost_usd REAL, created_at TEXT)"); | |
| 424 | db.run("CREATE TABLE pull (merged_at TEXT, merged_oid TEXT, base_oid TEXT, base_branch TEXT, issue_id TEXT)"); | |
| 425 | db.run("CREATE TABLE issue (id TEXT, repo_id TEXT)"); | |
| 426 | reachTables(db); | |
| 427 | db.run( | |
| 428 | `INSERT INTO run (id, kind, cost_usd, created_at) VALUES | |
| 429 | ('a','agent',300,'2026-10-25T10:00:00.000Z'), ('b','agent',300,'2026-10-25T12:30:00.000Z'), ('c','agent',1,'2026-10-25T15:00:00.000Z')`, | |
| 430 | ); | |
| 431 | const now = new Date("2026-10-25T20:00:00Z"); | |
| 432 | expect(readKpis(db, now, { ceilingUsd: 600 }).spend.hitAt).toBe("2026-10-25T12:30:00.000Z"); | |
| 433 | expect(readKpis(db, now, { ceilingUsd: 700 }).spend.hitAt).toBeNull(); | |
| 434 | expect(readKpis(db, now).spend.hitAt).toBeNull(); | |
| 435 | }); | |
| 436 | }); |
test/tui-kpis.test.ts +7 −1
| @@ -86,6 +86,12 @@ test("the live figure is the running commit, as /releases shows it", async () => | ||
| 86 | 86 | test("the admin's figures carry the configured daily ceiling, and say so when there is none", async () => { |
| 87 | 87 | const capped = { ...forge, config: { ...forge.config, crewCeilingUsd: 40 } }; |
| 88 | 88 | expect(await firstScreen(alice, capped)).toContain("Crew spend today $0.00 of $40"); |
| 89 | expect(await firstScreen(alice)).toContain("Crew spend today $0.00, no ceiling"); | |
| 89 | expect(await firstScreen(alice)).toContain("Crew spend today $0.00, no forge ceiling"); | |
| 90 | 90 | expect(await firstScreen(alice)).toContain("Visitors 0 today, 0 in 7 days"); |
| 91 | 91 | }); |
| 92 | ||
| 93 | test("past the forge cap the terminal and the web tile read the same words", async () => { | |
| 94 | const { spendCeilingWords } = await import("../src/flow/kpi_words.ts"); | |
| 95 | const w = spendCeilingWords({ todayUsd: 601, ceilingUsd: 600, hitAt: "2026-10-25T14:03:20.000Z" }); | |
| 96 | expect(w).toEqual({ note: "ceiling hit 14:03Z, builders idle", title: "ceiling hit 14:03Z, builders idle", attn: true }); | |
| 97 | }); |
new above
offline
Non-blocking: this closes an import cycle with crew/autonomy.ts (which now imports this module). Consider moving
startOfTodayUtchere or into its own small module.