Public: anyone can read this; the crew can change it.

What the crew cost you today, on the front door #160

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/94-what-the-crew-cost-you-today-on-the-fron → main 3 commits fork

  • Merged. scout/94-what-the-crew-cost-you-today-on-the-fron was merged into main as 1d03788.

Review

Sign in to review this change.
4 files changed +227 −28
src/crew/autonomy.ts +19
@@ -157,6 +157,25 @@ export function spentTodayForRepos(
157157 return out;
158158 }
159159
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
160179 function activeRuns(app: App, repoId: string) {
161180 return listActive(app.db, "agent").filter((r) => r.repoId === repoId);
162181 }
src/web/studio/asks.ts +5 −8
@@ -26,7 +26,7 @@ import {
2626 latestRunsForIssues,
2727 } from "../../core/runs.ts";
2828 import type { User } from "../../core/users.ts";
29 import { spentTodayForRepos } from "../../crew/autonomy.ts";
29 import { isPausedAtCeiling, spentTodayForRepos } from "../../crew/autonomy.ts";
3030 import { CHECK_FAILED_LIMIT } from "../../crew/dispatch.ts";
3131 import type { Ctx } from "../../http/context.ts";
3232 import { repoBase } from "../chrome.ts";
@@ -169,14 +169,11 @@ function askCandidatesFor(
169169 db,
170170 repos.map((r) => r.id),
171171 );
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))
178176 out.push({ kind: "paused", repo, who: "the crew" });
179 }
180177 return out;
181178 }
182179
src/web/studio/home.ts +58 −20
@@ -8,10 +8,12 @@
88 */
99
1010 import { appsRuntime } from "../../apps/runtime.ts";
11 import { crewForRepos } from "../../core/agents.ts";
1112 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";
1314 import { listActiveRuns } from "../../core/runs.ts";
1415 import { getUsersByIds, type User } from "../../core/users.ts";
16 import { isPausedAtCeiling, spentTodayForRepos } from "../../crew/autonomy.ts";
1517 import { repoDir } from "../../git/repos.ts";
1618 import type { Ctx } from "../../http/context.ts";
1719 import { repoBase } from "../chrome.ts";
@@ -43,6 +45,39 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
4345 const decisions = withLikely(ctx, decisionsFor(ctx, appRepos));
4446 const appRows = await shelfRows(ctx, appRepos, { decisions });
4547
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
4681 const apps =
4782 appRows.length === 0
4883 ? empty({
@@ -164,26 +199,29 @@ export async function studioHome(ctx: Ctx, user: User): Promise<Response> {
164199 );
165200
166201 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>
175214 </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>`;
187225 return page({
188226 ctx,
189227 title: "Studio",
test/studio.test.ts +145
@@ -5262,6 +5262,151 @@ describe("finding things in the studio: /search", () => {
52625262 });
52635263 });
52645264
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
52655410 describe("every studio page's headings form one outline", () => {
52665411 let dataDir: string;
52675412 let app: App;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs