Public: anyone can read this; the crew can change it.
What the crew cost you today, on the front door #160
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/94-what-the-crew-cost-you-today-on-the-fron → main 3 commits fork
4 files
changed +227
−28
src/crew/autonomy.ts +19
| @@ -157,6 +157,25 @@ export function spentTodayForRepos( | ||
| 157 | 157 | return out; |
| 158 | 158 | } |
| 159 | 159 | |
| 160 | /** | |
| 161 | * Whether a repository is paused at today's ceiling right now — the same | |
| 162 | * three conditions `autonomyStatus`'s own `"ceiling"` reads for one, but | |
| 163 | * asked of crew and spend already read for the whole set | |
| 164 | * (`crewForRepos`, `spentTodayForRepos`), never a query of its own. The | |
| 165 | * ask card (`studio/asks.ts`) and the studio home's cost line | |
| 166 | * (`studio/home.ts`) both decide from this, so neither can disagree with | |
| 167 | * the other about which app is paused. | |
| 168 | */ | |
| 169 | export function isPausedAtCeiling( | |
| 170 | repo: Repo, | |
| 171 | crew: ReadonlyMap<string, readonly string[]>, | |
| 172 | spent: ReadonlyMap<string, number>, | |
| 173 | ): boolean { | |
| 174 | if (repo.autonomy === "off" || repo.autonomyBy === null) return false; | |
| 175 | if ((crew.get(repo.id)?.length ?? 0) === 0) return false; | |
| 176 | return (spent.get(repo.id) ?? 0) >= repo.autonomyCeilingUsd; | |
| 177 | } | |
| 178 | ||
| 160 | 179 | function activeRuns(app: App, repoId: string) { |
| 161 | 180 | return listActive(app.db, "agent").filter((r) => r.repoId === repoId); |
| 162 | 181 | } |
src/web/studio/asks.ts +5 −8
| @@ -26,7 +26,7 @@ import { | ||
| 26 | 26 | latestRunsForIssues, |
| 27 | 27 | } from "../../core/runs.ts"; |
| 28 | 28 | import type { User } from "../../core/users.ts"; |
| 29 | import { spentTodayForRepos } from "../../crew/autonomy.ts"; | |
| 29 | import { isPausedAtCeiling, spentTodayForRepos } from "../../crew/autonomy.ts"; | |
| 30 | 30 | import { CHECK_FAILED_LIMIT } from "../../crew/dispatch.ts"; |
| 31 | 31 | import type { Ctx } from "../../http/context.ts"; |
| 32 | 32 | import { repoBase } from "../chrome.ts"; |
| @@ -169,14 +169,11 @@ function askCandidatesFor( | ||
| 169 | 169 | db, |
| 170 | 170 | repos.map((r) => r.id), |
| 171 | 171 | ); |
| 172 | for (const repo of repos) { | |
| 173 | // Only a ceiling pause is an ask — "nobody" and "no crew" are settings | |
| 174 | // problems, not a decision, so neither needs its own grouped read. | |
| 175 | if (repo.autonomy === "off" || repo.autonomyBy === null) continue; | |
| 176 | if ((crew.get(repo.id)?.length ?? 0) === 0) continue; | |
| 177 | if ((spent.get(repo.id) ?? 0) >= repo.autonomyCeilingUsd) | |
| 172 | // Only a ceiling pause is an ask — "nobody" and "no crew" are settings | |
| 173 | // problems, not a decision, so neither needs its own card. | |
| 174 | for (const repo of repos) | |
| 175 | if (isPausedAtCeiling(repo, crew, spent)) | |
| 178 | 176 | out.push({ kind: "paused", repo, who: "the crew" }); |
| 179 | } | |
| 180 | 177 | return out; |
| 181 | 178 | } |
| 182 | 179 |
src/web/studio/home.ts +58 −20
| @@ -8,10 +8,12 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { appsRuntime } from "../../apps/runtime.ts"; |
| 11 | import { crewForRepos } from "../../core/agents.ts"; | |
| 11 | 12 | import { openPullsForRepos, type Pull } from "../../core/pulls.ts"; |
| 12 | import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; | |
| 13 | import { canSeeOwnerFacts, writableRepoIds, type Repo } from "../../core/repos.ts"; | |
| 13 | 14 | import { listActiveRuns } from "../../core/runs.ts"; |
| 14 | 15 | import { getUsersByIds, type User } from "../../core/users.ts"; |
| 16 | import { isPausedAtCeiling, spentTodayForRepos } from "../../crew/autonomy.ts"; | |
| 15 | 17 | import { repoDir } from "../../git/repos.ts"; |
| 16 | 18 | import type { Ctx } from "../../http/context.ts"; |
| 17 | 19 | import { repoBase } from "../chrome.ts"; |
| @@ -43,6 +45,39 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> { | ||
| 43 | 45 | const decisions = withLikely(ctx, decisionsFor(ctx, appRepos)); |
| 44 | 46 | const appRows = await shelfRows(ctx, appRepos, { decisions }); |
| 45 | 47 | |
| 48 | // What the crew cost today, across the apps the viewer can write, and how | |
| 49 | // many of those are paused at their ceiling right now — the owner's first | |
| 50 | // question at the front door, answered in one bounded read each | |
| 51 | // (`writableRepoIds`, `spentTodayForRepos`, `crewForRepos`), never a | |
| 52 | // query or an `autonomyStatus` call per app. A reader who cannot write | |
| 53 | // any of these apps sees no money at all, the same floor an app's own | |
| 54 | // page holds (`canSeeOwnerFacts`). | |
| 55 | const writable = writableRepoIds( | |
| 56 | db, | |
| 57 | user, | |
| 58 | appRepos.map((r) => r.id), | |
| 59 | ); | |
| 60 | const costRepos = appRepos.filter((r) => writable.has(r.id)); | |
| 61 | const costLine = when(costRepos.length > 0, () => { | |
| 62 | const spentToday = spentTodayForRepos( | |
| 63 | db, | |
| 64 | costRepos.map((r) => r.id), | |
| 65 | ); | |
| 66 | const crew = crewForRepos( | |
| 67 | db, | |
| 68 | costRepos.map((r) => r.id), | |
| 69 | ); | |
| 70 | const spentUsd = costRepos.reduce( | |
| 71 | (sum, r) => sum + (spentToday.get(r.id) ?? 0), | |
| 72 | 0, | |
| 73 | ); | |
| 74 | const paused = costRepos.filter((r) => | |
| 75 | isPausedAtCeiling(r, crew, spentToday), | |
| 76 | ).length; | |
| 77 | const noun = `app${costRepos.length === 1 ? "" : "s"}`; | |
| 78 | return html`<p class="small muted studio-cost">$${spentUsd.toFixed(2)} spent today across ${costRepos.length} ${noun} you can write · ${paused} paused at <a href="/map">their ceiling</a></p>`; | |
| 79 | }); | |
| 80 | ||
| 46 | 81 | const apps = |
| 47 | 82 | appRows.length === 0 |
| 48 | 83 | ? empty({ |
| @@ -164,26 +199,29 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> { | ||
| 164 | 199 | ); |
| 165 | 200 | |
| 166 | 201 | const body = html`<h1 class="sr-only">Studio</h1> |
| 167 | <div class="dash"> | |
| 168 | <div class="dash-main stack"> | |
| 169 | <div id="live-asks" data-live="run.*,pull.*,issue.*,check.*">${asks}</div> | |
| 170 | <div | |
| 171 | id="live-apps" | |
| 172 | data-live="app.serving,app.stopped,run.queued,run.started,run.finished,pull.opened,pull.closed,pull.merged" | |
| 173 | > | |
| 174 | ${apps} | |
| 202 | ${costLine} | |
| 203 | <div class="dash"> | |
| 204 | <div class="dash-main stack"> | |
| 205 | <div id="live-asks" data-live="run.*,pull.*,issue.*,check.*"> | |
| 206 | ${asks} | |
| 207 | </div> | |
| 208 | <div | |
| 209 | id="live-apps" | |
| 210 | data-live="app.serving,app.stopped,run.queued,run.started,run.finished,pull.opened,pull.closed,pull.merged" | |
| 211 | > | |
| 212 | ${apps} | |
| 213 | </div> | |
| 175 | 214 | </div> |
| 176 | </div> | |
| 177 | <aside class="dash-rail stack"> | |
| 178 | <div id="live-now" data-live="run.*">${now}</div> | |
| 179 | <div | |
| 180 | id="live-on-you" | |
| 181 | data-live="issue.assigned,pull.assigned,issue.closed,pull.closed,issue.reopened,pull.reopened,pull.merged" | |
| 182 | > | |
| 183 | ${onYouCard(ctx, user) ?? null} | |
| 184 | </div> | |
| 185 | </aside> | |
| 186 | </div>`; | |
| 215 | <aside class="dash-rail stack"> | |
| 216 | <div id="live-now" data-live="run.*">${now}</div> | |
| 217 | <div | |
| 218 | id="live-on-you" | |
| 219 | data-live="issue.assigned,pull.assigned,issue.closed,pull.closed,issue.reopened,pull.reopened,pull.merged" | |
| 220 | > | |
| 221 | ${onYouCard(ctx, user) ?? null} | |
| 222 | </div> | |
| 223 | </aside> | |
| 224 | </div>`; | |
| 187 | 225 | return page({ |
| 188 | 226 | ctx, |
| 189 | 227 | title: "Studio", |
test/studio.test.ts +145
| @@ -5262,6 +5262,151 @@ describe("finding things in the studio: /search", () => { | ||
| 5262 | 5262 | }); |
| 5263 | 5263 | }); |
| 5264 | 5264 | |
| 5265 | /** | |
| 5266 | * The studio home's own answer to "what is this costing me today?" (#94): | |
| 5267 | * one line, the sum of today's agent spend across the apps the viewer can | |
| 5268 | * write, and how many of those sit paused at their ceiling right now. | |
| 5269 | */ | |
| 5270 | describe("the studio home says what the crew cost you today", () => { | |
| 5271 | let dataDir: string; | |
| 5272 | let app: App; | |
| 5273 | let servers: Servers; | |
| 5274 | let base: string; | |
| 5275 | let alice: User; | |
| 5276 | let aliceCookie: string; | |
| 5277 | let carolCookie: string; | |
| 5278 | ||
| 5279 | const signIn = async (username: string): Promise<string> => | |
| 5280 | ( | |
| 5281 | ( | |
| 5282 | await fetch(`${base}/signin`, { | |
| 5283 | method: "POST", | |
| 5284 | body: new URLSearchParams({ | |
| 5285 | username, | |
| 5286 | password: "a-good-passphrase", | |
| 5287 | }), | |
| 5288 | redirect: "manual", | |
| 5289 | }) | |
| 5290 | ).headers.get("set-cookie") ?? "" | |
| 5291 | ).split(";")[0]!; | |
| 5292 | ||
| 5293 | const home = (cookie: string) => | |
| 5294 | fetch(`${base}/`, { | |
| 5295 | headers: { cookie: `${cookie}; forge_mode=studio` }, | |
| 5296 | redirect: "manual", | |
| 5297 | }).then((r) => r.text()); | |
| 5298 | ||
| 5299 | beforeAll(async () => { | |
| 5300 | dataDir = await mkdtemp(join(tmpdir(), "forge-studio-cost-today-")); | |
| 5301 | app = createApp({ dataDir, port: 0 }); | |
| 5302 | servers = await serve(app); | |
| 5303 | base = servers.url; | |
| 5304 | const a = await createUser(app.db, { | |
| 5305 | username: "alice", | |
| 5306 | email: "[email protected]", | |
| 5307 | password: "a-good-passphrase", | |
| 5308 | adminIfFirst: true, | |
| 5309 | }); | |
| 5310 | if (!a.ok) throw new Error(a.error); | |
| 5311 | alice = a.value; | |
| 5312 | const c = await createUser(app.db, { | |
| 5313 | username: "carol", | |
| 5314 | email: "[email protected]", | |
| 5315 | password: "a-good-passphrase", | |
| 5316 | }); | |
| 5317 | if (!c.ok) throw new Error(c.error); | |
| 5318 | aliceCookie = await signIn("alice"); | |
| 5319 | carolCookie = await signIn("carol"); | |
| 5320 | ||
| 5321 | // "beacon": on its own, over its $5 ceiling today — paused. | |
| 5322 | const beaconMade = await createRepo(app, alice, { | |
| 5323 | name: "beacon", | |
| 5324 | visibility: "public", | |
| 5325 | description: "a light that stays on", | |
| 5326 | }); | |
| 5327 | if (!beaconMade.ok) throw new Error(String(beaconMade.error)); | |
| 5328 | const beacon = beaconMade.value; | |
| 5329 | const beaconSeeded = await commitToBranch(app, alice, beacon, { | |
| 5330 | message: "Start", | |
| 5331 | files: [{ path: "README.md", content: "beacon\n" }], | |
| 5332 | }); | |
| 5333 | if (!beaconSeeded.ok) throw new Error(JSON.stringify(beaconSeeded.error)); | |
| 5334 | const beaconEnabled = await enableApp(app, alice, beacon, { | |
| 5335 | slug: "beacon", | |
| 5336 | }); | |
| 5337 | if (!beaconEnabled.ok) throw new Error(String(beaconEnabled.error)); | |
| 5338 | const keeper = await createAgent(app, alice, { username: "keeper" }); | |
| 5339 | if (!keeper.ok) throw new Error(String(keeper.error)); | |
| 5340 | expect(addToCrew(app, alice, beacon, "keeper").ok).toBe(true); | |
| 5341 | const on = await updateRepoSettings(app, alice, beacon, { | |
| 5342 | autonomy: "assisted", | |
| 5343 | autonomyDirection: "", | |
| 5344 | autonomyCeilingUsd: 5, | |
| 5345 | autonomySlots: 2, | |
| 5346 | autonomyBy: alice.id, | |
| 5347 | }); | |
| 5348 | expect(on.ok).toBe(true); | |
| 5349 | const keeperId = getUserByName(app.db, "keeper")!.id; | |
| 5350 | const beaconRun = createRun(app.db, { | |
| 5351 | repoId: beacon.id, | |
| 5352 | kind: "agent", | |
| 5353 | actorId: keeperId, | |
| 5354 | reason: "assigned", | |
| 5355 | }); | |
| 5356 | claimRun(app.db, beaconRun.id); | |
| 5357 | noteRunProgress(app.db, beaconRun.id, { costUsd: 6 }); | |
| 5358 | ||
| 5359 | // "loom": no autonomy at all, a cheaper run today — never paused. | |
| 5360 | const loomMade = await createRepo(app, alice, { | |
| 5361 | name: "loom", | |
| 5362 | visibility: "public", | |
| 5363 | description: "a place for cloth", | |
| 5364 | }); | |
| 5365 | if (!loomMade.ok) throw new Error(String(loomMade.error)); | |
| 5366 | const loom = loomMade.value; | |
| 5367 | const loomSeeded = await commitToBranch(app, alice, loom, { | |
| 5368 | message: "Start", | |
| 5369 | files: [{ path: "README.md", content: "loom\n" }], | |
| 5370 | }); | |
| 5371 | if (!loomSeeded.ok) throw new Error(JSON.stringify(loomSeeded.error)); | |
| 5372 | const loomEnabled = await enableApp(app, alice, loom, { slug: "loom" }); | |
| 5373 | if (!loomEnabled.ok) throw new Error(String(loomEnabled.error)); | |
| 5374 | const weaver = await createAgent(app, alice, { username: "weaver" }); | |
| 5375 | if (!weaver.ok) throw new Error(String(weaver.error)); | |
| 5376 | expect(addToCrew(app, alice, loom, "weaver").ok).toBe(true); | |
| 5377 | const weaverId = getUserByName(app.db, "weaver")!.id; | |
| 5378 | const loomRun = createRun(app.db, { | |
| 5379 | repoId: loom.id, | |
| 5380 | kind: "agent", | |
| 5381 | actorId: weaverId, | |
| 5382 | reason: "assigned", | |
| 5383 | }); | |
| 5384 | claimRun(app.db, loomRun.id); | |
| 5385 | noteRunProgress(app.db, loomRun.id, { costUsd: 2 }); | |
| 5386 | }); | |
| 5387 | ||
| 5388 | afterAll(async () => { | |
| 5389 | servers.stop(); | |
| 5390 | app.close(); | |
| 5391 | await rm(dataDir, { recursive: true, force: true }); | |
| 5392 | }); | |
| 5393 | ||
| 5394 | test("the owner sees the day's total across both apps, and the one that is paused at its ceiling", async () => { | |
| 5395 | const html = await home(aliceCookie); | |
| 5396 | expect(html).toContain("$8.00 spent today across 2 apps you can write"); | |
| 5397 | expect(html).toContain("1 paused at"); | |
| 5398 | expect(html).toMatch(/<a href="\/map">their ceiling<\/a>/); | |
| 5399 | }); | |
| 5400 | ||
| 5401 | test("a viewer who can only read sees no figure at all", async () => { | |
| 5402 | const html = await home(carolCookie); | |
| 5403 | expect(html).not.toContain("spent today"); | |
| 5404 | expect(html).not.toContain("$8.00"); | |
| 5405 | expect(html).not.toContain("$6"); | |
| 5406 | expect(html).not.toContain("$2"); | |
| 5407 | }); | |
| 5408 | }); | |
| 5409 | ||
| 5265 | 5410 | describe("every studio page's headings form one outline", () => { |
| 5266 | 5411 | let dataDir: string; |
| 5267 | 5412 | let app: App; |
new above
offline