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

Fleet operations with dry-run preview and a durable receipt (with undo where reversible) #465

Merged opened this in answer to

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

Merged

mason/forge:mason/447-fleet-operations-with-dry-run-preview-an → main 4 commits fork

  • Merged. mason/447-fleet-operations-with-dry-run-preview-an was merged into main as 0418ef3.

Review

Sign in to review this change.
15 files changed +1753 −5
CLAUDE.md +5
@@ -157,6 +157,11 @@ src/
157157 proxy (Host routing, signed grants for private apps), scaffold
158158 (the app a new app starts as), tty (`ssh <slug>@forge`: the
159159 app's own terminal on a pty)
160 fleet/ one action over many rows (docs/FLEET.md): selection (the
161 query line → row ids, only what the viewer reads), verbs (each
162 action as read/next/refuse/write over one row, writing through
163 actions/); actions/fleet.ts plans, applies, keeps the receipt
164 (core/fleet_ops.ts) and undoes
160165 runs/ log (one file per run, appended live, tailed by listeners)
161166 crew/ agents as child processes: brief (the system + task prompts:
162167 the golden path, written down), claude (the headless adapter:
README.md +3
@@ -331,6 +331,9 @@ crosses.
331331 `docs/FOLLOWING.md` is a fork that keeps up with its parent: where it
332332 stands, taking updates, and the crew's part when both sides moved.
333333
334 `docs/FLEET.md` is one change across many repositories, runs or issues: a
335 query, a dry-run plan of every row before → after, a receipt, and undo.
336
334337 `docs/MCP.md` is the forge as a connector, and `docs/STANDARDS.md` is what
335338 it answers by default: `.well-known`, feeds, cards, the manifest.
336339
docs/FLEET.md added +71
@@ -0,0 +1,71 @@
1 # Fleet operations
2
3 One action over every row a query selects, with the plan shown before
4 anything changes and a receipt kept after.
5
6 ```sh
7 # the plan: every row, before → after, and the rows refused with the reason
8 curl -sX POST $FORGE/api/v1/fleet/ops -H "authorization: Bearer $TOKEN" \
9 -d '{"query":"repo autonomy:autonomous","action":{"verb":"autonomy","value":"off"}}'
10
11 # the same, applied (an admin token): the receipt comes back
12 curl -sX POST $FORGE/api/v1/fleet/ops -H "authorization: Bearer $ADMIN" \
13 -d '{"query":"repo autonomy:autonomous","action":{"verb":"autonomy","value":"off"},"dryRun":false}'
14
15 # undo it, once
16 curl -sX POST $FORGE/api/v1/fleet/ops/$ID/undo -H "authorization: Bearer $ADMIN"
17 ```
18
19 ## The query
20
21 A kind — `repo`, `run` or `issue` — then `field:value` terms, all of which
22 must hold. `!` before a value negates it. `repo:owner/name` (or a bare name)
23 works on every kind.
24
25 | kind | fields |
26 | ----- | ----------------------------------------------------------- |
27 | repo | `repo` `owner` `name` `id` `autonomy` `visibility` |
28 | run | `repo` `owner` `name` `id` `state` `kind` `agent` `issue` |
29 | issue | `repo` `owner` `name` `id` `number` `state` `assignee` (`none`) `label` |
30
31 Only rows you can read are selected. More than 500 rows is refused
32 (`too_many`), never cut. This grammar is `src/fleet/selection.ts`; the fleet
33 table (#446) grows it.
34
35 ## The actions
36
37 `action` is `{ "verb": …, "value": … }`, or a bare verb when it takes no value.
38
39 | kind | verb | value | undo |
40 | ----- | ----------- | ------------------------- | ---- |
41 | repo | `autonomy` | `off` `assisted` `autonomous` | yes |
42 | repo | `slots` | 1–8 | yes |
43 | repo | `ceiling` | dollars, 0–10000 | yes |
44 | repo | `direction` | text, ≤ 2000 | yes |
45 | run | `cancel` | — | no |
46 | run | `requeue` | — (a cancelled or failed agent run) | no |
47 | issue | `close` | — | yes |
48 | issue | `assign` | a username, or `none` | yes |
49 | issue | `label` | a label's name (added) | yes |
50 | issue | `link` | the number they wait for | yes |
51
52 ## Who may
53
54 Every row needs admin of its repository; the rest come back `refused` with
55 the reason, in the plan and in the receipt. A dry run takes a read token;
56 applying and undoing take an admin one. An agent may preview but never
57 apply. Each row is written through the same action a person's click calls,
58 so it is authorized again and leaves its own event.
59
60 ## The receipt
61
62 Applying records a `fleet_operation` row (who, the query, the action) and
63 one row per selected target: the value before, the value asked for, and
64 `ok`, `failed`, `refused` or `unchanged` with the reason. One
65 `fleet.operated` event — for its actor alone, since it names repositories
66 across the forge — carries the counts. `GET /api/v1/fleet/ops` lists your
67 operations; `GET /api/v1/fleet/ops/:id` is one receipt.
68
69 Undo is a new operation that writes each `ok` row's value from before,
70 only where the row still holds what the operation left; a row someone
71 changed since is refused as `changed since: it is … now`.
forge.json +1 −1
@@ -6,7 +6,7 @@
66 "ready": 90
77 },
88 "check": {
9 "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/apps-token.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts test/asks.test.ts test/feed.test.ts test/feed-page.test.ts test/offline.test.ts test/share.test.ts test/cloud.test.ts test/run-secrets.test.ts test/forge-cloudflare.test.ts test/usage.test.ts test/standing.test.ts test/board-windows.test.ts test/spawn-plan.test.ts test/spawn.test.ts test/web-apps.test.ts",
9 "run": "bun x tsc --noEmit && bun scripts/test-shards.ts --timeout 60000 test/crew.test.ts test/crew-wrapup.test.ts test/crew-limits.test.ts test/crew-sandbox.test.ts test/steer.test.ts test/landing.test.ts test/decide.test.ts test/ledger.test.ts test/checks.test.ts test/dependencies.test.ts test/autonomy.test.ts test/studio.test.ts test/studio-words.test.ts test/forks.test.ts test/fork-flow.test.ts test/cross-repo-pull.test.ts test/pulls.test.ts test/apps.test.ts test/apps-adversarial.test.ts test/apps-busy.test.ts test/apps-token.test.ts test/web.test.ts test/git-trust.test.ts test/helpers.test.ts test/shards.test.ts test/migrate.test.ts test/vitals.test.ts test/asks.test.ts test/feed.test.ts test/feed-page.test.ts test/offline.test.ts test/share.test.ts test/cloud.test.ts test/run-secrets.test.ts test/forge-cloudflare.test.ts test/usage.test.ts test/standing.test.ts test/board-windows.test.ts test/spawn-plan.test.ts test/spawn.test.ts test/web-apps.test.ts test/fleet-ops.test.ts",
1010 "timeout": 1800
1111 }
1212 }
src/actions/fleet.ts added +427
@@ -0,0 +1,427 @@
1 /**
2 * Fleet operations: one action over every row a query selects. A dry run
3 * answers with the plan — each row, its value before and after, and the
4 * rows refused with the reason. Applying plans again from scratch (the
5 * world may have moved since the preview), writes each row through the
6 * platform's own action, and keeps the receipt: an `operation` row, a
7 * result per row, and one `fleet.operated` event. A reversible operation
8 * can be undone once, as a new operation that writes each row's value
9 * from before — only where the row still holds what this one left.
10 *
11 * Who may: every row is the actor's to change only if they administer its
12 * repository; the rest are listed as refused, never silently dropped. A
13 * row the actor cannot read is not selected at all. An agent may preview
14 * but never apply: a mass change is a person's call.
15 */
16
17 import type { App } from "../app.ts";
18 import {
19 claimUndo,
20 getOperation,
21 listOperations,
22 recordOperation,
23 releaseUndo,
24 settleUndo,
25 type FleetValue,
26 type Operation,
27 type OperationRow,
28 } from "../core/fleet_ops.ts";
29 import { blockerNumbersFor } from "../core/dependencies.ts";
30 import {
31 getIssuesByIds,
32 labelsForIssues,
33 listLabels,
34 } from "../core/issues.ts";
35 import { canAdmin, getReposByIds, type Repo } from "../core/repos.ts";
36 import { getRunsByIds } from "../core/runs.ts";
37 import type { User } from "../core/users.ts";
38 import { err, ok, type Result } from "../kernel/result.ts";
39 import {
40 parseQuery,
41 selectRows,
42 type FleetKind,
43 } from "../fleet/selection.ts";
44 import { VERBS, type Target, type Verb } from "../fleet/verbs.ts";
45
46 export interface FleetError {
47 readonly code:
48 | "unauthorized"
49 | "forbidden"
50 | "not_found"
51 | "bad_query"
52 | "too_many"
53 | "bad_action"
54 | "not_reversible"
55 | "already_undone";
56 readonly message: string;
57 }
58
59 export type PlannedOutcome = "change" | "unchanged" | "refused";
60
61 export interface PlannedRow extends Omit<OperationRow, "outcome"> {
62 readonly outcome: PlannedOutcome;
63 }
64
65 export interface Plan {
66 readonly query: string;
67 readonly kind: FleetKind;
68 readonly verb: string;
69 readonly value: FleetValue;
70 readonly reversible: boolean;
71 readonly rows: readonly PlannedRow[];
72 readonly counts: Readonly<Record<PlannedOutcome, number>>;
73 }
74
75 const fail = (code: FleetError["code"], message: string) =>
76 err<FleetError>({ code, message });
77
78 const same = (a: FleetValue, b: FleetValue): boolean =>
79 JSON.stringify(a) === JSON.stringify(b);
80
81 const shown = (value: FleetValue): string =>
82 value === null
83 ? "nothing"
84 : Array.isArray(value)
85 ? `[${value.join(", ")}]`
86 : String(value);
87
88 /** Read `{ verb, value }` (or a bare verb) against the query's kind. */
89 function parseAction(
90 app: App,
91 kind: FleetKind,
92 raw: unknown,
93 ): Result<{ name: string; verb: Verb; value: FleetValue }, FleetError> {
94 const input =
95 typeof raw === "string"
96 ? { verb: raw }
97 : typeof raw === "object" && raw !== null
98 ? (raw as Record<string, unknown>)
99 : {};
100 const name = typeof input.verb === "string" ? input.verb : "";
101 const verb = Object.hasOwn(VERBS, name) ? VERBS[name] : undefined;
102 if (verb === undefined || verb.kind !== kind) {
103 const verbs = Object.keys(VERBS).filter((v) => VERBS[v]!.kind === kind);
104 return fail("bad_action", `a ${kind} takes ${verbs.join(", ")}`);
105 }
106 const value = verb.parse(app, input.value ?? null);
107 if (!value.ok) return fail("bad_action", value.error);
108 return ok({ name, verb, value: value.value });
109 }
110
111 /** Every selected row with what any verb reads, in a few grouped queries. */
112 function loadTargets(
113 app: App,
114 kind: FleetKind,
115 ids: readonly string[],
116 ): Target[] {
117 if (kind === "repo") {
118 const repos = getReposByIds(app.db, ids);
119 return ids.flatMap((id) => {
120 const repo = repos.get(id);
121 return repo ? [{ id, repo, label: repoName(repo) }] : [];
122 });
123 }
124 if (kind === "run") {
125 const runs = getRunsByIds(app.db, ids);
126 const repos = getReposByIds(
127 app.db,
128 [...runs.values()].map((r) => r.repoId),
129 );
130 return ids.flatMap((id) => {
131 const run = runs.get(id);
132 const repo = run && repos.get(run.repoId);
133 return run && repo
134 ? [{ id, repo, run, label: `${repoName(repo)} ${run.kind} run ${id}` }]
135 : [];
136 });
137 }
138 const issues = getIssuesByIds(app.db, ids);
139 const repos = getReposByIds(
140 app.db,
141 [...issues.values()].map((i) => i.repoId),
142 );
143 const labels = labelsForIssues(app.db, ids);
144 const blockers = blockerNumbersFor(app.db, ids);
145 const repoLabels = new Map<string, string[]>();
146 for (const repoId of repos.keys())
147 repoLabels.set(
148 repoId,
149 listLabels(app.db, repoId).map((l) => l.name),
150 );
151 return ids.flatMap((id) => {
152 const issue = issues.get(id);
153 const repo = issue && repos.get(issue.repoId);
154 if (!issue || !repo) return [];
155 return [
156 {
157 id,
158 repo,
159 issue,
160 label: `${repoName(repo)}#${issue.number}`,
161 labels: (labels.get(id) ?? [])
162 .map((l) => l.name)
163 .sort((a, b) => a.localeCompare(b)),
164 blockers: blockers.get(id) ?? [],
165 repoLabels: repoLabels.get(repo.id) ?? [],
166 },
167 ];
168 });
169 }
170
171 const repoName = (repo: Repo): string => `${repo.ownerName}/${repo.name}`;
172
173 /** Whether `actor` administers a row's repository, asked once per repository. */
174 function adminOf(app: App, actor: User): (repo: Repo) => boolean {
175 const known = new Map<string, boolean>();
176 return (repo) => {
177 let yes = known.get(repo.id);
178 if (yes === undefined) {
179 yes = canAdmin(app.db, repo, actor);
180 known.set(repo.id, yes);
181 }
182 return yes;
183 };
184 }
185
186 /**
187 * One row's plan. `expect`, for an undo, is what the row must still hold:
188 * a row someone changed since is theirs now, and is left alone.
189 */
190 function planRow(
191 app: App,
192 admin: (repo: Repo) => boolean,
193 verb: Verb,
194 target: Target,
195 after: (before: FleetValue) => FleetValue,
196 expect?: FleetValue,
197 ): PlannedRow {
198 const before = verb.read(target);
199 const row = {
200 targetId: target.id,
201 repoId: target.repo.id,
202 label: target.label,
203 before,
204 after: after(before),
205 };
206 const refused = (reason: string): PlannedRow => ({
207 ...row,
208 outcome: "refused",
209 reason,
210 });
211 if (!admin(target.repo))
212 return refused(`you are not an admin of ${repoName(target.repo)}`);
213 if (expect !== undefined && !same(before, expect))
214 return refused(`changed since: it is ${shown(before)} now`);
215 if (same(row.before, row.after))
216 return { ...row, outcome: "unchanged", reason: "already so" };
217 const why = verb.refuse(app, target, row.after);
218 return why === undefined
219 ? { ...row, outcome: "change", reason: "" }
220 : refused(why);
221 }
222
223 function counted(rows: readonly PlannedRow[]): Plan["counts"] {
224 const counts = { change: 0, unchanged: 0, refused: 0 };
225 for (const row of rows) counts[row.outcome] += 1;
226 return counts;
227 }
228
229 function makePlan(
230 app: App,
231 actor: User | undefined,
232 query: string,
233 action: unknown,
234 ): Result<Plan & { verbOf: Verb; targets: Map<string, Target> }, FleetError> {
235 if (!actor) return fail("unauthorized", "sign in to act on the fleet");
236 const parsed = parseQuery(query);
237 if (!parsed.ok) return err(parsed.error);
238 const act = parseAction(app, parsed.value.kind, action);
239 if (!act.ok) return act;
240 const ids = selectRows(app.db, actor, parsed.value);
241 if (!ids.ok) return err(ids.error);
242 const targets = loadTargets(app, parsed.value.kind, ids.value);
243 const admin = adminOf(app, actor);
244 const { verb, value } = act.value;
245 const rows = targets.map((t) =>
246 planRow(app, admin, verb, t, (before) => verb.next(before, value)),
247 );
248 return ok({
249 query: query.trim(),
250 kind: parsed.value.kind,
251 verb: act.value.name,
252 value,
253 reversible: verb.reversible,
254 rows,
255 counts: counted(rows),
256 verbOf: verb,
257 targets: new Map(targets.map((t) => [t.id, t])),
258 });
259 }
260
261 /** The dry run: what applying would do to each row, and nothing done. */
262 export function previewFleetOp(
263 app: App,
264 actor: User | undefined,
265 query: string,
266 action: unknown,
267 ): Result<Plan, FleetError> {
268 const plan = makePlan(app, actor, query, action);
269 if (!plan.ok) return plan;
270 const { verbOf: _verb, targets: _targets, ...shape } = plan.value;
271 return ok(shape);
272 }
273
274 /** Write each planned change, in order; the rows as they ended. */
275 async function carryOut(
276 app: App,
277 actor: User,
278 verb: Verb,
279 rows: readonly PlannedRow[],
280 targets: Map<string, Target>,
281 ): Promise<OperationRow[]> {
282 const out: OperationRow[] = [];
283 for (const row of rows) {
284 const target = targets.get(row.targetId);
285 if (row.outcome !== "change" || target === undefined) {
286 out.push({ ...row, outcome: row.outcome === "change" ? "failed" : row.outcome });
287 continue;
288 }
289 const why = await verb.write(app, actor, target, row.after);
290 out.push(
291 why === undefined
292 ? { ...row, outcome: "ok", reason: "" }
293 : { ...row, outcome: "failed", reason: why },
294 );
295 }
296 return out;
297 }
298
299 function announce(app: App, op: Operation): void {
300 const counts = { ok: 0, failed: 0, refused: 0, unchanged: 0 };
301 for (const row of op.rows) counts[row.outcome] += 1;
302 app.bus.append({
303 kind: "fleet.operated",
304 actorId: op.actorId,
305 payload: {
306 operation: op.id,
307 query: op.query,
308 verb: op.verb,
309 undoOf: op.undoOf,
310 ...counts,
311 },
312 });
313 }
314
315 function requirePerson(
316 actor: User | undefined,
317 ): Result<User, FleetError> {
318 if (!actor) return fail("unauthorized", "sign in to act on the fleet");
319 if (actor.kind === "agent")
320 return fail("forbidden", "a fleet operation is a person's call");
321 return ok(actor);
322 }
323
324 /** Apply: plan again, write every row that may change, keep the receipt. */
325 export async function applyFleetOp(
326 app: App,
327 actor: User | undefined,
328 query: string,
329 action: unknown,
330 ): Promise<Result<Operation, FleetError>> {
331 const person = requirePerson(actor);
332 if (!person.ok) return person;
333 const plan = makePlan(app, person.value, query, action);
334 if (!plan.ok) return plan;
335 const { verbOf, targets, rows } = plan.value;
336 const done = await carryOut(app, person.value, verbOf, rows, targets);
337 const op = recordOperation(app.db, {
338 actorId: person.value.id,
339 query: plan.value.query,
340 kind: plan.value.kind,
341 verb: plan.value.verb,
342 value: plan.value.value,
343 reversible: plan.value.reversible,
344 undoOf: null,
345 rows: done,
346 });
347 announce(app, op);
348 return ok(op);
349 }
350
351 /**
352 * Undo: every row this operation changed goes back to its value from
353 * before, if it still holds the value this operation left. Once per
354 * operation; the undo is an operation of its own, with its own receipt.
355 */
356 export async function undoFleetOp(
357 app: App,
358 actor: User | undefined,
359 operationId: string,
360 ): Promise<Result<Operation, FleetError>> {
361 const person = requirePerson(actor);
362 if (!person.ok) return person;
363 const found = getFleetOp(app, person.value, operationId);
364 if (!found.ok) return found;
365 const op = found.value;
366 const verb = Object.hasOwn(VERBS, op.verb) ? VERBS[op.verb] : undefined;
367 if (!op.reversible || verb === undefined)
368 return fail("not_reversible", `${op.verb} cannot be undone`);
369 if (!claimUndo(app.db, op.id))
370 return fail("already_undone", "this operation was undone already");
371 let undone: Operation | undefined;
372 try {
373 const changed = op.rows.filter((r) => r.outcome === "ok");
374 const targets = loadTargets(
375 app,
376 op.kind,
377 changed.map((r) => r.targetId),
378 );
379 const byId = new Map(targets.map((t) => [t.id, t]));
380 const admin = adminOf(app, person.value);
381 const rows = changed.map((r): PlannedRow => {
382 const target = byId.get(r.targetId);
383 if (target === undefined)
384 return { ...r, before: r.after, after: r.before, outcome: "refused", reason: "it is gone" };
385 return planRow(app, admin, verb, target, () => r.before, r.after);
386 });
387 const done = await carryOut(app, person.value, verb, rows, byId);
388 undone = recordOperation(app.db, {
389 actorId: person.value.id,
390 query: op.query,
391 kind: op.kind,
392 verb: op.verb,
393 value: op.value,
394 reversible: true,
395 undoOf: op.id,
396 rows: done,
397 });
398 settleUndo(app.db, op.id, undone.id);
399 announce(app, undone);
400 return ok(undone);
401 } finally {
402 if (undone === undefined) releaseUndo(app.db, op.id);
403 }
404 }
405
406 /** A receipt, for the person who acted or a site admin; anyone else gets `not_found`. */
407 export function getFleetOp(
408 app: App,
409 actor: User | undefined,
410 operationId: string,
411 ): Result<Operation, FleetError> {
412 const op = getOperation(app.db, operationId);
413 if (!op || !actor || (op.actorId !== actor.id && !actor.isAdmin))
414 return fail("not_found", "no such operation here");
415 return ok(op);
416 }
417
418 /** The actor's own operations, newest first, without their rows. */
419 export function listFleetOps(
420 app: App,
421 actor: User | undefined,
422 limit: number,
423 before?: string,
424 ): Result<Operation[], FleetError> {
425 if (!actor) return fail("unauthorized", "sign in to see your operations");
426 return ok(listOperations(app.db, actor.id, limit, before));
427 }
src/actions/runs.ts +41 −2
@@ -1,6 +1,6 @@
11 /**
2 * Run actions: look at a repository's runs and their logs, cancel one, and
3 * — for the workers that produce runs — announce a run's state on the
2 * Run actions: look at a repository's runs and their logs, cancel one,
3 * queue a finished agent run's task again, and — for the workers that produce runs — announce a run's state on the
44 * outbox. Reading follows the repository's visibility (a run's log is as
55 * private as the code it ran); cancelling takes write access; announcing
66 * is the system's own voice and takes no actor.
@@ -20,6 +20,7 @@ import {
2020 } from "../core/runs.ts";
2121 import type { User } from "../core/users.ts";
2222 import { abortAgentRun } from "../crew/running.ts";
23 import { queueAgentRun, taskOf } from "../crew/dispatch.ts";
2324 import { err, ok, type Result } from "../kernel/result.ts";
2425 import { readRunLog, runLogPath } from "../runs/log.ts";
2526 import { requireReader, requireWriter, type AccessError } from "./access.ts";
@@ -134,6 +135,42 @@ export function cancelRepoRun(
134135 return ok(cancelled);
135136 }
136137
138 /**
139 * Queue a cancelled or failed agent run's task again: the same agent, the
140 * same issue, the same reason. The run that starts reads the whole
141 * conversation, so it carries on rather than starting over. Refused when
142 * the issue is closed or the agent is gone (`no_task`), and when the crew
143 * would not take it now — a run already waiting, the breaker tripped, the
144 * day's ceiling reached (`not_queued`).
145 */
146 export function requeueRepoRun(
147 app: App,
148 actor: User | undefined,
149 repo: Repo,
150 runId: string,
151 ): Result<Run, AccessError | "not_agent" | "not_finished" | "no_task" | "not_queued"> {
152 const writer = requireWriter(app, actor, repo);
153 if (!writer.ok) return writer;
154 // A run costs money; like assigning, queueing one is a person's call.
155 if (writer.value.kind === "agent") return err("forbidden");
156 const run = getRun(app.db, runId);
157 if (!run || run.repoId !== repo.id) return err("not_found");
158 if (run.kind !== "agent") return err("not_agent");
159 if (run.state !== "cancelled" && run.state !== "failed")
160 return err("not_finished");
161 const task = taskOf(app, run);
162 if (task === undefined) return err("no_task");
163 const queued = queueAgentRun(
164 app,
165 task.repo,
166 task.issue,
167 task.agent,
168 task.reason,
169 writer.value.id,
170 );
171 return queued ? ok(queued) : err("not_queued");
172 }
173
137174 export type SteerOutcome =
138175 /** The agent hears it at its next step. */
139176 | "sent"
@@ -155,6 +192,8 @@ export function steerRepoRun(
155192 ): Result<SteerOutcome, AccessError | "empty" | "not_agent"> {
156193 const writer = requireWriter(app, actor, repo);
157194 if (!writer.ok) return writer;
195 // A run costs money; like assigning, queueing one is a person's call.
196 if (writer.value.kind === "agent") return err("forbidden");
158197 const run = getRun(app.db, runId);
159198 if (!run || run.repoId !== repo.id) return err("not_found");
160199 if (run.kind !== "agent") return err("not_agent");
src/core/dependencies.ts +29
@@ -84,6 +84,35 @@ function blockerIds(db: Database, issueId: string): string[] {
8484 .map((r) => r.blocked_by_id);
8585 }
8686
87 /**
88 * The numbers each issue waits for, for a batch of issues in one query per
89 * chunk, ascending. Only ids that wait for something get an entry.
90 */
91 export function blockerNumbersFor(
92 db: Database,
93 issueIds: readonly string[],
94 ): Map<string, number[]> {
95 const out = new Map<string, number[]>();
96 const CHUNK = 500;
97 for (let i = 0; i < issueIds.length; i += CHUNK) {
98 const chunk = issueIds.slice(i, i + CHUNK);
99 const rows = db
100 .query<{ issue_id: string; number: number }, string[]>(
101 `SELECT d.issue_id, b.number FROM issue_dependency AS d
102 JOIN issue AS b ON b.id = d.blocked_by_id
103 WHERE d.issue_id IN (${chunk.map((_, n) => `?${n + 1}`).join(",")})
104 ORDER BY b.number`,
105 )
106 .all(...chunk);
107 for (const row of rows) {
108 const list = out.get(row.issue_id) ?? [];
109 list.push(row.number);
110 out.set(row.issue_id, list);
111 }
112 }
113 return out;
114 }
115
87116 /** What this issue waits for, and what waits for it. */
88117 export function dependenciesOf(
89118 db: Database,
src/core/event_audience.ts +3 −1
@@ -31,7 +31,9 @@ export function eventAudience(kind: string): EventAudience {
3131 kind === "repo.settings_updated"
3232 )
3333 return "admins";
34 if (kind === "repo.pinned" || kind === "repo.unpinned") return "actor";
34 // A fleet operation spans repositories; its receipt is its actor's.
35 if (kind === "repo.pinned" || kind === "repo.unpinned" || kind.startsWith("fleet."))
36 return "actor";
3537 return "readers";
3638 }
3739
src/core/fleet_ops.ts added +207
@@ -0,0 +1,207 @@
1 /**
2 * The receipt of a fleet operation: one row for the operation (who, which
3 * query, which action) and one per selected row (its value before, the
4 * value asked for, and what happened). Pure data — who may act and what
5 * acting does live in `actions/fleet.ts`.
6 */
7
8 import type { Database } from "bun:sqlite";
9 import { transact } from "../kernel/db.ts";
10 import { id } from "../kernel/id.ts";
11
12 /** A row's value, as it is shown before → after: plain JSON. */
13 export type FleetValue = string | number | null | readonly (string | number)[];
14
15 export type RowOutcome = "ok" | "failed" | "refused" | "unchanged";
16
17 export interface OperationRow {
18 readonly targetId: string;
19 readonly repoId: string;
20 /** The row in words: `owner/name`, `owner/name#12`, `run 01… on owner/name`. */
21 readonly label: string;
22 readonly before: FleetValue;
23 readonly after: FleetValue;
24 readonly outcome: RowOutcome;
25 /** Why a row was refused, failed or left alone; empty when it went through. */
26 readonly reason: string;
27 }
28
29 export interface Operation {
30 readonly id: string;
31 readonly actorId: string;
32 readonly actorName: string;
33 readonly query: string;
34 readonly kind: "repo" | "run" | "issue";
35 readonly verb: string;
36 readonly value: FleetValue;
37 readonly reversible: boolean;
38 /** The operation this one reversed, when it is an undo. */
39 readonly undoOf: string | null;
40 /** The operation that reversed this one, once one has. */
41 readonly undoneBy: string | null;
42 readonly createdAt: string;
43 readonly rows: readonly OperationRow[];
44 }
45
46 export type NewOperation = Omit<
47 Operation,
48 "id" | "actorName" | "undoneBy" | "createdAt"
49 >;
50
51 interface OperationRecord {
52 id: string;
53 actor_id: string;
54 actor_name: string | null;
55 query: string;
56 kind: Operation["kind"];
57 verb: string;
58 value: string;
59 reversible: number;
60 undo_of: string | null;
61 undone_by: string | null;
62 created_at: string;
63 }
64
65 interface RowRecord {
66 operation_id: string;
67 target_id: string;
68 repo_id: string;
69 label: string;
70 before: string;
71 after: string;
72 outcome: RowOutcome;
73 reason: string;
74 }
75
76 const SELECT = `
77 SELECT fleet_operation.*, user.username AS actor_name
78 FROM fleet_operation LEFT JOIN user ON user.id = fleet_operation.actor_id`;
79
80 /** Write the receipt, rows and all, in one transaction. */
81 export function recordOperation(db: Database, input: NewOperation): Operation {
82 const opId = id();
83 transact(db, () => {
84 db.query(
85 `INSERT INTO fleet_operation
86 (id, actor_id, query, kind, verb, value, reversible, undo_of, created_at)
87 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, strftime('%Y-%m-%dT%H:%M:%fZ','now'))`,
88 ).run(
89 opId,
90 input.actorId,
91 input.query,
92 input.kind,
93 input.verb,
94 JSON.stringify(input.value),
95 input.reversible ? 1 : 0,
96 input.undoOf,
97 );
98 const insert = db.query(
99 `INSERT INTO fleet_operation_row
100 (operation_id, position, target_id, repo_id, label, before, after, outcome, reason)
101 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)`,
102 );
103 input.rows.forEach((row, n) =>
104 insert.run(
105 opId,
106 n,
107 row.targetId,
108 row.repoId,
109 row.label,
110 JSON.stringify(row.before),
111 JSON.stringify(row.after),
112 row.outcome,
113 row.reason,
114 ),
115 );
116 });
117 return getOperation(db, opId)!;
118 }
119
120 /**
121 * Claim an operation for undoing before any row is touched: the UPDATE's
122 * WHERE is the claim, so two undos of one operation cannot both run.
123 * `releaseUndo` gives it back when the undo came to nothing.
124 */
125 export function claimUndo(db: Database, operationId: string): boolean {
126 return (
127 db
128 .query(
129 "UPDATE fleet_operation SET undone_by = 'pending' WHERE id = ?1 AND undone_by IS NULL",
130 )
131 .run(operationId).changes > 0
132 );
133 }
134
135 export function releaseUndo(db: Database, operationId: string): void {
136 db.query(
137 "UPDATE fleet_operation SET undone_by = NULL WHERE id = ?1 AND undone_by = 'pending'",
138 ).run(operationId);
139 }
140
141 /** Point a claimed operation at the operation that reversed it. */
142 export function settleUndo(
143 db: Database,
144 operationId: string,
145 undoneBy: string,
146 ): void {
147 db.query(
148 "UPDATE fleet_operation SET undone_by = ?2 WHERE id = ?1 AND undone_by = 'pending'",
149 ).run(operationId, undoneBy);
150 }
151
152 export function getOperation(
153 db: Database,
154 operationId: string,
155 ): Operation | undefined {
156 const op = db
157 .query<OperationRecord, [string]>(`${SELECT} WHERE fleet_operation.id = ?1`)
158 .get(operationId);
159 if (!op) return undefined;
160 const rows = db
161 .query<RowRecord, [string]>(
162 "SELECT * FROM fleet_operation_row WHERE operation_id = ?1 ORDER BY position",
163 )
164 .all(operationId);
165 return toOperation(op, rows);
166 }
167
168 /** One actor's operations, newest first, without their rows. */
169 export function listOperations(
170 db: Database,
171 actorId: string,
172 limit: number,
173 before?: string,
174 ): Operation[] {
175 return db
176 .query<OperationRecord, [string, string, number]>(
177 `${SELECT} WHERE fleet_operation.actor_id = ?1 AND fleet_operation.id < ?2
178 ORDER BY fleet_operation.id DESC LIMIT ?3`,
179 )
180 .all(actorId, before ?? "\u{10FFFF}", limit)
181 .map((op) => toOperation(op, []));
182 }
183
184 function toOperation(op: OperationRecord, rows: RowRecord[]): Operation {
185 return {
186 id: op.id,
187 actorId: op.actor_id,
188 actorName: op.actor_name ?? "",
189 query: op.query,
190 kind: op.kind,
191 verb: op.verb,
192 value: JSON.parse(op.value) as FleetValue,
193 reversible: op.reversible === 1,
194 undoOf: op.undo_of,
195 undoneBy: op.undone_by === "pending" ? null : op.undone_by,
196 createdAt: op.created_at,
197 rows: rows.map((r) => ({
198 targetId: r.target_id,
199 repoId: r.repo_id,
200 label: r.label,
201 before: JSON.parse(r.before) as FleetValue,
202 after: JSON.parse(r.after) as FleetValue,
203 outcome: r.outcome,
204 reason: r.reason,
205 })),
206 };
207 }
src/core/schema.ts +33
@@ -1048,4 +1048,37 @@ export const migrations: readonly string[] = [
10481048 ALTER TABLE run ADD COLUMN sandbox_enforced INTEGER;
10491049 ALTER TABLE run ADD COLUMN sandbox_note TEXT NOT NULL DEFAULT '';
10501050 `,
1051 // 40 — a fleet operation's receipt (`core/fleet_ops.ts`): who acted, on
1052 // which query, with what action, and per row the value before, the value
1053 // asked for and what happened. Rows keep the target's words and no
1054 // reference, so a receipt outlives a deleted repository. `undone_by`
1055 // points at the operation that reversed this one.
1056 `
1057 CREATE TABLE fleet_operation (
1058 id TEXT PRIMARY KEY,
1059 actor_id TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
1060 query TEXT NOT NULL,
1061 kind TEXT NOT NULL CHECK (kind IN ('repo','run','issue')),
1062 verb TEXT NOT NULL,
1063 value TEXT NOT NULL,
1064 reversible INTEGER NOT NULL,
1065 undo_of TEXT,
1066 undone_by TEXT,
1067 created_at TEXT NOT NULL
1068 );
1069 CREATE INDEX fleet_operation_actor ON fleet_operation(actor_id, id);
1070 CREATE TABLE fleet_operation_row (
1071 operation_id TEXT NOT NULL REFERENCES fleet_operation(id) ON DELETE CASCADE,
1072 position INTEGER NOT NULL,
1073 target_id TEXT NOT NULL,
1074 repo_id TEXT NOT NULL,
1075 label TEXT NOT NULL,
1076 before TEXT NOT NULL,
1077 after TEXT NOT NULL,
1078 outcome TEXT NOT NULL
1079 CHECK (outcome IN ('ok','failed','refused','unchanged')),
1080 reason TEXT NOT NULL DEFAULT '',
1081 PRIMARY KEY (operation_id, position)
1082 );
1083 `,
10511084 ];
src/crew/dispatch.ts +1 −1
@@ -820,7 +820,7 @@ function resumeLater(app: App, run: Run, pause: Pause): void {
820820 }
821821
822822 /** The still-open task a run was about, and the reason to queue it under again. */
823 function taskOf(
823 export function taskOf(
824824 app: App,
825825 run: Run,
826826 ): { issue: Issue; repo: Repo; agent: Agent; reason: RunReason } | undefined {
src/fleet/selection.ts added +194
@@ -0,0 +1,194 @@
1 /**
2 * Which rows a fleet operation is about: a query line in, row ids out.
3 *
4 * The line is a kind and then `field:value` terms, all of which must hold:
5 * `repo autonomy:autonomous`, `run state:queued repo:!trevato/forge`,
6 * `issue repo:engine label:bug assignee:none`. A value starting with `!`
7 * negates the term. This is the small grammar operations need to name their
8 * rows; the fleet table (#446) grows the language, and this module is the
9 * one seam it replaces.
10 *
11 * Only rows the viewer may read are ever selected — a repository you cannot
12 * see is not refused, it is absent, the same ambiguity `not_found` keeps.
13 */
14
15 import type { Database } from "bun:sqlite";
16 import { REPO_VISIBLE } from "../core/visibility.ts";
17 import type { User } from "../core/users.ts";
18 import { err, ok, type Result } from "../kernel/result.ts";
19
20 export type FleetKind = "repo" | "run" | "issue";
21
22 /** More rows than this and the operator must narrow the query. */
23 export const MAX_ROWS = 500;
24 const MAX_QUERY = 500;
25 const MAX_TERMS = 20;
26
27 export interface Term {
28 readonly field: string;
29 readonly value: string;
30 readonly negate: boolean;
31 }
32
33 export interface FleetQuery {
34 readonly kind: FleetKind;
35 readonly terms: readonly Term[];
36 }
37
38 export type QueryError =
39 | { code: "bad_query"; message: string }
40 | { code: "too_many"; message: string };
41
42 const KINDS: Record<string, FleetKind> = {
43 repo: "repo",
44 repos: "repo",
45 run: "run",
46 runs: "run",
47 issue: "issue",
48 issues: "issue",
49 };
50
51 /**
52 * Each field as a SQL predicate over one bound value. The FROM clause of
53 * every kind joins `repo` and its owner as `user`, so the repository
54 * fields mean the same thing on every kind.
55 */
56 type Predicate = (p: string) => string;
57
58 const repoFields: Record<string, Predicate> = {
59 owner: (p) => `user.username = ${p} COLLATE NOCASE`,
60 name: (p) => `repo.name = ${p} COLLATE NOCASE`,
61 };
62
63 const FIELDS: Record<FleetKind, Record<string, Predicate>> = {
64 repo: {
65 ...repoFields,
66 id: (p) => `repo.id = ${p}`,
67 autonomy: (p) => `repo.autonomy = ${p} COLLATE NOCASE`,
68 visibility: (p) => `repo.visibility = ${p} COLLATE NOCASE`,
69 },
70 run: {
71 ...repoFields,
72 id: (p) => `run.id = ${p}`,
73 state: (p) => `run.state = ${p} COLLATE NOCASE`,
74 kind: (p) => `run.kind = ${p} COLLATE NOCASE`,
75 agent: (p) =>
76 `run.actor_id IN (SELECT id FROM user AS a WHERE a.username = ${p} COLLATE NOCASE)`,
77 issue: (p) =>
78 `run.issue_id IN (SELECT i.id FROM issue AS i
79 WHERE i.repo_id = run.repo_id AND i.number = CAST(${p} AS INTEGER))`,
80 },
81 issue: {
82 ...repoFields,
83 id: (p) => `issue.id = ${p}`,
84 number: (p) => `issue.number = CAST(${p} AS INTEGER)`,
85 state: (p) => `issue.state = ${p} COLLATE NOCASE`,
86 assignee: (p) =>
87 `(CASE WHEN ${p} = 'none' THEN issue.assignee_id IS NULL
88 ELSE issue.assignee_id IN (SELECT id FROM user AS a WHERE a.username = ${p} COLLATE NOCASE) END)`,
89 label: (p) =>
90 `EXISTS (SELECT 1 FROM issue_label JOIN label ON label.id = issue_label.label_id
91 WHERE issue_label.issue_id = issue.id AND label.name = ${p} COLLATE NOCASE)`,
92 },
93 };
94
95 const FROM: Record<FleetKind, { table: string; from: string; order: string }> =
96 {
97 repo: {
98 table: "repo",
99 from: "repo JOIN user ON user.id = repo.owner_id",
100 order: "user.username, repo.name",
101 },
102 run: {
103 table: "run",
104 from: `run JOIN repo ON repo.id = run.repo_id
105 JOIN user ON user.id = repo.owner_id`,
106 order: "run.id DESC",
107 },
108 issue: {
109 table: "issue",
110 from: `issue JOIN repo ON repo.id = issue.repo_id
111 JOIN user ON user.id = repo.owner_id`,
112 order: "user.username, repo.name, issue.number",
113 },
114 };
115
116 const bad = (message: string): Result<never, QueryError> =>
117 err({ code: "bad_query", message });
118
119 /** Read a query line. Every field is checked against its kind's list. */
120 export function parseQuery(text: string): Result<FleetQuery, QueryError> {
121 if (text.length > MAX_QUERY) return bad(`a query is at most ${MAX_QUERY} characters`);
122 const [head, ...rest] = text.trim().split(/\s+/);
123 const kind = KINDS[(head ?? "").toLowerCase()];
124 if (kind === undefined)
125 return bad("a query starts with what it selects: repo, run or issue");
126 if (rest.length > MAX_TERMS) return bad(`a query has at most ${MAX_TERMS} terms`);
127 const terms: Term[] = [];
128 for (const word of rest) {
129 const colon = word.indexOf(":");
130 const field = colon > 0 ? word.slice(0, colon).toLowerCase() : "";
131 let value = colon > 0 ? word.slice(colon + 1) : "";
132 const negate = value.startsWith("!");
133 if (negate) value = value.slice(1);
134 if (field === "" || value === "")
135 return bad(`"${word}" is not field:value`);
136 if (field !== "repo" && FIELDS[kind][field] === undefined)
137 return bad(
138 `${kind} has no field "${field}"; it has repo, ${Object.keys(FIELDS[kind]).join(", ")}`,
139 );
140 terms.push({ field, value, negate });
141 }
142 return ok({ kind, terms });
143 }
144
145 /**
146 * The ids a query selects for `viewer`, in a stable order. More than
147 * `MAX_ROWS` is refused rather than cut: an operation over the first 500
148 * of 900 rows is not what anyone meant.
149 */
150 export function selectRows(
151 db: Database,
152 viewer: User,
153 query: FleetQuery,
154 ): Result<string[], QueryError> {
155 const params: (string | number)[] = [viewer.id];
156 const bind = (value: string): string => {
157 params.push(value);
158 return `?${params.length}`;
159 };
160 const where = [REPO_VISIBLE];
161 if (query.kind === "issue") where.push("issue.kind = 'issue'");
162 for (const term of query.terms) {
163 let sql: string;
164 if (term.field === "repo") {
165 // `owner/name`, or a bare name in any owner.
166 const slash = term.value.indexOf("/");
167 sql =
168 slash < 0
169 ? repoFields.name!(bind(term.value))
170 : `${repoFields.owner!(bind(term.value.slice(0, slash)))}
171 AND ${repoFields.name!(bind(term.value.slice(slash + 1)))}`;
172 } else sql = FIELDS[query.kind][term.field]!(bind(term.value));
173 // COALESCE: a NULL comparison negated is still NULL, and `!x` must
174 // select the rows where x does not hold, including the empty ones.
175 const held = `COALESCE((${sql}), 0)`;
176 where.push(term.negate ? `NOT ${held}` : held);
177 }
178 const { table, from, order } = FROM[query.kind];
179 params.push(MAX_ROWS + 1);
180 const ids = db
181 .query<{ id: string }, (string | number)[]>(
182 `SELECT ${table}.id AS id FROM ${from}
183 WHERE ${where.join(" AND ")}
184 ORDER BY ${order} LIMIT ?${params.length}`,
185 )
186 .all(...params)
187 .map((r) => r.id);
188 if (ids.length > MAX_ROWS)
189 return err({
190 code: "too_many",
191 message: `more than ${MAX_ROWS} rows match; narrow the query`,
192 });
193 return ok(ids);
194 }
src/fleet/verbs.ts added +305
@@ -0,0 +1,305 @@
1 /**
2 * What a fleet operation can do, one verb per entry. Every verb is the same
3 * four questions over one row: what does it hold now (`read`), what will
4 * it hold (`next`), why can it not (`refuse`), and make it so (`write`).
5 * Because a verb's value is the whole state it touches — a state, a label
6 * set, a list of blockers — undo is the same verb asked to write the value
7 * from before, so nothing here knows how to undo anything.
8 *
9 * `write` goes through the platform's own actions, which authorize again
10 * and append their own events; a verb never touches the database itself.
11 */
12
13 import type { App } from "../app.ts";
14 import {
15 assignIssue,
16 setIssueLabels,
17 setIssueState,
18 } from "../actions/issues.ts";
19 import { linkIssue, unlinkIssue, dependencyWords } from "../actions/dependencies.ts";
20 import { updateRepoSettings } from "../actions/repos.ts";
21 import { cancelRepoRun, requeueRepoRun } from "../actions/runs.ts";
22 import { isCrew } from "../core/agents.ts";
23 import type { FleetValue } from "../core/fleet_ops.ts";
24 import { getIssue, type Issue } from "../core/issues.ts";
25 import {
26 canRead,
27 isAutonomy,
28 type Repo,
29 type RepoSettings,
30 } from "../core/repos.ts";
31 import { isTerminal, type Run } from "../core/runs.ts";
32 import { getUserByName, type User } from "../core/users.ts";
33 import { err, ok, type Result } from "../kernel/result.ts";
34 import { isMirror } from "../origins/write-through.ts";
35 import type { FleetKind } from "./selection.ts";
36
37 /** One selected row, with what its verbs read, loaded for the whole batch. */
38 export interface Target {
39 readonly id: string;
40 readonly repo: Repo;
41 readonly label: string;
42 readonly run?: Run;
43 readonly issue?: Issue;
44 /** For an issue: its label names, sorted. */
45 readonly labels?: readonly string[];
46 /** For an issue: the numbers it waits for, ascending. */
47 readonly blockers?: readonly number[];
48 /** For an issue: every label its repository has, by name. */
49 readonly repoLabels?: readonly string[];
50 }
51
52 export interface Verb {
53 readonly kind: FleetKind;
54 readonly reversible: boolean;
55 /** Check the action's value at the boundary; the normalised value, or why not. */
56 parse(app: App, value: unknown): Result<FleetValue, string>;
57 read(target: Target): FleetValue;
58 next(before: FleetValue, value: FleetValue): FleetValue;
59 /** Why this row cannot hold `after`, beyond who is asking; undefined when it can. */
60 refuse(app: App, target: Target, after: FleetValue): string | undefined;
61 /** Make the row hold `after`; undefined when it does, else why not. */
62 write(
63 app: App,
64 actor: User,
65 target: Target,
66 after: FleetValue,
67 ): Promise<string | undefined>;
68 }
69
70 /** An action's refusal codes, in words a receipt can show. */
71 const WORDS: Record<string, string> = {
72 ...dependencyWords,
73 not_found: "not found",
74 forbidden: "not allowed",
75 unauthorized: "not signed in",
76 mirrored: "mirrored: change it at the origin",
77 unknown_label: "no such label here",
78 assignee_cannot_read: "the assignee cannot read this repository",
79 agent_not_on_crew: "that agent is not on this repository's crew",
80 already_merged: "a merged pull stays closed",
81 finished: "already finished",
82 not_agent: "only an agent run is queued again",
83 not_finished: "only a cancelled or failed run is queued again",
84 no_task: "its issue is closed or its agent is gone",
85 not_queued: "the crew would not take it now (already queued, parked, or at the ceiling)",
86 unknown_branch: "unknown branch",
87 };
88
89 const words = (code: string): string => WORDS[code] ?? code;
90
91 /** A Result from an action, as a verb's answer. */
92 const done = (result: Result<unknown, string>): string | undefined =>
93 result.ok ? undefined : words(result.error);
94
95 const sortedUnique = (
96 values: readonly (string | number)[],
97 ): (string | number)[] =>
98 [...new Set(values)].sort((a, b) =>
99 typeof a === "number" && typeof b === "number"
100 ? a - b
101 : String(a).localeCompare(String(b)),
102 );
103
104 const replace = (_before: FleetValue, value: FleetValue): FleetValue => value;
105
106 /** A repository setting: the row holds the setting's value. */
107 function setting(
108 field: "autonomy" | "autonomySlots" | "autonomyCeilingUsd" | "autonomyDirection",
109 parse: (value: unknown) => Result<FleetValue, string>,
110 ): Verb {
111 return {
112 kind: "repo",
113 reversible: true,
114 parse: (_app, value) => parse(value),
115 read: (t) => t.repo[field],
116 next: replace,
117 refuse: () => undefined,
118 async write(app, actor, t, after) {
119 const settings: RepoSettings = {};
120 if (field === "autonomy" && isAutonomy(after)) {
121 settings.autonomy = after;
122 // Whoever turns autonomy on is who it acts as, as on the settings page.
123 settings.autonomyBy = after === "off" ? null : actor.id;
124 } else if (field === "autonomyDirection" && typeof after === "string")
125 settings.autonomyDirection = after;
126 else if (field === "autonomySlots" && typeof after === "number")
127 settings.autonomySlots = after;
128 else if (field === "autonomyCeilingUsd" && typeof after === "number")
129 settings.autonomyCeilingUsd = after;
130 else return "not a value for this setting";
131 return done(await updateRepoSettings(app, actor, t.repo, settings));
132 },
133 };
134 }
135
136 const noValue = (): Result<FleetValue, string> => ok(null);
137
138 export const VERBS: Record<string, Verb> = {
139 autonomy: setting("autonomy", (v) =>
140 isAutonomy(v) ? ok(v) : err("autonomy is off, assisted or autonomous"),
141 ),
142 slots: setting("autonomySlots", (v) =>
143 typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 8
144 ? ok(v)
145 : err("slots are a whole number from 1 to 8"),
146 ),
147 ceiling: setting("autonomyCeilingUsd", (v) =>
148 typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 10_000
149 ? ok(Math.round(v * 100) / 100)
150 : err("the ceiling is a number of dollars from 0 to 10000"),
151 ),
152 direction: setting("autonomyDirection", (v) =>
153 typeof v === "string" && v.trim().length <= 2000
154 ? ok(v.trim())
155 : err("the direction is text of at most 2000 characters"),
156 ),
157
158 cancel: {
159 kind: "run",
160 reversible: false,
161 parse: noValue,
162 read: (t) => t.run?.state ?? null,
163 next: () => "cancelled",
164 refuse: (_app, t) =>
165 t.run && isTerminal(t.run.state) && t.run.state !== "cancelled"
166 ? `already finished (${t.run.state})`
167 : undefined,
168 write: async (app, actor, t) =>
169 done(cancelRepoRun(app, actor, t.repo, t.id)),
170 },
171
172 requeue: {
173 kind: "run",
174 reversible: false,
175 parse: noValue,
176 read: (t) => t.run?.state ?? null,
177 next: () => "queued",
178 refuse(_app, t) {
179 if (t.run?.kind !== "agent") return words("not_agent");
180 if (t.run.state !== "cancelled" && t.run.state !== "failed")
181 return words("not_finished");
182 return undefined;
183 },
184 write: async (app, actor, t) =>
185 done(requeueRepoRun(app, actor, t.repo, t.id)),
186 },
187
188 close: {
189 kind: "issue",
190 reversible: true,
191 parse: noValue,
192 read: (t) => t.issue?.state ?? null,
193 next: () => "closed",
194 refuse: (app, t) => (isMirror(app, t.repo) ? words("mirrored") : undefined),
195 async write(app, actor, t, after) {
196 if (!t.issue || (after !== "open" && after !== "closed"))
197 return words("not_found");
198 return done(setIssueState(app, actor, t.repo, t.issue, after));
199 },
200 },
201
202 assign: {
203 kind: "issue",
204 reversible: true,
205 parse(app, value) {
206 if (value === null || value === "" || value === "none") return ok(null);
207 if (typeof value !== "string") return err("assign takes a username, or none");
208 const user = getUserByName(app.db, value.replace(/^@/, ""));
209 return user ? ok(user.username) : err(`no account named ${value}`);
210 },
211 read: (t) => t.issue?.assigneeName ?? null,
212 next: replace,
213 refuse(app, t, after) {
214 if (after === null) return undefined;
215 const user = getUserByName(app.db, String(after));
216 if (!user) return "that account is gone";
217 if (user.kind === "org" || !canRead(app.db, t.repo, user))
218 return words("assignee_cannot_read");
219 if (user.kind === "agent" && !isCrew(app.db, t.repo.id, user.id))
220 return words("agent_not_on_crew");
221 return undefined;
222 },
223 async write(app, actor, t, after) {
224 if (!t.issue) return words("not_found");
225 const user = after === null ? null : getUserByName(app.db, String(after));
226 if (user === undefined) return "that account is gone";
227 return done(assignIssue(app, actor, t.repo, t.issue, user));
228 },
229 },
230
231 label: {
232 kind: "issue",
233 reversible: true,
234 parse: (_app, value) =>
235 typeof value === "string" && value.trim() !== "" && value.length <= 100
236 ? ok(value.trim())
237 : err("label takes a label's name"),
238 read: (t) => t.labels ?? [],
239 next: (before, value) => {
240 const names = Array.isArray(before) ? before.map(String) : [];
241 const name = String(value);
242 return names.some((n) => n.toLowerCase() === name.toLowerCase())
243 ? names
244 : sortedUnique([...names, name]);
245 },
246 refuse(app, t, after) {
247 if (isMirror(app, t.repo)) return words("mirrored");
248 const known = new Set((t.repoLabels ?? []).map((n) => n.toLowerCase()));
249 const missing = (Array.isArray(after) ? after : []).find(
250 (n) => !known.has(String(n).toLowerCase()),
251 );
252 return missing === undefined
253 ? undefined
254 : `no label named ${missing} in ${t.repo.ownerName}/${t.repo.name}`;
255 },
256 async write(app, actor, t, after) {
257 if (!t.issue || !Array.isArray(after)) return words("not_found");
258 return done(
259 setIssueLabels(app, actor, t.repo, t.issue, after.map(String)),
260 );
261 },
262 },
263
264 link: {
265 kind: "issue",
266 reversible: true,
267 parse: (_app, value) =>
268 typeof value === "number" && Number.isInteger(value) && value > 0
269 ? ok(value)
270 : err("link takes the number of the issue they wait for"),
271 read: (t) => t.blockers ?? [],
272 next: (before, value) =>
273 sortedUnique([...(Array.isArray(before) ? before : []), Number(value)]),
274 refuse(app, t, after) {
275 for (const n of Array.isArray(after) ? after : []) {
276 if (typeof n !== "number") return "not an issue number";
277 if (n === t.issue?.number) return dependencyWords.self;
278 const blocker = getIssue(app.db, t.repo.id, n);
279 if (!blocker) return `no #${n} in ${t.repo.ownerName}/${t.repo.name}`;
280 if (blocker.kind !== "issue") return dependencyWords.not_an_issue;
281 }
282 return undefined;
283 },
284 async write(app, actor, t, after) {
285 if (!t.issue || !Array.isArray(after)) return words("not_found");
286 const want = new Set(after.map(Number));
287 const have = new Set(t.blockers ?? []);
288 for (const n of want) {
289 if (have.has(n)) continue;
290 const blocker = getIssue(app.db, t.repo.id, n);
291 if (!blocker) return `no #${n} here`;
292 const why = done(linkIssue(app, actor, t.repo, t.issue, blocker));
293 if (why) return why;
294 }
295 for (const n of have) {
296 if (want.has(n)) continue;
297 const blocker = getIssue(app.db, t.repo.id, n);
298 if (!blocker) continue;
299 const why = done(unlinkIssue(app, actor, t.repo, t.issue, blocker));
300 if (why) return why;
301 }
302 return undefined;
303 },
304 },
305 };
src/http/api.ts +70
@@ -229,6 +229,14 @@ import {
229229 type SourceError,
230230 } from "../view/source.ts";
231231 import { eventStream } from "./sse.ts";
232 import {
233 applyFleetOp,
234 getFleetOp,
235 listFleetOps,
236 previewFleetOp,
237 undoFleetOp,
238 type FleetError,
239 } from "../actions/fleet.ts";
232240 import { sessionCookie } from "../web/pages/site.ts";
233241 import { cookieValue, crossOrigin, type Ctx, type Handler } from "./context.ts";
234242 import {
@@ -250,6 +258,17 @@ import type { Router } from "./router.ts";
250258
251259 // ---- shapes ----
252260
261 /** A fleet refusal: the access codes keep their fixed shapes. */
262 function respondFleet(error: FleetError): Response {
263 if (error.code === "not_found") return notFound();
264 if (error.code === "unauthorized") return unauthorized();
265 return apiError(
266 error.code,
267 error.code === "forbidden" ? 403 : error.code === "already_undone" ? 409 : 422,
268 error.message,
269 );
270 }
271
253272 function publicKey(key: UserKey) {
254273 return {
255274 id: key.id,
@@ -2631,6 +2650,57 @@ export function apiRoutes(router: Router<Handler>): void {
26312650 return json(synced.value);
26322651 });
26332652
2653 // ── fleet operations — one action over every row a query selects ─────
2654 //
2655 // A dry run (the default) answers with the plan and changes nothing; it
2656 // needs only a read credential. Applying and undoing change what an
2657 // admin changes, so they take an admin token — and every row is still
2658 // checked against the actor's admin of its repository.
2659
2660 router.add("POST", "/api/v1/fleet/ops", async (ctx) => {
2661 const input = await body(ctx.request);
2662 const dryRun = input.dryRun !== false;
2663 const denied = requireScope(ctx, dryRun ? "ro" : "admin");
2664 if (denied) return denied;
2665 const query = str(input.query);
2666 if (dryRun) {
2667 const plan = previewFleetOp(ctx.app, ctx.auth.user, query, input.action);
2668 if (!plan.ok) return respondFleet(plan.error);
2669 return json({ dryRun: true, ...plan.value });
2670 }
2671 const op = await applyFleetOp(ctx.app, ctx.auth.user, query, input.action);
2672 if (!op.ok) return respondFleet(op.error);
2673 return json({ dryRun: false, ...op.value }, 201);
2674 });
2675
2676 router.add("GET", "/api/v1/fleet/ops", (ctx) => {
2677 if (!ctx.auth.user) return unauthorized();
2678 const q = ctx.url.searchParams;
2679 const limit = pageLimit(q.has("limit") ? Number(q.get("limit")) : undefined);
2680 const ops = listFleetOps(
2681 ctx.app,
2682 ctx.auth.user,
2683 limit,
2684 q.get("cursor") ?? undefined,
2685 );
2686 if (!ops.ok) return respondFleet(ops.error);
2687 return json(paged(ops.value, limit, (op) => op.id));
2688 });
2689
2690 router.add("GET", "/api/v1/fleet/ops/:id", (ctx) => {
2691 const op = getFleetOp(ctx.app, ctx.auth.user, ctx.params.id!);
2692 if (!op.ok) return respondFleet(op.error);
2693 return json(op.value);
2694 });
2695
2696 router.add("POST", "/api/v1/fleet/ops/:id/undo", async (ctx) => {
2697 const denied = requireScope(ctx, "admin");
2698 if (denied) return denied;
2699 const op = await undoFleetOp(ctx.app, ctx.auth.user, ctx.params.id!);
2700 if (!op.ok) return respondFleet(op.error);
2701 return json(op.value, 201);
2702 });
2703
26342704 // ── pins ─────────────────────────────────────────────────────────────
26352705
26362706 // The local client clones from this list, so each pin carries what a
test/fleet-ops.test.ts added +363
@@ -0,0 +1,363 @@
1 /**
2 * Fleet operations: a query selects rows, a dry run plans each one before →
3 * after and changes nothing, applying writes through the platform's own
4 * actions and keeps a receipt, and undo puts back only what still holds
5 * the value the operation left. The adversarial half: rows outside the
6 * actor's admin are refused and named, rows they cannot read are absent,
7 * a receipt is its actor's, and a read token cannot apply.
8 */
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { createApp, type App } from "../src/app.ts";
14 import { addToCrew, createAgent } from "../src/actions/crew.ts";
15 import {
16 applyFleetOp,
17 previewFleetOp,
18 undoFleetOp,
19 } from "../src/actions/fleet.ts";
20 import { assignIssue, openIssue } from "../src/actions/issues.ts";
21 import {
22 createRepo,
23 createLabel,
24 updateRepoSettings,
25 } from "../src/actions/repos.ts";
26 import { dependenciesOf } from "../src/core/dependencies.ts";
27 import { eventAudience } from "../src/core/event_audience.ts";
28 import { listEvents } from "../src/core/events.ts";
29 import { createIssue, getIssue, labelsFor } from "../src/core/issues.ts";
30 import { getRepoById, type Repo } from "../src/core/repos.ts";
31 import { cancelRun, createRun, getRun, listRuns } from "../src/core/runs.ts";
32 import { createToken } from "../src/core/tokens.ts";
33 import { createUser, type User } from "../src/core/users.ts";
34 import { parseQuery } from "../src/fleet/selection.ts";
35 import { serve, type Servers } from "../src/http/server.ts";
36
37 describe("fleet operations", () => {
38 let dataDir: string;
39 let app: App;
40 let servers: Servers;
41 let alice: User;
42 let bob: User;
43 let root: User;
44 let engine: Repo;
45 let tools: Repo;
46 let bobsPublic: Repo;
47 let bobsPrivate: Repo;
48
49 const person = async (username: string, adminIfFirst = false) => {
50 const made = await createUser(app.db, {
51 username,
52 email: `${username}@forge.local`,
53 password: "a-good-passphrase",
54 adminIfFirst,
55 });
56 if (!made.ok) throw new Error(made.error);
57 return made.value;
58 };
59 const repoOf = async (owner: User, name: string, visibility: "public" | "private") => {
60 const made = await createRepo(app, owner, { name, visibility });
61 if (!made.ok) throw new Error(String(made.error));
62 return made.value;
63 };
64 const fresh = (repo: Repo) => getRepoById(app.db, repo.id)!;
65 const call = (token: string, path: string, body?: unknown) =>
66 fetch(`${servers.url}${path}`, {
67 method: body === undefined ? "GET" : "POST",
68 headers: {
69 authorization: `Bearer ${token}`,
70 "content-type": "application/json",
71 },
72 ...(body === undefined ? {} : { body: JSON.stringify(body) }),
73 });
74
75 beforeAll(async () => {
76 dataDir = await mkdtemp(join(tmpdir(), "forge-fleet-"));
77 app = createApp({ dataDir, port: 0 });
78 servers = await serve(app);
79 // The first account is the site admin; alice and bob are not.
80 root = await person("root", true);
81 alice = await person("alice");
82 bob = await person("bob");
83 engine = await repoOf(alice, "engine", "private");
84 tools = await repoOf(alice, "tools", "public");
85 bobsPublic = await repoOf(bob, "garden", "public");
86 bobsPrivate = await repoOf(bob, "diary", "private");
87 for (const repo of [engine, tools])
88 await updateRepoSettings(app, alice, repo, {
89 autonomy: "assisted",
90 autonomyBy: alice.id,
91 });
92 for (const repo of [bobsPublic, bobsPrivate])
93 await updateRepoSettings(app, bob, repo, {
94 autonomy: "assisted",
95 autonomyBy: bob.id,
96 });
97 }, 30_000);
98
99 afterAll(async () => {
100 servers.stop();
101 app.close();
102 await rm(dataDir, { recursive: true, force: true });
103 });
104
105 test("the query line names a kind and field:value terms, and says what is wrong", () => {
106 const q = parseQuery("repo autonomy:assisted repo:!alice/tools");
107 expect(q.ok && q.value).toEqual({
108 kind: "repo",
109 terms: [
110 { field: "autonomy", value: "assisted", negate: false },
111 { field: "repo", value: "alice/tools", negate: true },
112 ],
113 });
114 for (const bad of ["", "pulls", "repo autonomy", "repo spent:4", "run label:x"]) {
115 const parsed = parseQuery(bad);
116 expect(parsed.ok).toBe(false);
117 if (!parsed.ok) expect(parsed.error.code).toBe("bad_query");
118 }
119 });
120
121 test("a dry run plans every row before → after, refuses what is not yours, hides what you cannot see, and changes nothing", () => {
122 const plan = previewFleetOp(app, alice, "repo autonomy:assisted", {
123 verb: "autonomy",
124 value: "off",
125 });
126 expect(plan.ok).toBe(true);
127 if (!plan.ok) return;
128 const rows = Object.fromEntries(plan.value.rows.map((r) => [r.label, r]));
129 expect(Object.keys(rows).sort()).toEqual([
130 "alice/engine",
131 "alice/tools",
132 "bob/garden",
133 ]);
134 expect(rows["alice/engine"]).toMatchObject({
135 before: "assisted",
136 after: "off",
137 outcome: "change",
138 });
139 expect(rows["bob/garden"]).toMatchObject({
140 outcome: "refused",
141 reason: "you are not an admin of bob/garden",
142 });
143 expect(plan.value.counts).toEqual({ change: 2, unchanged: 0, refused: 1 });
144 expect(plan.value.reversible).toBe(true);
145 expect(fresh(engine).autonomy).toBe("assisted");
146 });
147
148 test("an action is checked against the kind and its value at the boundary", () => {
149 const wrongKind = previewFleetOp(app, alice, "repo", "cancel");
150 expect(!wrongKind.ok && wrongKind.error.code).toBe("bad_action");
151 const badSlots = previewFleetOp(app, alice, "repo", { verb: "slots", value: 99 });
152 expect(!badSlots.ok && badSlots.error.code).toBe("bad_action");
153 const badMode = previewFleetOp(app, alice, "repo", { verb: "autonomy", value: "yolo" });
154 expect(!badMode.ok && badMode.error.code).toBe("bad_action");
155 const proto = previewFleetOp(app, alice, "repo", { verb: "__proto__" });
156 expect(!proto.ok && proto.error.code).toBe("bad_action");
157 });
158
159 test("apply writes each row, keeps the receipt and an event, and undo puts it back", async () => {
160 const applied = await applyFleetOp(app, alice, "repo autonomy:assisted", {
161 verb: "autonomy",
162 value: "off",
163 });
164 expect(applied.ok).toBe(true);
165 if (!applied.ok) return;
166 const op = applied.value;
167 expect(op.rows.map((r) => [r.label, r.outcome])).toEqual([
168 ["alice/engine", "ok"],
169 ["alice/tools", "ok"],
170 ["bob/garden", "refused"],
171 ]);
172 expect(fresh(engine).autonomy).toBe("off");
173 expect(fresh(engine).autonomyBy).toBeNull();
174 expect(fresh(bobsPublic).autonomy).toBe("assisted");
175 const event = listEvents(app.db, { kind: "fleet.operated" }).find(
176 (e) => e.kind === "fleet.operated" && e.payload.operation === op.id,
177 );
178 expect(event?.payload).toMatchObject({ ok: 2, refused: 1, failed: 0 });
179 // It names repositories across the forge: the event is its actor's alone.
180 expect(eventAudience("fleet.operated")).toBe("actor");
181
182 const undone = await undoFleetOp(app, alice, op.id);
183 expect(undone.ok).toBe(true);
184 if (!undone.ok) return;
185 expect(undone.value.undoOf).toBe(op.id);
186 expect(undone.value.rows.map((r) => [r.label, r.before, r.after, r.outcome])).toEqual([
187 ["alice/engine", "off", "assisted", "ok"],
188 ["alice/tools", "off", "assisted", "ok"],
189 ]);
190 expect(fresh(engine).autonomy).toBe("assisted");
191 expect(fresh(engine).autonomyBy).toBe(alice.id);
192
193 const twice = await undoFleetOp(app, alice, op.id);
194 expect(!twice.ok && twice.error.code).toBe("already_undone");
195 });
196
197 test("undo leaves alone a row someone changed since, and says so", async () => {
198 const applied = await applyFleetOp(app, alice, "repo owner:alice", {
199 verb: "slots",
200 value: 4,
201 });
202 if (!applied.ok) throw new Error(applied.error.message);
203 await updateRepoSettings(app, alice, fresh(tools), { autonomySlots: 6 });
204 const undone = await undoFleetOp(app, alice, applied.value.id);
205 if (!undone.ok) throw new Error(undone.error.message);
206 const rows = Object.fromEntries(undone.value.rows.map((r) => [r.label, r]));
207 expect(rows["alice/engine"]?.outcome).toBe("ok");
208 expect(rows["alice/tools"]).toMatchObject({
209 outcome: "refused",
210 reason: "changed since: it is 6 now",
211 });
212 expect(fresh(engine).autonomySlots).toBe(1);
213 expect(fresh(tools).autonomySlots).toBe(6);
214 });
215
216 test("issues: close, label, assign and link, each undone exactly", async () => {
217 const opened = [1, 2, 3].map((n) => {
218 const made = openIssue(app, alice, engine, { title: `Task ${n}` });
219 if (!made.ok) throw new Error(String(made.error));
220 return made.value;
221 });
222 createLabel(app, alice, engine, "urgent", "ff0000");
223 const q = "issue repo:alice/engine state:open number:!3";
224
225 const label = await applyFleetOp(app, alice, q, { verb: "label", value: "urgent" });
226 if (!label.ok) throw new Error(label.error.message);
227 expect(label.value.rows.map((r) => r.after)).toEqual([["urgent"], ["urgent"]]);
228 expect(labelsFor(app.db, opened[0]!.id).map((l) => l.name)).toEqual(["urgent"]);
229
230 const missing = previewFleetOp(app, alice, q, { verb: "label", value: "nope" });
231 expect(missing.ok && missing.value.rows[0]?.reason).toBe(
232 "no label named nope in alice/engine",
233 );
234
235 const link = await applyFleetOp(app, alice, q, { verb: "link", value: 3 });
236 if (!link.ok) throw new Error(link.error.message);
237 expect(dependenciesOf(app.db, opened[0]!.id).blockedBy.map((i) => i.number)).toEqual([3]);
238
239 const assign = await applyFleetOp(app, alice, q, { verb: "assign", value: "alice" });
240 if (!assign.ok) throw new Error(assign.error.message);
241 expect(getIssue(app.db, engine.id, 1)?.assigneeName).toBe("alice");
242 // bob cannot read alice's private engine: assigning him is refused per row.
243 const toBob = previewFleetOp(app, alice, q, { verb: "assign", value: "bob" });
244 expect(toBob.ok && toBob.value.counts.refused).toBe(2);
245
246 const close = await applyFleetOp(app, alice, q, "close");
247 if (!close.ok) throw new Error(close.error.message);
248 expect(getIssue(app.db, engine.id, 1)?.state).toBe("closed");
249
250 for (const op of [close, assign, link, label]) {
251 if (!op.ok) continue;
252 const undone = await undoFleetOp(app, alice, op.value.id);
253 expect(undone.ok && undone.value.rows.every((r) => r.outcome === "ok")).toBe(true);
254 }
255 expect(getIssue(app.db, engine.id, 1)?.state).toBe("open");
256 expect(getIssue(app.db, engine.id, 1)?.assigneeName).toBeNull();
257 expect(dependenciesOf(app.db, opened[0]!.id).blockedBy).toEqual([]);
258 expect(labelsFor(app.db, opened[0]!.id)).toEqual([]);
259 });
260
261 test("runs: cancel is final, requeue puts an agent's task back on the queue", async () => {
262 const agent = await createAgent(app, root, { username: "helper", model: "", brief: "" });
263 if (!agent.ok) throw new Error(String(agent.error));
264 addToCrew(app, root, tools, "helper");
265 const issue = openIssue(app, alice, tools, { title: "Runs for the fleet" });
266 if (!issue.ok) throw new Error(String(issue.error));
267 assignIssue(app, alice, tools, issue.value, agent.value.user);
268 const queued = createRun(app.db, {
269 repoId: tools.id,
270 kind: "agent",
271 issueId: issue.value.id,
272 actorId: agent.value.user.id,
273 reason: "assigned",
274 });
275 const deploy = createRun(app.db, { repoId: tools.id, kind: "deploy" });
276 cancelRun(app.db, deploy.id);
277
278 const cancel = await applyFleetOp(app, alice, "run repo:alice/tools state:queued", "cancel");
279 if (!cancel.ok) throw new Error(cancel.error.message);
280 expect(cancel.value.reversible).toBe(false);
281 expect(cancel.value.rows.map((r) => r.targetId)).toContain(queued.id);
282 expect(getRun(app.db, queued.id)?.state).toBe("cancelled");
283 const undo = await undoFleetOp(app, alice, cancel.value.id);
284 expect(!undo.ok && undo.error.code).toBe("not_reversible");
285
286 const requeue = await applyFleetOp(
287 app,
288 alice,
289 "run repo:alice/tools state:cancelled",
290 "requeue",
291 );
292 if (!requeue.ok) throw new Error(requeue.error.message);
293 const byId = Object.fromEntries(requeue.value.rows.map((r) => [r.targetId, r]));
294 expect(byId[queued.id]?.outcome).toBe("ok");
295 expect(byId[deploy.id]).toMatchObject({
296 outcome: "refused",
297 reason: "only an agent run is queued again",
298 });
299 const again = listRuns(app.db, tools.id, { kind: "agent" }).filter(
300 (r) => r.state === "queued" && r.issueId === issue.value.id,
301 );
302 expect(again.length).toBe(1);
303 });
304
305 test("an agent may look but never apply", async () => {
306 const agent = await createAgent(app, root, { username: "looker", model: "", brief: "" });
307 if (!agent.ok) throw new Error(String(agent.error));
308 const looked = previewFleetOp(app, agent.value.user, "repo", { verb: "slots", value: 2 });
309 expect(looked.ok).toBe(true);
310 const applied = await applyFleetOp(app, agent.value.user, "repo", { verb: "slots", value: 2 });
311 expect(!applied.ok && applied.error.code).toBe("forbidden");
312 });
313
314 test("more rows than an operation takes is refused, not cut", () => {
315 for (let n = 0; n < 501; n++)
316 createIssue(app.db, { repoId: tools.id, authorId: alice.id, title: `bulk ${n}` });
317 const plan = previewFleetOp(app, alice, "issue repo:alice/tools", "close");
318 expect(!plan.ok && plan.error.code).toBe("too_many");
319 });
320
321 test("over HTTP: a dry run by default, a read token cannot apply, and a receipt is its actor's", async () => {
322 const ro = createToken(app.db, alice.id, "ro", "ro").token;
323 const rw = createToken(app.db, alice.id, "rw", "rw").token;
324 const admin = createToken(app.db, alice.id, "admin", "admin").token;
325 const bobs = createToken(app.db, bob.id, "bob", "admin").token;
326 const action = { verb: "ceiling", value: 7.5 };
327 const query = "repo owner:alice";
328
329 const dry = await call(ro, "/api/v1/fleet/ops", { query, action });
330 expect(dry.status).toBe(200);
331 const plan = (await dry.json()) as { dryRun: boolean; rows: unknown[] };
332 expect(plan.dryRun).toBe(true);
333 expect(plan.rows.length).toBe(2);
334 expect(fresh(engine).autonomyCeilingUsd).toBe(20);
335
336 for (const token of [ro, rw]) {
337 const refused = await call(token, "/api/v1/fleet/ops", { query, action, dryRun: false });
338 expect(refused.status).toBe(403);
339 }
340 const applied = await call(admin, "/api/v1/fleet/ops", { query, action, dryRun: false });
341 expect(applied.status).toBe(201);
342 const op = (await applied.json()) as { id: string; dryRun: boolean };
343 expect(op.dryRun).toBe(false);
344 expect(fresh(engine).autonomyCeilingUsd).toBe(7.5);
345
346 expect((await call(ro, `/api/v1/fleet/ops/${op.id}`)).status).toBe(200);
347 expect((await call(bobs, `/api/v1/fleet/ops/${op.id}`)).status).toBe(404);
348 expect((await call(bobs, `/api/v1/fleet/ops/${op.id}/undo`, {})).status).toBe(404);
349 const list = (await (await call(ro, "/api/v1/fleet/ops")).json()) as {
350 items: { id: string }[];
351 };
352 expect(list.items[0]?.id).toBe(op.id);
353
354 const undo = await call(admin, `/api/v1/fleet/ops/${op.id}/undo`, {});
355 expect(undo.status).toBe(201);
356 expect(fresh(engine).autonomyCeilingUsd).toBe(20);
357 expect((await call(admin, `/api/v1/fleet/ops/${op.id}/undo`, {})).status).toBe(409);
358
359 const bad = await call(ro, "/api/v1/fleet/ops", { query: "nonsense", action });
360 expect(bad.status).toBe(422);
361 expect(((await bad.json()) as { error: string }).error).toBe("bad_query");
362 });
363 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs