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

A test proves spend reported from outside is a claim kept apart: recorded as said, shown apart, never added into the crew's returns or read by a limit #7611

Merged opened this

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

Merged

torque/forge:torque/7593-a-test-proves-spend-reported-from-outsid → main 1 commit fork

  • Merged. torque/7593-a-test-proves-spend-reported-from-outsid was merged into main as ee8bba9.

Review

Sign in to review this change.
1 file changed +226 −0
test/outside-spend.test.ts added +226
@@ -0,0 +1,226 @@
1 /**
2 * Spend reported from outside is a claim kept apart (#7593): `reportSpend`
3 * records it as said, `readReturns` shows it in `outside` only, and no
4 * limit the crew is paced by — the repository's daily ceiling, the forge's,
5 * the account's, the subscription's budget and standing, the governor —
6 * reads it. Every read is the real one, snapshotted before and after a
7 * report big enough to trip each limit were it ever added in.
8 */
9
10 import { afterAll, beforeAll, expect, test } from "bun:test";
11 import { mkdtemp, rm } from "node:fs/promises";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { createApp, type App } from "../src/app.ts";
15 import { addToCrew, createAgent } from "../src/actions/crew.ts";
16 import { openIssue } from "../src/actions/issues.ts";
17 import { createRepo, updateRepoSettings } from "../src/actions/repos.ts";
18 import { readReturns, reportSpend } from "../src/actions/returns.ts";
19 import { daySpent, dailyLeft, setAccountCeiling } from "../src/core/account_ceiling.ts";
20 import type { Issue } from "../src/core/issues.ts";
21 import { getRepoById, type Repo } from "../src/core/repos.ts";
22 import { createRun } from "../src/core/runs.ts";
23 import { standings } from "../src/core/standing.ts";
24 import { addSubscription, budgetsOf, setBudget } from "../src/core/subscriptions.ts";
25 import { recordUsage, usageTotals } from "../src/core/usage.ts";
26 import { createUser, type User } from "../src/core/users.ts";
27 import { autonomyStatus, spentToday, spentTodayEverywhere } from "../src/crew/autonomy.ts";
28 import { governorFor } from "../src/crew/governed.ts";
29 import { quietLogs } from "./helpers/globals.ts";
30
31 quietLogs();
32
33 let dataDir = "";
34 let app: App;
35 let alice: User;
36 let bob: User;
37 let repo: Repo;
38 let secret: Repo;
39 let issue: Issue;
40 let subscriptionId = "";
41 /** The crew's own spend: one $2 run, today. */
42 const RUN_USD = 2;
43 /** A claim far past every limit below; were it added in, each would move. */
44 const CLAIM_USD = 9_999.5;
45
46 async function person(username: string, admin = false): Promise<User> {
47 const made = await createUser(app.db, {
48 origin: "seed",
49 trusted: true,
50 username,
51 email: `${username}@forge.local`,
52 password: "a-good-passphrase",
53 adminIfFirst: admin,
54 });
55 if (!made.ok) throw new Error(made.error);
56 return made.value;
57 }
58
59 /** Every spend read a limit or a page takes, at one instant. */
60 function reads(now: Date) {
61 const since = new Date(now.getTime() - 86_400_000).toISOString();
62 const returns = readReturns(app, alice, { repo: "alice/mill", days: 1, now });
63 if (!returns.ok) throw new Error(returns.error);
64 const { outside: _outside, ...crew } = returns.value;
65 return {
66 returns: crew,
67 spentToday: spentToday(app.db, repo.id, now),
68 spentEverywhere: spentTodayEverywhere(app.db, now),
69 status: autonomyStatus(app, getRepoById(app.db, repo.id)!, now),
70 daySpent: daySpent(app.db, alice.id, now),
71 dailyLeft: dailyLeft(app.db, alice.id, now),
72 usage: usageTotals(app.db, "repo", { since, repoId: repo.id }),
73 budget: budgetsOf(app.db).get(subscriptionId),
74 standing: standings(app.db, [subscriptionId], now).get(subscriptionId),
75 governor: governorFor(app, getRepoById(app.db, repo.id)!, now),
76 };
77 }
78
79 beforeAll(async () => {
80 dataDir = await mkdtemp(join(tmpdir(), "forge-outside-spend-"));
81 app = createApp({ dataDir, port: 0 });
82 alice = await person("alice", true);
83 bob = await person("bob");
84 const made = await createRepo(app, alice, { name: "mill", visibility: "public" });
85 const hidden = await createRepo(app, alice, { name: "vault", visibility: "private" });
86 if (!made.ok || !hidden.ok) throw new Error("repos");
87 repo = made.value;
88 secret = hidden.value;
89 const agent = await createAgent(app, alice, { username: "bot" });
90 if (!agent.ok) throw new Error(String(agent.error));
91 expect(addToCrew(app, alice, repo, "bot").ok).toBe(true);
92 const on = await updateRepoSettings(app, alice, repo, {
93 autonomy: "autonomous",
94 autonomyDirection: "Grind the grain",
95 autonomyCeilingUsd: 5,
96 autonomySlots: 3,
97 autonomyBy: alice.id,
98 });
99 expect(on.ok).toBe(true);
100 const opened = openIssue(app, alice, repo, { title: "Mill the flour" });
101 if (!opened.ok) throw new Error(String(opened.error));
102 issue = opened.value;
103 const sub = addSubscription(app.db, dataDir, { name: "work-plan", token: "sk-ant-api-test-token" });
104 if (!sub.ok) throw new Error(sub.error);
105 subscriptionId = sub.value.id;
106 expect(setBudget(app.db, subscriptionId, 10)).toBe(true);
107 expect(setAccountCeiling(app.db, alice.id, 5, 5, alice.id, new Date())).toBe(true);
108
109 const run = createRun(app.db, {
110 repoId: repo.id,
111 kind: "agent",
112 issueId: issue.id,
113 branch: "",
114 actorId: agent.value.user.id,
115 reason: "assigned",
116 });
117 const at = new Date().toISOString();
118 app.db
119 .query(`UPDATE run SET state = 'ok', cost_usd = ?2, summary = 'ok', started_at = ?3, finished_at = ?3 WHERE id = ?1`)
120 .run(run.id, RUN_USD, at);
121 recordUsage(app.db, {
122 runId: run.id,
123 repoId: repo.id,
124 issueId: issue.id,
125 agentId: agent.value.user.id,
126 subscriptionId,
127 tokensIn: 5_000,
128 tokensOut: 1_000,
129 costUsd: RUN_USD,
130 wallMs: 60_000,
131 outcome: "ok",
132 at,
133 });
134 });
135
136 afterAll(async () => {
137 app.close?.();
138 await rm(dataDir, { recursive: true, force: true });
139 });
140
141 test("who may report: no one signed in is unauthorized; a reader and a stranger are not_found", () => {
142 const claim = { who: "the build team", usd: 1 };
143 const none = reportSpend(app, undefined, repo, claim);
144 expect(none).toEqual({ ok: false, error: "unauthorized" });
145 // bob reads the public mill, and is a stranger to the private vault.
146 expect(reportSpend(app, bob, repo, claim)).toEqual({ ok: false, error: "not_found" });
147 expect(reportSpend(app, bob, secret, claim)).toEqual({ ok: false, error: "not_found" });
148 const kept = app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM outside_spend").get()!.n;
149 expect(kept).toBe(0);
150 });
151
152 test("a bad amount, a missing who or an unknown issue is invalid", () => {
153 for (const input of [
154 { who: "the build team", usd: -1 },
155 { who: "the build team", usd: 10_000.01 },
156 { who: "the build team", usd: Number.NaN },
157 { who: "the build team", usd: "3" },
158 { who: "the build team" },
159 { who: "the build team", usd: 1, tokensIn: 1.5 },
160 { who: "", usd: 1 },
161 { who: "the build team", usd: 1, issue: 999 },
162 ]) {
163 expect(reportSpend(app, alice, repo, input)).toEqual({ ok: false, error: "invalid" });
164 }
165 const kept = app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM outside_spend").get()!.n;
166 expect(kept).toBe(0);
167 });
168
169 test("a report is kept as said, shown apart, and moves no total and no limit", () => {
170 const now = new Date(Date.now() + 1_000);
171 const before = reads(now);
172 // The fixture sits under every limit, so a claim added in would trip each.
173 expect(before.spentToday).toBe(RUN_USD);
174 expect(before.status.paused).toBeNull();
175 expect(before.dailyLeft).toBe(3);
176 expect(before.returns.spent.usd).toBe(RUN_USD);
177
178 const made = reportSpend(app, alice, repo, {
179 who: "the build team",
180 model: "claude-opus-5-5",
181 tokensIn: 120_000,
182 tokensOut: 30_000,
183 usd: CLAIM_USD,
184 issue: issue.number,
185 note: "a long session at home",
186 });
187 if (!made.ok) throw new Error(made.error);
188 expect(made.value).toMatchObject({
189 repoId: repo.id,
190 issueId: issue.id,
191 reporterId: alice.id,
192 who: "the build team",
193 usd: CLAIM_USD,
194 });
195
196 // Read back from the table exactly as said: amount, issue, who said it.
197 const row = app.db
198 .query<Record<string, unknown>, [string]>(
199 `SELECT repo_id, issue_id, reporter_id, who, model, tokens_in, tokens_out, usd, note
200 FROM outside_spend WHERE id = ?1`,
201 )
202 .get(made.value.id);
203 expect(row).toEqual({
204 repo_id: repo.id,
205 issue_id: issue.id,
206 reporter_id: alice.id,
207 who: "the build team",
208 model: "claude-opus-5-5",
209 tokens_in: 120_000,
210 tokens_out: 30_000,
211 usd: CLAIM_USD,
212 note: "a long session at home",
213 });
214
215 const after = reads(now);
216 expect(after).toEqual(before);
217 expect(after.status.paused).toBeNull();
218
219 // The claim appears only in its own field.
220 const returns = readReturns(app, alice, { repo: "alice/mill", days: 1, now });
221 if (!returns.ok) throw new Error(returns.error);
222 expect(returns.value.outside).toEqual([
223 { who: "the build team", reports: 1, tokensIn: 120_000, tokensOut: 30_000, usd: CLAIM_USD },
224 ]);
225 expect(returns.value.spent).toEqual({ runs: 1, usd: RUN_USD });
226 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs