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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/447-fleet-operations-with-dry-run-preview-an → main 4 commits fork
15 files
changed +1753
−5
CLAUDE.md +5
| @@ -157,6 +157,11 @@ src/ | ||
| 157 | 157 | proxy (Host routing, signed grants for private apps), scaffold |
| 158 | 158 | (the app a new app starts as), tty (`ssh <slug>@forge`: the |
| 159 | 159 | 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 | |
| 160 | 165 | runs/ log (one file per run, appended live, tailed by listeners) |
| 161 | 166 | crew/ agents as child processes: brief (the system + task prompts: |
| 162 | 167 | the golden path, written down), claude (the headless adapter: |
README.md +3
| @@ -331,6 +331,9 @@ crosses. | ||
| 331 | 331 | `docs/FOLLOWING.md` is a fork that keeps up with its parent: where it |
| 332 | 332 | stands, taking updates, and the crew's part when both sides moved. |
| 333 | 333 | |
| 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 | ||
| 334 | 337 | `docs/MCP.md` is the forge as a connector, and `docs/STANDARDS.md` is what |
| 335 | 338 | it answers by default: `.well-known`, feeds, cards, the manifest. |
| 336 | 339 |
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 /api/v1/fleet/ops -H "authorization: Bearer " \ | |
| 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 /api/v1/fleet/ops -H "authorization: Bearer " \ | |
| 13 | -d '{"query":"repo autonomy:autonomous","action":{"verb":"autonomy","value":"off"},"dryRun":false}' | |
| 14 | ||
| 15 | # undo it, once | |
| 16 | curl -sX POST /api/v1/fleet/ops//undo -H "authorization: Bearer " | |
| 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 @@ | ||
| 6 | 6 | "ready": 90 |
| 7 | 7 | }, |
| 8 | 8 | "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", | |
| 10 | 10 | "timeout": 1800 |
| 11 | 11 | } |
| 12 | 12 | } |
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 @@ | ||
| 1 | 1 | /** |
| 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 | |
| 4 | 4 | * outbox. Reading follows the repository's visibility (a run's log is as |
| 5 | 5 | * private as the code it ran); cancelling takes write access; announcing |
| 6 | 6 | * is the system's own voice and takes no actor. |
| @@ -20,6 +20,7 @@ import { | ||
| 20 | 20 | } from "../core/runs.ts"; |
| 21 | 21 | import type { User } from "../core/users.ts"; |
| 22 | 22 | import { abortAgentRun } from "../crew/running.ts"; |
| 23 | import { queueAgentRun, taskOf } from "../crew/dispatch.ts"; | |
| 23 | 24 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 24 | 25 | import { readRunLog, runLogPath } from "../runs/log.ts"; |
| 25 | 26 | import { requireReader, requireWriter, type AccessError } from "./access.ts"; |
| @@ -134,6 +135,42 @@ export function cancelRepoRun( | ||
| 134 | 135 | return ok(cancelled); |
| 135 | 136 | } |
| 136 | 137 | |
| 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 | ||
| 137 | 174 | export type SteerOutcome = |
| 138 | 175 | /** The agent hears it at its next step. */ |
| 139 | 176 | | "sent" |
| @@ -155,6 +192,8 @@ export function steerRepoRun( | ||
| 155 | 192 | ): Result<SteerOutcome, AccessError | "empty" | "not_agent"> { |
| 156 | 193 | const writer = requireWriter(app, actor, repo); |
| 157 | 194 | 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"); | |
| 158 | 197 | const run = getRun(app.db, runId); |
| 159 | 198 | if (!run || run.repoId !== repo.id) return err("not_found"); |
| 160 | 199 | if (run.kind !== "agent") return err("not_agent"); |
src/core/dependencies.ts +29
| @@ -84,6 +84,35 @@ function blockerIds(db: Database, issueId: string): string[] { | ||
| 84 | 84 | .map((r) => r.blocked_by_id); |
| 85 | 85 | } |
| 86 | 86 | |
| 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 | ||
| 87 | 116 | /** What this issue waits for, and what waits for it. */ |
| 88 | 117 | export function dependenciesOf( |
| 89 | 118 | db: Database, |
src/core/event_audience.ts +3 −1
| @@ -31,7 +31,9 @@ export function eventAudience(kind: string): EventAudience { | ||
| 31 | 31 | kind === "repo.settings_updated" |
| 32 | 32 | ) |
| 33 | 33 | 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"; | |
| 35 | 37 | return "readers"; |
| 36 | 38 | } |
| 37 | 39 |
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[] = [ | ||
| 1048 | 1048 | ALTER TABLE run ADD COLUMN sandbox_enforced INTEGER; |
| 1049 | 1049 | ALTER TABLE run ADD COLUMN sandbox_note TEXT NOT NULL DEFAULT ''; |
| 1050 | 1050 | `, |
| 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 | `, | |
| 1051 | 1084 | ]; |
src/crew/dispatch.ts +1 −1
| @@ -820,7 +820,7 @@ function resumeLater(app: App, run: Run, pause: Pause): void { | ||
| 820 | 820 | } |
| 821 | 821 | |
| 822 | 822 | /** The still-open task a run was about, and the reason to queue it under again. */ |
| 823 | function taskOf( | |
| 823 | export function taskOf( | |
| 824 | 824 | app: App, |
| 825 | 825 | run: Run, |
| 826 | 826 | ): { 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 { | ||
| 229 | 229 | type SourceError, |
| 230 | 230 | } from "../view/source.ts"; |
| 231 | 231 | 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"; | |
| 232 | 240 | import { sessionCookie } from "../web/pages/site.ts"; |
| 233 | 241 | import { cookieValue, crossOrigin, type Ctx, type Handler } from "./context.ts"; |
| 234 | 242 | import { |
| @@ -250,6 +258,17 @@ import type { Router } from "./router.ts"; | ||
| 250 | 258 | |
| 251 | 259 | // ---- shapes ---- |
| 252 | 260 | |
| 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 | ||
| 253 | 272 | function publicKey(key: UserKey) { |
| 254 | 273 | return { |
| 255 | 274 | id: key.id, |
| @@ -2631,6 +2650,57 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2631 | 2650 | return json(synced.value); |
| 2632 | 2651 | }); |
| 2633 | 2652 | |
| 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 | ||
| 2634 | 2704 | // ── pins ───────────────────────────────────────────────────────────── |
| 2635 | 2705 | |
| 2636 | 2706 | // 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 | }); |
new above
offline