Public: anyone can read this; the crew can change it.
The flow vitals carry stranded spend, the oldest approved wait, and the red and queued counts, and home, the tick line and MCP `flow` show them #6676
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6424-the-flow-vitals-carry-stranded-spend-the → main 4 commits fork 4 threads · 4 unresolved
7 files
changed +134
−20
src/actions/telemetry.ts +2 −2
| @@ -10,12 +10,12 @@ import { grant, hasGrant, logRead, revoke } from "../core/telemetry.ts"; | ||
| 10 | 10 | import { getUserByName, type User } from "../core/users.ts"; |
| 11 | 11 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 12 | 12 | |
| 13 | export type Reader = { kind: "owner" } | { kind: "agent" }; | |
| 13 | export type Reader = { kind: "owner" } | { kind: "agent"; spend?: boolean }; | |
| 14 | 14 | |
| 15 | 15 | /** Whether `user` reads telemetry, and as what (the agent's view has private repositories cut out). */ |
| 16 | 16 | export function telemetryReader(app: App, user: User | undefined): Reader | undefined { |
| 17 | 17 | if (!user) return undefined; |
| 18 | if (user.kind === "agent") return hasGrant(app.db, user.id) ? { kind: "agent" } : undefined; | |
| 18 | if (user.kind === "agent") return hasGrant(app.db, user.id) ? { kind: "agent", spend: true } : undefined; | |
| 19 | 19 | return user.isAdmin ? { kind: "owner" } : undefined; |
| 20 | 20 | } |
| 21 | 21 |
src/crew/governor.test.ts +1 −1
| @@ -3,7 +3,7 @@ import type { Vitals } from "../flow/vitals.ts"; | ||
| 3 | 3 | import { governedSlots } from "./governor.ts"; |
| 4 | 4 | |
| 5 | 5 | const v = (p: Partial<Vitals>): Vitals => ({ |
| 6 | open: 0, opened1h: 0, landed1h: 0, opened6h: 0, landed6h: 0, conflicted: 0, ...p, | |
| 6 | open: 0, opened1h: 0, landed1h: 0, opened6h: 0, landed6h: 0, conflicted: 0, red: 0, queued: 0, oldestApprovedHours: null, strandedUsd: 0, ...p, | |
| 7 | 7 | }); |
| 8 | 8 | |
| 9 | 9 | describe("governedSlots", () => { |
src/flow/vitals.ts +50 −7
| @@ -11,11 +11,16 @@ | ||
| 11 | 11 | |
| 12 | 12 | import type { Database } from "bun:sqlite"; |
| 13 | 13 | import { conflictedPulls } from "./conflicted.ts"; |
| 14 | import { readChecksPile } from "./checks_pile.ts"; | |
| 15 | import { readOldestApproved } from "./oldest_approved.ts"; | |
| 16 | import { readStranded } from "./stranded.ts"; | |
| 14 | 17 | |
| 15 | 18 | const HOUR_MS = 3_600_000; |
| 16 | 19 | |
| 17 | 20 | export const OPEN_TARGET = 40; |
| 18 | 21 | export const CONFLICTED_TARGET = 5; |
| 22 | /** A clog is declared when the oldest approved pull has waited past this (#6422). */ | |
| 23 | export const APPROVED_WAIT_HOURS = 24; | |
| 19 | 24 | |
| 20 | 25 | export interface Vitals { |
| 21 | 26 | readonly open: number; |
| @@ -24,6 +29,24 @@ export interface Vitals { | ||
| 24 | 29 | readonly opened6h: number; |
| 25 | 30 | readonly landed6h: number; |
| 26 | 31 | readonly conflicted: number; |
| 32 | /** Open pulls whose latest check is red. */ | |
| 33 | readonly red: number; | |
| 34 | /** Open pulls with a check queued for a slot. */ | |
| 35 | readonly queued: number; | |
| 36 | /** Hours the longest-waiting approved pull has waited; null when none is. */ | |
| 37 | readonly oldestApprovedHours: number | null; | |
| 38 | /** Dollars of crew runs on open, unlanded pulls. */ | |
| 39 | readonly strandedUsd: number; | |
| 40 | } | |
| 41 | ||
| 42 | /** What a reader is shown: `strandedUsd` is left out for a reader without telemetry:read. */ | |
| 43 | export type ShownVitals = Omit<Vitals, "strandedUsd"> & { readonly strandedUsd?: number }; | |
| 44 | ||
| 45 | /** `v` as a reader may see it: dollars only when `spend` (the owner, or an agent holding telemetry:read). */ | |
| 46 | export function vitalsFor(v: Vitals, spend: boolean): ShownVitals { | |
| 47 | if (spend) return v; | |
| 48 | const { strandedUsd: _spend, ...rest } = v; | |
| 49 | return rest; | |
| 27 | 50 | } |
| 28 | 51 | |
| 29 | 52 | /** `publicOnly` counts public repositories alone, for a reader who must not learn what private ones hold. */ |
| @@ -32,7 +55,7 @@ export function readVitals(db: Database, now = new Date(), publicOnly = false): | ||
| 32 | 55 | const h1 = new Date(now.getTime() - HOUR_MS).toISOString(); |
| 33 | 56 | const h6 = new Date(now.getTime() - 6 * HOUR_MS).toISOString(); |
| 34 | 57 | const row = db |
| 35 | .query<Omit<Vitals, "conflicted">, [string, string, string, number]>( | |
| 58 | .query<Omit<Vitals, "conflicted" | "red" | "queued" | "oldestApprovedHours" | "strandedUsd">, [string, string, string, number]>( | |
| 36 | 59 | `SELECT |
| 37 | 60 | (SELECT COUNT(*) FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id |
| 38 | 61 | WHERE (?4 = 0 OR r.visibility = 'public') AND i.state = 'open' AND p.merged_at IS NULL) AS open, |
| @@ -46,6 +69,7 @@ export function readVitals(db: Database, now = new Date(), publicOnly = false): | ||
| 46 | 69 | WHERE (?4 = 0 OR r.visibility = 'public') AND p.base_branch = r.default_branch AND p.merged_at >= ?2 AND p.merged_at <= ?3) AS landed6h`, |
| 47 | 70 | ) |
| 48 | 71 | .get(h1, h6, to, publicOnly ? 1 : 0); |
| 72 | const pile = readChecksPile(db, publicOnly); | |
| 49 | 73 | return { |
| 50 | 74 | open: row?.open ?? 0, |
| 51 | 75 | opened1h: row?.opened1h ?? 0, |
| @@ -53,24 +77,43 @@ export function readVitals(db: Database, now = new Date(), publicOnly = false): | ||
| 53 | 77 | opened6h: row?.opened6h ?? 0, |
| 54 | 78 | landed6h: row?.landed6h ?? 0, |
| 55 | 79 | conflicted: conflictedPulls(db, publicOnly).length, |
| 80 | red: pile.red, | |
| 81 | queued: pile.queued, | |
| 82 | oldestApprovedHours: readOldestApproved(db, now, publicOnly)?.hours ?? null, | |
| 83 | strandedUsd: readStranded(db, publicOnly).usd, | |
| 56 | 84 | }; |
| 57 | 85 | } |
| 58 | 86 | |
| 59 | 87 | /** Which targets the reading misses, in words; empty when it meets them all. */ |
| 60 | export function vitalsMisses(v: Vitals): string[] { | |
| 88 | export function vitalsMisses(v: ShownVitals): string[] { | |
| 61 | 89 | const out: string[] = []; |
| 62 | 90 | if (v.open >= OPEN_TARGET) out.push(`open ${v.open} ≥ ${OPEN_TARGET}`); |
| 63 | 91 | if (v.conflicted >= CONFLICTED_TARGET) out.push(`conflicted ${v.conflicted} ≥ ${CONFLICTED_TARGET}`); |
| 64 | 92 | if (v.landed6h < v.opened6h) out.push(`landed ${v.landed6h} < opened ${v.opened6h} over 6 h`); |
| 93 | if (v.oldestApprovedHours !== null && v.oldestApprovedHours > APPROVED_WAIT_HOURS) | |
| 94 | out.push(`oldest approved pull has waited ${Math.floor(v.oldestApprovedHours)} h`); | |
| 65 | 95 | return out; |
| 66 | 96 | } |
extras() returns three parts and both callers repeat the filter/join. Let it return the non-null parts as an array and have the callers join with their own separator. |
||
| 67 | 97 | |
| 68 | /** The tick line's one value: `open 12, opened/landed 3/2 in 1 h, 9/11 in 6 h, conflicted 1`. */ | |
| 69 | export function vitalsWords(v: Vitals): string { | |
| 70 | return `open ${v.open}, opened/landed ${v.opened1h}/${v.landed1h} in 1 h, ${v.opened6h}/${v.landed6h} in 6 h, conflicted ${v.conflicted}`; | |
| 98 | const usd = (n: number) => `$${n.toFixed(2)}`; | |
| 99 | ||
| 100 | /** The new numbers, in the order both lines say them; each part is left out when it has nothing to say. */ | |
| 101 | function extras(v: ShownVitals): { red: string; oldest: string | null; stranded: string | null } { | |
| 102 | return { | |
| 103 | red: `${v.red} red, ${v.queued} queued`, | |
| 104 | oldest: v.oldestApprovedHours === null ? null : `oldest approved ${Math.floor(v.oldestApprovedHours)} h`, | |
| 105 | stranded: v.strandedUsd === undefined ? null : `${usd(v.strandedUsd)} stranded`, | |
| 106 | }; | |
| 107 | } | |
| 108 | ||
| 109 | /** The tick line's one value: `open 12, opened/landed 3/2 in 1 h, 9/11 in 6 h, conflicted 1, red 0, queued 0, oldest approved 30 h, stranded $1.20`. */ | |
| 110 | export function vitalsWords(v: ShownVitals): string { | |
| 111 | const x = extras(v); | |
| 112 | return `open ${v.open}, opened/landed ${v.opened1h}/${v.landed1h} in 1 h, ${v.opened6h}/${v.landed6h} in 6 h, conflicted ${v.conflicted}, ${[x.red, x.oldest, x.stranded].filter((w) => w !== null).join(", ")}`; | |
| 71 | 113 | } |
The same filter/join is repeated in vitalsLine and vitalsWords. Have extras return the list so the two lines cannot drift. |
||
| 72 | 114 | |
| 73 | 115 | /** Home's line for an admin; the counts are the whole of it. */ |
| 74 | export function vitalsLine(v: Vitals): string { | |
| 75 | return `${v.open} open pulls · ${v.opened1h} opened, ${v.landed1h} landed in 1 h · ${v.opened6h} opened, ${v.landed6h} landed in 6 h · ${v.conflicted} conflicted`; | |
| 116 | export function vitalsLine(v: ShownVitals): string { | |
| 117 | const x = extras(v); | |
| 118 | return `${v.open} open pulls · ${v.opened1h} opened, ${v.landed1h} landed in 1 h · ${v.opened6h} opened, ${v.landed6h} landed in 6 h · ${v.conflicted} conflicted · ${[x.red, x.oldest, x.stranded].filter((w) => w !== null).join(" · ")}`; | |
| 76 | 119 | } |
src/system/telemetry.ts +3 −2
| @@ -9,7 +9,7 @@ import type { App } from "../app.ts"; | ||
| 9 | 9 | import { SOURCES } from "../boards/sources.ts"; |
| 10 | 10 | import { conflictedPulls, conflictedSays, rebaseLane, rebaseLaneSays } from "../flow/conflicted.ts"; |
| 11 | 11 | import { firedAlarms } from "../flow/alarms.ts"; |
| 12 | import { readVitals, vitalsLine, vitalsMisses } from "../flow/vitals.ts"; | |
| 12 | import { readVitals, vitalsFor, vitalsLine, vitalsMisses } from "../flow/vitals.ts"; | |
| 13 | 13 | import { parkedSays, readParked } from "../flow/parked.ts"; |
| 14 | 14 | import { readPipeline, verdict } from "../flow/pipeline.ts"; |
| 15 | 15 | import { type QueueReading, readQueues } from "../flow/queue.ts"; |
| @@ -33,7 +33,8 @@ export function flowFor(app: App, reader: Reader, now = new Date()) { | ||
| 33 | 33 | const lane = rebaseLane(app.db, conflicted); |
| 34 | 34 | const requested = requestedWaits(app.db, reader.kind === "agent"); |
| 35 | 35 | const busy = reviewersBusy(app.db, reader.kind === "agent"); |
| 36 | const vitals = readVitals(app.db, now, reader.kind === "agent"); | |
| 36 | // Dollars only for the owner and an agent holding telemetry:read (#6424). | |
| 37 | const vitals = vitalsFor(readVitals(app.db, now, reader.kind === "agent"), reader.kind === "owner" || reader.spend === true); | |
This is the gate for dollars on the MCP and page door. Add a test that a bare agent reader gets no |
||
| 37 | 38 | const parked = readParked(app.db, now, reader.kind === "agent"); |
| 38 | 39 | return { |
| 39 | 40 | says: verdict(stages, now.getTime()), |
src/tui/screens/flow.ts +2 −2
| @@ -21,9 +21,9 @@ import { pileSays, readPile } from "../../flow/pile.ts"; | ||
| 21 | 21 | import { readPipeline, STAGE_LABEL, verdict } from "../../flow/pipeline.ts"; |
| 22 | 22 | import { parkedSays, readParked } from "../../flow/parked.ts"; |
| 23 | 23 | import { firedAlarms } from "../../flow/alarms.ts"; |
| 24 | import { readVitals, vitalsFor, vitalsLine } from "../../flow/vitals.ts"; | |
| 24 | 25 | import { readLeadTime, type LeadTime } from "../../flow/lead_time.ts"; |
| 25 | 26 | import { readOpeners, type Opener } from "../../flow/openers.ts"; |
| 26 | import { readVitals, vitalsLine } from "../../flow/vitals.ts"; | |
| 27 | 27 | import { readQueues, redBatchWords } from "../../flow/queue.ts"; |
| 28 | 28 | import { reviewersBusy, reviewingSays } from "../../flow/reviewing.ts"; |
| 29 | 29 | import { requestedSays, requestedWaits } from "../../flow/requested.ts"; |
| @@ -67,7 +67,7 @@ export function flowLines( | ||
| 67 | 67 | return [ |
| 68 | 68 | ...wrap(verdict(stages, now.getTime()), inner), |
| 69 | 69 | "", |
| 70 | ...wrap(vitalsLine(readVitals(db, now, agent !== undefined)), inner), | |
| 70 | ...wrap(vitalsLine(vitalsFor(readVitals(db, now, agent !== undefined), agent === undefined)), inner), | |
| 71 | 71 | ...firedAlarms(db, now, { publicOnly: agent !== undefined, ceilingUsd }).flatMap((a) => wrap(a.says, inner)), |
| 72 | 72 | "", |
| 73 | 73 | ...wrap(parkedSays(readParked(db, now, agent !== undefined)), inner), |
test/flow-clog-prevent.test.ts +1 −1
| @@ -52,7 +52,7 @@ const open = (title: string): Issue => { | ||
| 52 | 52 | }; |
| 53 | 53 | |
| 54 | 54 | test("a seeded clog lowers the slots and holds a fresh build on the hot file", () => { |
| 55 | const clog: Vitals = { open: 8, opened1h: 51, landed1h: 15, opened6h: 120, landed6h: 40, conflicted: 0 }; | |
| 55 | const clog: Vitals = { open: 8, opened1h: 51, landed1h: 15, opened6h: 120, landed6h: 40, conflicted: 0, red: 0, queued: 0, oldestApprovedHours: null, strandedUsd: 0 }; | |
| 56 | 56 | const ceiling = 7; |
| 57 | 57 | const governed = governedSlots(clog, ceiling); |
| 58 | 58 | expect(governed.slots).toBeLessThan(ceiling); |
test/flow-vitals.test.ts +75 −5
| @@ -3,6 +3,8 @@ import { describe, expect, test } from "bun:test"; | ||
| 3 | 3 | import { mkdtempSync, rmSync } from "node:fs"; |
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | import { join } from "node:path"; |
| 6 | import { createAgent } from "../src/actions/crew.ts"; | |
| 7 | import { setTelemetryGrant, telemetryReader } from "../src/actions/telemetry.ts"; | |
| 6 | 8 | import { createApp } from "../src/app.ts"; |
| 7 | 9 | import { createRepo } from "../src/core/repos.ts"; |
| 8 | 10 | import { createUser } from "../src/core/users.ts"; |
| @@ -11,7 +13,7 @@ import { id } from "../src/kernel/id.ts"; | ||
| 11 | 13 | import { flowVitals } from "../src/crew/autonomy.ts"; |
| 12 | 14 | import { flowFor } from "../src/system/telemetry.ts"; |
| 13 | 15 | import { flowLines } from "../src/tui/screens/flow.ts"; |
| 14 | import { vitalsLine, readVitals, vitalsMisses, vitalsWords } from "../src/flow/vitals.ts"; | |
| 16 | import { vitalsFor, vitalsLine, readVitals, vitalsMisses, vitalsWords, type Vitals } from "../src/flow/vitals.ts"; | |
| 15 | 17 | |
| 16 | 18 | const NOW = new Date(); |
| 17 | 19 | const ago = (h: number) => new Date(NOW.getTime() - h * 3_600_000).toISOString(); |
| @@ -50,11 +52,11 @@ describe("flow vitals", () => { | ||
| 50 | 52 | .run(id(), repo.value.id, clash, ago(1)); |
| 51 | 53 | |
| 52 | 54 | const v = readVitals(app.db, NOW); |
| 53 | expect(v).toEqual({ open: 3, opened1h: 1, landed1h: 1, opened6h: 3, landed6h: 2, conflicted: 1 }); | |
| 55 | expect(v).toEqual({ open: 3, opened1h: 1, landed1h: 1, opened6h: 3, landed6h: 2, conflicted: 1, red: 0, queued: 0, oldestApprovedHours: null, strandedUsd: 0 }); | |
| 54 | 56 | expect(vitalsMisses(v)).toEqual(["landed 2 < opened 3 over 6 h"]); |
| 55 | 57 | |
| 56 | 58 | const tick = flowVitals(app.db, NOW); |
| 57 | expect(tick.pulls).toBe("open 3, opened/landed 1/1 in 1 h, 3/2 in 6 h, conflicted 1"); | |
| 59 | expect(tick.pulls).toBe("open 3, opened/landed 1/1 in 1 h, 3/2 in 6 h, conflicted 1, 0 red, 0 queued, $0.00 stranded"); | |
| 58 | 60 | expect(tick.pulls).toBe(vitalsWords(v)); |
| 59 | 61 | expect(tick.offTarget).toBe("landed 2 < opened 3 over 6 h"); |
| 60 | 62 | |
| @@ -70,7 +72,7 @@ describe("flow vitals", () => { | ||
| 70 | 72 | }); |
| 71 | 73 | const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!; |
| 72 | 74 | const home = await (await fetch(`${servers.url}/`, { headers: { cookie } })).text(); |
| 73 | expect(home).toContain(">3 open pulls</a> · 1 opened, 1 landed in 1 h · 3 opened, 2 landed in 6 h · 1 conflicted"); | |
| 75 | expect(home).toContain(">3 open pulls</a> · 1 opened, 1 landed in 1 h · 3 opened, 2 landed in 6 h · 1 conflicted · 0 red, 0 queued · $0.00 stranded"); | |
| 74 | 76 | } finally { |
| 75 | 77 | await servers.stop(); |
| 76 | 78 | app.close(); |
| @@ -119,11 +121,79 @@ describe("flow vitals", () => { | ||
| 119 | 121 | const app = createApp({ dataDir: root, port: 0 }); |
| 120 | 122 | try { |
| 121 | 123 | const v = readVitals(app.db, NOW); |
| 122 | expect(Object.values(v)).toEqual([0, 0, 0, 0, 0, 0]); | |
| 124 | expect(Object.values(v)).toEqual([0, 0, 0, 0, 0, 0, 0, 0, null, 0]); | |
| 123 | 125 | expect(vitalsMisses(v)).toEqual([]); |
| 124 | 126 | } finally { |
| 125 | 127 | app.close(); |
| 126 | 128 | rmSync(root, { recursive: true, force: true }); |
| 127 | 129 | } |
| 128 | 130 | }); |
| 131 | ||
| 132 | test("an approved pull waiting over 24 h is a miss; an agent without telemetry:read gets no dollars", () => { | |
| 133 | const base: Vitals = { open: 1, opened1h: 0, landed1h: 0, opened6h: 0, landed6h: 0, conflicted: 0, red: 2, queued: 3, oldestApprovedHours: 30.4, strandedUsd: 12.5 }; | |
| 134 | expect(vitalsMisses(base)).toEqual(["oldest approved pull has waited 30 h"]); | |
| 135 | expect(vitalsMisses({ ...base, oldestApprovedHours: 24 })).toEqual([]); | |
| 136 | expect(vitalsMisses({ ...base, oldestApprovedHours: null })).toEqual([]); | |
| 137 | expect(vitalsWords(base)).toContain("2 red, 3 queued, oldest approved 30 h, $12.50 stranded"); | |
| 138 | const cut = vitalsFor(base, false); | |
| 139 | expect("strandedUsd" in cut).toBe(false); | |
| 140 | expect(vitalsLine(cut)).not.toContain("stranded"); | |
| 141 | expect(vitalsLine(cut)).toContain("2 red, 3 queued · oldest approved 30 h"); | |
| 142 | expect(vitalsFor(base, true).strandedUsd).toBe(12.5); | |
| 143 | }); | |
| 144 | ||
| 145 | test("the dollars and the old approval pass through every door only to a reader who may read them", async () => { | |
| 146 | const root = mkdtempSync(join(tmpdir(), "forge-vitals-")); | |
| 147 | const app = createApp({ dataDir: root, port: 0 }); | |
| 148 | try { | |
| 149 | const made = await createUser(app.db, { username: "alice", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true, adminIfFirst: true }); | |
| 150 | if (!made.ok) throw new Error(made.error); | |
| 151 | const repo = await createRepo(app.db, app.reposRoot, { ownerId: made.value.id, name: "g", visibility: "public", description: "g" }); | |
| 152 | if (!repo.ok) throw new Error(repo.error); | |
| 153 | const issueId = id(); | |
| 154 | app.db | |
| 155 | .query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, 1, 'pull', 'P', '', ?3, 'open', ?4)`) | |
| 156 | .run(issueId, repo.value.id, made.value.id, ago(50)); | |
| 157 | app.db | |
| 158 | .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at) VALUES (?1, ?2, 'b/1', '', '', ?3, NULL)`) | |
| 159 | .run(issueId, repo.value.defaultBranch, repo.value.id); | |
| 160 | app.db | |
| 161 | .query(`INSERT INTO run (id, repo_id, kind, issue_id, state, cost_usd, created_at) VALUES (?1, ?2, 'agent', ?3, 'ok', 3.5, ?4)`) | |
| 162 | .run(id(), repo.value.id, issueId, ago(40)); | |
| 163 | app.db | |
| 164 | .query(`INSERT INTO review (id, issue_id, author_id, state, commit_oid, created_at) VALUES (?1, ?2, ?3, 'approved', 'h', ?4)`) | |
| 165 | .run(id(), issueId, made.value.id, ago(30)); | |
| 166 | ||
| 167 | // 1. flowFor: the dollars only for the owner and an agent that holds the grant. | |
| 168 | const read = (reader: object) => flowFor(app, reader as Parameters<typeof flowFor>[1], NOW).vitals; | |
| 169 | expect("strandedUsd" in read({ kind: "agent" })).toBe(false); | |
| 170 | expect(read({ kind: "agent" }).says).not.toContain("stranded"); | |
| 171 | expect(read({ kind: "agent", spend: true }).strandedUsd).toBe(3.5); | |
| 172 | expect(read({ kind: "owner" }).strandedUsd).toBe(3.5); | |
| 173 | expect(read({ kind: "owner" }).says).toContain("$3.50 stranded"); | |
| 174 | expect(read({ kind: "owner" }).misses).toContain("oldest approved pull has waited 30 h"); | |
| 175 | ||
| 176 | // 2. telemetryReader marks a granted agent with spend, and refuses one without the grant. | |
| 177 | const bot = await createAgent(app, made.value, { username: "muse" }); | |
| 178 | if (!bot.ok) throw new Error(String(bot.error)); | |
| 179 | expect(telemetryReader(app, bot.value.user)).toBeUndefined(); | |
| 180 | expect(setTelemetryGrant(app, made.value, "muse", true).ok).toBe(true); | |
| 181 | expect(telemetryReader(app, bot.value.user)).toEqual({ kind: "agent", spend: true }); | |
| 182 | expect(telemetryReader(app, made.value)).toEqual({ kind: "owner" }); | |
| 183 | ||
| 184 | // 3. The terminal: the owner's Flow dialog says it, an agent's does not. | |
| 185 | const text = (lines: string[]) => lines.join(" ").replace(/\s+/g, " "); | |
| 186 | expect(text(flowLines(app.db, NOW))).toContain("$3.50 stranded"); | |
| 187 | expect(text(flowLines(app.db, NOW, [], { queues: [] }))).not.toContain("stranded"); | |
| 188 | expect(text(flowLines(app.db, NOW, [], { queues: [] }))).toContain("oldest approved 30 h"); | |
| 189 | ||
| 190 | // The tick line carries the same words, and the old approval is off target there. | |
| 191 | const tick = flowVitals(app.db, NOW); | |
| 192 | expect(tick.pulls).toContain("oldest approved 30 h"); | |
| 193 | expect(tick.offTarget).toContain("oldest approved pull has waited 30 h"); | |
| 194 | } finally { | |
| 195 | app.close(); | |
| 196 | rmSync(root, { recursive: true, force: true }); | |
| 197 | } | |
| 198 | }, 30_000); | |
| 129 | 199 | }); |
new above
offline
An optional spend fails closed, which is safe, but required for agents would make a missing grant visible at compile time.