Public: anyone can read this; the crew can change it.
Fleet queries grow comparisons and age: `cost:>0.5`, `created:<7d`, `state:queued,running` #10137
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/566-fleet-queries-grow-comparisons-and-age-c → main 3 commits fork 1 thread · 1 unresolved
8 files
changed +351
−36
docs/features/fleet.md +14 −7
| @@ -14,19 +14,26 @@ Before a fleet operation acts, see the rows its query line selects. There are tw | ||
| 14 | 14 | - `/` writes a new line. `s` sorts by the next column and `r` reverses the sort. `j` and `k` scroll, and `esc` goes back. |
| 15 | 15 | - A line that does not parse keeps the prompt open with its reason. |
| 16 | 16 | |
| 17 | **The query line:** a kind, then `field:value` terms that must all hold. A value starting with `!` negates its term. | |
| 17 | **The query line:** a kind, then `field:value` terms that must all hold. Both doors show the same grammar under the line: one sentence, then each kind with its fields (`GRAMMAR` and `fieldsOf` in `src/fleet/selection.ts`). | |
| 18 | ||
| 19 | - A value starting with `!` negates its term: `repo:!trevato/forge`. | |
| 20 | - A comma means any of: `state:queued,running`. | |
| 21 | - `cost` and `number` take a number, and may compare it with `>`, `<`, `>=` or `<=`: `cost:>0.5`. | |
| 22 | - `created` and `updated` take an age: a whole number of minutes, hours, days or weeks (`30m`, `12h`, `7d`, `2w`). `created:<7d` is younger than seven days, `created:>7d` older, and a bare `created:7d` means within seven days. | |
| 23 | - A term that does not fit its field is refused, naming the field and what it wanted: "cost wants a number", "created wants an age like 30m, 12h, 7d or 2w", "state takes no comparison; only a value or values". Nothing is selected. | |
| 24 | - `issue number:` now refuses text it used to accept. `number:abc` once selected nothing without a word, and now it says "number wants a number". | |
| 18 | 25 | |
| 19 | 26 | | Kind | Fields | Columns | |
| 20 | 27 | | --- | --- | --- | |
| 21 | | `repo` | `owner`, `name`, `repo`, `autonomy`, `visibility` | repository, autonomy, visibility, app | | |
| 22 | | `run` | `state`, `kind`, `agent`, `issue`, `repo` | started, repository, kind, agent, state, cost | | |
| 23 | | `issue` | `number`, `state`, `assignee` (`none` for unassigned), `label`, `limb`, `repo` | repository, #, title, state, assignee | | |
| 28 | | `repo` | `owner`, `name`, `repo`, `autonomy`, `visibility`, `created` | repository, autonomy, visibility, app | | |
| 29 | | `run` | `state`, `kind`, `agent`, `issue`, `repo`, `cost`, `created` | started, repository, kind, agent, state, cost | | |
| 30 | | `issue` | `number`, `state`, `assignee` (`none` for unassigned), `label`, `limb`, `repo`, `created`, `updated` | repository, #, title, state, assignee | | |
| 24 | 31 | |
| 25 | Every kind also has `id`, and `repo` and `issue` share `owner` and `name`. Some examples: | |
| 32 | Every kind also has `id`, and all three share `owner` and `name`. Some examples: | |
| 26 | 33 | |
| 27 | 34 | - `repo autonomy:autonomous` |
| 28 | - `run state:queued repo:!trevato/forge` | |
| 29 | - `issue repo:engine label:bug assignee:none` | |
| 35 | - `run state:queued,running cost:>0.5 repo:!trevato/forge` | |
| 36 | - `issue repo:engine label:bug assignee:none created:<7d` | |
| 30 | 37 | |
| 31 | 38 | **Shared code and rules:** |
| 32 | 39 |
src/fleet/selection.ts +117 −24
| @@ -11,6 +11,15 @@ | ||
| 11 | 11 | * Only rows the viewer may read are ever selected — a repository you cannot |
| 12 | 12 | * see is not refused, it is absent, the same ambiguity `not_found` keeps. |
| 13 | 13 | * |
| 14 | * Comparisons: a numeric field (`cost`, `number`) takes `>`, `<`, `>=` or | |
| 15 | * `<=` before a number — `run cost:>0.5`. A time field (`created`, | |
| 16 | * `updated`) takes a relative age, `30m`, `12h`, `7d`, `2w`: `created:<7d` | |
| 17 | * is younger than seven days, `created:>7d` older, and a bare `created:7d` | |
| 18 | * means within. A comma means any of: `run state:queued,running`. A term | |
| 19 | * that does not fit its field is refused naming the field and what it | |
| 20 | * wanted ("cost wants a number"), and nothing is selected. Every value | |
| 21 | * reaches SQL as a bound parameter, never as text in the statement. | |
| 22 | * | |
| 14 | 23 | * `limb:<number>` on issues selects a limb (`core/milestones.ts`): the |
| 15 | 24 | * issue of that number and everything beneath it that only it needs, top |
| 16 | 25 | * down — never what other work outside the limb also waits for. It needs |
| @@ -33,10 +42,16 @@ export const MAX_ROWS = 500; | ||
| 33 | 42 | const MAX_QUERY = 500; |
| 34 | 43 | const MAX_TERMS = 20; |
| 35 | 44 | |
| 45 | export type Op = "=" | ">" | "<" | ">=" | "<="; | |
| 46 | ||
| 36 | 47 | export interface Term { |
| 37 | 48 | readonly field: string; |
| 49 | /** The value as written, after `!` and the comparison. */ | |
| 38 | 50 | readonly value: string; |
| 39 | 51 | readonly negate: boolean; |
| 52 | readonly op: Op; | |
| 53 | /** `value` split on commas: the term holds when any of them does. */ | |
| 54 | readonly values: readonly string[]; | |
| 40 | 55 | } |
| 41 | 56 | |
| 42 | 57 | export interface FleetQuery { |
| @@ -69,6 +84,25 @@ const repoFields: Record<string, Predicate> = { | ||
| 69 | 84 | name: (p) => `repo.name = ${p} COLLATE NOCASE`, |
| 70 | 85 | }; |
| 71 | 86 | |
| 87 | /** A column compared with `op`: numbers bound as numbers, ages as instants. */ | |
| 88 | type Compared = { readonly type: "number" | "time"; readonly column: string }; | |
| 89 | ||
| 90 | const created = (table: string): Compared => ({ type: "time", column: `${table}.created_at` }); | |
| 91 | ||
| 92 | const COMPARED: Record<FleetKind, Record<string, Compared>> = { | |
| 93 | repo: { created: created("repo") }, | |
| 94 | run: { cost: { type: "number", column: "run.cost_usd" }, created: created("run") }, | |
| 95 | issue: { | |
| 96 | number: { type: "number", column: "issue.number" }, | |
| 97 | created: created("issue"), | |
| 98 | updated: { type: "time", column: "issue.updated_at" }, | |
| 99 | }, | |
| 100 | }; | |
| 101 | ||
| 102 | const AGE_UNITS: Record<string, number> = { m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 }; | |
| 103 | const NUMBER = /^-?\d+(\.\d+)?$/; | |
| 104 | const AGE = /^(\d{1,6})([mhdw])$/; | |
| 105 | ||
| 72 | 106 | const FIELDS: Record<FleetKind, Record<string, Predicate>> = { |
| 73 | 107 | repo: { |
| 74 | 108 | ...repoFields, |
| @@ -90,7 +124,6 @@ const FIELDS: Record<FleetKind, Record<string, Predicate>> = { | ||
| 90 | 124 | issue: { |
| 91 | 125 | ...repoFields, |
| 92 | 126 | id: (p) => `issue.id = ${p}`, |
| 93 | number: (p) => `issue.number = CAST(${p} AS INTEGER)`, | |
| 94 | 127 | state: (p) => `issue.state = ${p} COLLATE NOCASE`, |
| 95 | 128 | assignee: (p) => |
| 96 | 129 | `(CASE WHEN ${p} = 'none' THEN issue.assignee_id IS NULL |
| @@ -114,7 +147,7 @@ function limbIds( | ||
| 114 | 147 | term: Term, |
| 115 | 148 | ): Result<string[], QueryError> { |
| 116 | 149 | const named = query.terms.filter( |
| 117 | (t) => t.field === "repo" && !t.negate && t.value.includes("/"), | |
| 150 | (t) => t.field === "repo" && !t.negate && t.values.length === 1 && t.value.includes("/"), | |
| 118 | 151 | ); |
| 119 | 152 | if (named.length !== 1) |
| 120 | 153 | return bad("limb:<number> needs the repository: repo:owner/name"); |
| @@ -163,22 +196,77 @@ export function parseQuery(text: string): Result<FleetQuery, QueryError> { | ||
| 163 | 196 | if (rest.length > MAX_TERMS) return bad(`a query has at most ${MAX_TERMS} terms`); |
| 164 | 197 | const terms: Term[] = []; |
| 165 | 198 | for (const word of rest) { |
| 166 | const colon = word.indexOf(":"); | |
| 167 | const field = colon > 0 ? word.slice(0, colon).toLowerCase() : ""; | |
| 168 | let value = colon > 0 ? word.slice(colon + 1) : ""; | |
| 169 | const negate = value.startsWith("!"); | |
| 170 | if (negate) value = value.slice(1); | |
| 171 | if (field === "" || value === "") | |
| 172 | return bad(`"${word}" is not field:value`); | |
| 173 | if (field !== "repo" && FIELDS[kind][field] === undefined) | |
| 174 | return bad( | |
| 175 | `${kind} has no field "${field}"; it has repo, ${Object.keys(FIELDS[kind]).join(", ")}`, | |
| 176 | ); | |
| 177 | terms.push({ field, value, negate }); | |
| 199 | const term = parseTerm(kind, word); | |
| 200 | if (!term.ok) return term; | |
| 201 | terms.push(term.value); | |
| 178 | 202 | } |
| 179 | 203 | return ok({ kind, terms }); |
| 180 | 204 | } |
| 181 | 205 | |
| 206 | /** Every field a kind's query may name, for errors and for the grammar both doors show. */ | |
| 207 | export function fieldsOf(kind: FleetKind): string[] { | |
| 208 | return ["repo", ...Object.keys(FIELDS[kind]), ...Object.keys(COMPARED[kind])]; | |
| 209 | } | |
| 210 | ||
| 211 | /** The kinds a query line starts with, in the order the grammar lists them. */ | |
| 212 | export const FLEET_KINDS: readonly FleetKind[] = ["repo", "run", "issue"]; | |
| 213 | ||
| 214 | /** The grammar in one sentence, the same on the web page and the terminal screen. */ | |
| 215 | export const GRAMMAR = | |
| 216 | "A kind, then field:value terms that all hold; ! negates, a comma means any of, and cost, number or an age (30m, 12h, 7d, 2w) can be more or less with > or <."; | |
| 217 | ||
| 218 | /** Two lines that show the grammar at work. */ | |
| 219 | export const GRAMMAR_EXAMPLES: readonly string[] = [ | |
| 220 | "run state:queued,running cost:>0.5", | |
| 221 | "issue created:<7d assignee:none", | |
| 222 | ]; | |
| 223 | ||
| 224 | /** One `field:[!][op]value[,value…]` word, checked against what its field takes. */ | |
| 225 | function parseTerm(kind: FleetKind, word: string): Result<Term, QueryError> { | |
| 226 | const colon = word.indexOf(":"); | |
| 227 | const field = colon > 0 ? word.slice(0, colon).toLowerCase() : ""; | |
| 228 | let value = colon > 0 ? word.slice(colon + 1) : ""; | |
| 229 | const negate = value.startsWith("!"); | |
| 230 | if (negate) value = value.slice(1); | |
| 231 | const op = (/^[<>]=?/.exec(value)?.[0] ?? "=") as Op; | |
| 232 | value = value.slice(op === "=" ? 0 : op.length); | |
| 233 | if (field === "" || value === "") return bad(`"${word}" is not field:value`); | |
| 234 | const compared = COMPARED[kind][field]; | |
| 235 | if (field !== "repo" && FIELDS[kind][field] === undefined && compared === undefined) | |
| 236 | return bad( | |
| 237 | `${kind} has no field "${field}"; it has ${fieldsOf(kind).join(", ")}`, | |
| 238 | ); | |
| 239 | const values = value.split(","); | |
| 240 | if (values.some((v) => v === "")) return bad(`${field} has an empty value in "${value}"`); | |
| 241 | if (compared === undefined) { | |
| 242 | if (op !== "=") return bad(`${field} takes no comparison; only a value or values`); | |
| 243 | if (field === "limb" && values.length > 1) return bad("limb takes the number of one issue"); | |
| 244 | } else { | |
| 245 | if (op !== "=" && values.length > 1) return bad(`${field} compares with one value, not a list`); | |
| 246 | if (compared.type === "number" && !values.every((v) => NUMBER.test(v))) | |
| 247 | return bad(`${field} wants a number`); | |
| 248 | if (compared.type === "time" && !values.every((v) => AGE.test(v))) | |
| 249 | return bad(`${field} wants an age like 30m, 12h, 7d or 2w`); | |
| 250 | } | |
| 251 | return ok({ field, value, negate, op, values }); | |
| 252 | } | |
| 253 | ||
| 254 | /** `created:<7d` is younger than 7 days: a later instant, so the comparison flips. */ | |
| 255 | const FLIP: Record<Op, Op> = { "=": ">=", "<": ">", "<=": ">=", ">": "<", ">=": "<=" }; | |
| 256 | ||
| 257 | function comparedSql( | |
| 258 | compared: Compared, | |
| 259 | op: Op, | |
| 260 | value: string, | |
| 261 | bind: (value: string | number) => string, | |
| 262 | ): string { | |
| 263 | if (compared.type === "number") | |
| 264 | return `${compared.column} ${op} ${bind(Number(value))}`; | |
| 265 | const [, n, unit] = AGE.exec(value)!; | |
| 266 | const at = new Date(Date.now() - Number(n) * AGE_UNITS[unit!]!).toISOString(); | |
| 267 | return `${compared.column} ${FLIP[op]} ${bind(at)}`; | |
| 268 | } | |
| 269 | ||
| 182 | 270 | /** |
| 183 | 271 | * The ids a query selects for `viewer` (undefined: a signed-out visitor), |
| 184 | 272 | * in a stable order. More than |
| @@ -192,7 +280,7 @@ export function selectRows( | ||
| 192 | 280 | ): Result<string[], QueryError> { |
| 193 | 281 | // Signed out, `?1` is an id no account has: only public repositories hold. |
| 194 | 282 | const params: (string | number)[] = [viewer?.id ?? ""]; |
| 195 | const bind = (value: string): string => { | |
| 283 | const bind = (value: string | number): string => { | |
| 196 | 284 | params.push(value); |
| 197 | 285 | return `?${params.length}`; |
| 198 | 286 | }; |
| @@ -216,15 +304,20 @@ export function selectRows( | ||
| 216 | 304 | ids.value.length === 0 |
| 217 | 305 | ? "0" |
| 218 | 306 | : `issue.id IN (${ids.value.map((id) => bind(id)).join(",")})`; |
| 219 | } else if (term.field === "repo") { | |
| 220 | // `owner/name`, or a bare name in any owner. | |
| 221 | const slash = term.value.indexOf("/"); | |
| 222 | sql = | |
| 223 | slash < 0 | |
| 224 | ? repoFields.name!(bind(term.value)) | |
| 225 | : `${repoFields.owner!(bind(term.value.slice(0, slash)))} | |
| 226 | AND ${repoFields.name!(bind(term.value.slice(slash + 1)))}`; | |
| 227 | } else sql = FIELDS[query.kind][term.field]!(bind(term.value)); | |
| 307 | } else { | |
| 308 | const compared = COMPARED[query.kind][term.field]; | |
| 309 | const one = (value: string): string => { | |
| 310 | if (compared !== undefined) return comparedSql(compared, term.op, value, bind); | |
| 311 | if (term.field !== "repo") return FIELDS[query.kind][term.field]!(bind(value)); | |
| 312 | // `owner/name`, or a bare name in any owner. | |
| 313 | const slash = value.indexOf("/"); | |
| 314 | return slash < 0 | |
| 315 | ? repoFields.name!(bind(value)) | |
| 316 | : `${repoFields.owner!(bind(value.slice(0, slash)))} | |
| 317 | AND ${repoFields.name!(bind(value.slice(slash + 1)))}`; | |
| 318 | }; | |
| 319 | sql = term.values.map((v) => `(${one(v)})`).join(" OR "); | |
| 320 | } | |
| 228 | 321 | // COALESCE: a NULL comparison negated is still NULL, and `!x` must |
| 229 | 322 | // select the rows where x does not hold, including the empty ones. |
| 230 | 323 | const held = `COALESCE((${sql}), 0)`; |
src/tui/screens/fleet.ts +24 −2
| @@ -9,13 +9,13 @@ | ||
| 9 | 9 | |
| 10 | 10 | import { cellText, COLUMNS, sortRows, type Sort } from "../../fleet/columns.ts"; |
| 11 | 11 | import { viewFleet, type FleetView } from "../../fleet/rows.ts"; |
| 12 | import type { FleetKind } from "../../fleet/selection.ts"; | |
| 12 | import { fieldsOf, GRAMMAR, GRAMMAR_EXAMPLES, FLEET_KINDS, type FleetKind } from "../../fleet/selection.ts"; | |
| 13 | 13 | import type { FleetRow } from "../../fleet/rows.ts"; |
| 14 | 14 | import type { Ctx, Screen } from "../app/app.ts"; |
| 15 | 15 | import { binding } from "../app/keys.ts"; |
| 16 | 16 | import type { Session } from "../session.ts"; |
| 17 | 17 | import { style } from "../term/style.ts"; |
| 18 | import { pad, truncate } from "../term/width.ts"; | |
| 18 | import { pad, truncate, wrap } from "../term/width.ts"; | |
| 19 | 19 | import { promptScreen, Scroll } from "./common.ts"; |
| 20 | 20 | |
| 21 | 21 | /** No column is padded past this, so a long title does not push the rest away. */ |
| @@ -45,6 +45,20 @@ export function fleetLines( | ||
| 45 | 45 | ]; |
| 46 | 46 | } |
| 47 | 47 | |
| 48 | /** | |
| 49 | * The grammar as the web page shows it (`fleet/selection.ts`): the sentence | |
| 50 | * wrapped to `width`, one line a kind naming its fields, then the examples. | |
| 51 | */ | |
| 52 | export function grammarLines(width: number): string[] { | |
| 53 | return [ | |
| 54 | ...wrap(GRAMMAR, width), | |
| 55 | "", | |
| 56 | ...FLEET_KINDS.map((kind) => `${pad(kind, 6)} ${fieldsOf(kind).join(", ")}`), | |
| 57 | "", | |
| 58 | ...GRAMMAR_EXAMPLES.map((e) => `e.g. ${e}`), | |
| 59 | ]; | |
| 60 | } | |
| 61 | ||
| 48 | 62 | /** The next column to sort by: the one after the current, ascending; none sorted, the first. */ |
| 49 | 63 | export function nextSort(kind: FleetKind, sort: Sort | undefined): Sort { |
| 50 | 64 | const keys = COLUMNS[kind].map((c) => c.key); |
| @@ -81,6 +95,13 @@ export function fleetScreen( | ||
| 81 | 95 | ? `${query} ${view.rows.length} ${view.rows.length === 1 ? "row" : "rows"}` |
| 82 | 96 | : `${query} ${view.error}`; |
| 83 | 97 | s.text(area.x + 1, area.y, truncate(head, area.w - 2), style(view?.ok === false ? c.danger : c.mutedFg, c.bg), area.x + area.w); |
| 98 | if (view?.ok !== true) { | |
| 99 | // No rows to show: the grammar fills the space, as it sits under the web form. | |
| 100 | grammarLines(Math.max(20, area.w - 2)).forEach((text, i) => { | |
| 101 | if (2 + i < area.h) | |
| 102 | s.text(area.x + 1, area.y + 2 + i, truncate(text, area.w - 2), style(c.mutedFg, c.bg), area.x + area.w); | |
| 103 | }); | |
| 104 | } | |
| 84 | 105 | lines = view?.ok ? fleetLines(view.kind, view.rows, sort) : []; |
| 85 | 106 | const [header, ...body] = lines; |
| 86 | 107 | if (header !== undefined && area.h > 2) |
| @@ -108,6 +129,7 @@ export function fleetScreen( | ||
| 108 | 129 | promptScreen(ctx, screen, { |
| 109 | 130 | title: "Fleet query", |
| 110 | 131 | label: "kind, then field:value terms", |
| 132 | hint: `e.g. ${GRAMMAR_EXAMPLES[0]}`, | |
| 111 | 133 | placeholder: "repo autonomy:autonomous", |
| 112 | 134 | value: query, |
| 113 | 135 | onSubmit: (text) => { |
src/web/pages/fleet.ts +12 −1
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 10 | 10 | import { readSort } from "../../fleet/columns.ts"; |
| 11 | 11 | import { viewFleet, type FleetView } from "../../fleet/rows.ts"; |
| 12 | import { MAX_ROWS } from "../../fleet/selection.ts"; | |
| 12 | import { fieldsOf, GRAMMAR, GRAMMAR_EXAMPLES, FLEET_KINDS, MAX_ROWS } from "../../fleet/selection.ts"; | |
| 13 | 13 | import type { Ctx } from "../../http/context.ts"; |
| 14 | 14 | import { fleetTable } from "../fleet-table.ts"; |
| 15 | 15 | import { html } from "../html.ts"; |
| @@ -50,6 +50,17 @@ export function fleetPage(ctx: Ctx): Response { | ||
| 50 | 50 | })} |
| 51 | 51 | ${btn("Select", { type: "submit", variant: "secondary" })} |
| 52 | 52 | </form> |
| 53 | <div class="muted small mb" data-grammar> | |
| 54 | <p>${GRAMMAR} For example, ${GRAMMAR_EXAMPLES.map( | |
| 55 | (e, i) => html`${i === 0 ? "" : " or "}<code>${e}</code>`, | |
| 56 | )}.</p> | |
| 57 | <dl class="status-rows"> | |
| 58 | ${FLEET_KINDS.map( | |
| 59 | (kind) => | |
| 60 | html`<div class="status-row"><dt class="mono">${kind}</dt><dd>${fieldsOf(kind).join(", ")}</dd></div>`, | |
| 61 | )} | |
| 62 | </dl> | |
| 63 | </div> | |
| 53 | 64 | ${shown === undefined |
| 54 | 65 | ? null |
| 55 | 66 | : html`<p class="muted" data-count="${shown.rows.length}"> |
test/fleet-ops.test.ts +2 −2
| @@ -111,8 +111,8 @@ describe("fleet operations", () => { | ||
| 111 | 111 | expect(q.ok && q.value).toEqual({ |
| 112 | 112 | kind: "repo", |
| 113 | 113 | terms: [ |
| 114 | { field: "autonomy", value: "assisted", negate: false }, | |
| 115 | { field: "repo", value: "alice/tools", negate: true }, | |
| 114 | { field: "autonomy", value: "assisted", negate: false, op: "=", values: ["assisted"] }, | |
| 115 | { field: "repo", value: "alice/tools", negate: true, op: "=", values: ["alice/tools"] }, | |
| 116 | 116 | ], |
| 117 | 117 | }); |
| 118 | 118 | for (const bad of ["", "pulls", "repo autonomy", "repo spent:4", "run label:x"]) { |
test/fleet-selection.test.ts added +165
| @@ -0,0 +1,165 @@ | ||
| 1 | /** | |
| 2 | * The fleet query grammar (`fleet/selection.ts`), table-driven: every | |
| 3 | * accepted line and what it parses to, every refused line and the message | |
| 4 | * that names its field, then the comparisons, ages and lists against real | |
| 5 | * rows, and hostile values (quotes, `%`, `;`) through every field — bound, | |
| 6 | * never spliced, so they select nothing and break nothing. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { createRepo } from "../src/actions/repos.ts"; | |
| 14 | import { createIssue } from "../src/core/issues.ts"; | |
| 15 | import { createRun } from "../src/core/runs.ts"; | |
| 16 | import { createUser, type User } from "../src/core/users.ts"; | |
| 17 | import { fieldsOf, parseQuery, selectRows, type FleetKind } from "../src/fleet/selection.ts"; | |
| 18 | import { promote } from "./helpers/members.ts"; | |
| 19 | ||
| 20 | describe("parseQuery", () => { | |
| 21 | const accepted: [string, { field: string; op: string; values: string[]; negate?: boolean }][] = [ | |
| 22 | ["run cost:>0.5", { field: "cost", op: ">", values: ["0.5"] }], | |
| 23 | ["run cost:<=2", { field: "cost", op: "<=", values: ["2"] }], | |
| 24 | ["run cost:!>=1", { field: "cost", op: ">=", values: ["1"], negate: true }], | |
| 25 | ["issue number:<10", { field: "number", op: "<", values: ["10"] }], | |
| 26 | ["issue number:3,5,8", { field: "number", op: "=", values: ["3", "5", "8"] }], | |
| 27 | ["run created:<7d", { field: "created", op: "<", values: ["7d"] }], | |
| 28 | ["repo created:>30m", { field: "created", op: ">", values: ["30m"] }], | |
| 29 | ["issue updated:12h", { field: "updated", op: "=", values: ["12h"] }], | |
| 30 | ["run state:queued,running", { field: "state", op: "=", values: ["queued", "running"] }], | |
| 31 | ["repo repo:alice/a,bob/b", { field: "repo", op: "=", values: ["alice/a", "bob/b"] }], | |
| 32 | ["issue state:!closed", { field: "state", op: "=", values: ["closed"], negate: true }], | |
| 33 | ]; | |
| 34 | for (const [line, want] of accepted) | |
| 35 | test(`reads ${line}`, () => { | |
| 36 | const parsed = parseQuery(line); | |
| 37 | if (!parsed.ok) throw new Error(parsed.error.message); | |
| 38 | const term = parsed.value.terms[0]!; | |
| 39 | expect({ field: term.field, op: term.op as string, values: [...term.values] }).toEqual({ | |
| 40 | field: want.field, | |
| 41 | op: want.op, | |
| 42 | values: want.values, | |
| 43 | }); | |
| 44 | expect(term.negate).toBe(want.negate ?? false); | |
| 45 | }); | |
| 46 | ||
| 47 | const refused: [string, string][] = [ | |
| 48 | ["run cost:abc", "cost wants a number"], | |
| 49 | ["run cost:>abc", "cost wants a number"], | |
| 50 | ["run cost:1,x", "cost wants a number"], | |
| 51 | ["issue number:>1e3", "number wants a number"], | |
| 52 | ["run created:7", "created wants an age"], | |
| 53 | ["run created:<7y", "created wants an age"], | |
| 54 | ["issue updated:>soon", "updated wants an age"], | |
| 55 | ["run state:>queued", "state takes no comparison"], | |
| 56 | ["run cost:>1,2", "cost compares with one value"], | |
| 57 | ["run state:queued,", "state has an empty value"], | |
| 58 | ["issue limb:3,4", "limb takes the number of one issue"], | |
| 59 | ["run color:red", 'run has no field "color"'], | |
| 60 | ["run cost:>", "is not field:value"], | |
| 61 | ]; | |
| 62 | for (const [line, message] of refused) | |
| 63 | test(`refuses ${line}: ${message}`, () => { | |
| 64 | const parsed = parseQuery(line); | |
| 65 | expect(parsed.ok).toBe(false); | |
| 66 | if (!parsed.ok) expect(parsed.error.message).toContain(message); | |
| 67 | }); | |
| 68 | ||
| 69 | test("one bad term refuses the whole line: no partial selection", () => { | |
| 70 | expect(parseQuery("run state:queued cost:abc").ok).toBe(false); | |
| 71 | }); | |
| 72 | }); | |
| 73 | ||
| 74 | describe("selectRows with comparisons, ages and lists", () => { | |
| 75 | let dataDir: string; | |
| 76 | let app: App; | |
| 77 | let alice: User; | |
| 78 | const ids: Record<string, string> = {}; | |
| 79 | const select = (line: string): string[] => { | |
| 80 | const parsed = parseQuery(line); | |
| 81 | if (!parsed.ok) throw new Error(parsed.error.message); | |
| 82 | const rows = selectRows(app.db, alice, parsed.value); | |
| 83 | if (!rows.ok) throw new Error(rows.error.message); | |
| 84 | return rows.value; | |
| 85 | }; | |
| 86 | const ago = (ms: number) => new Date(Date.now() - ms).toISOString(); | |
| 87 | const DAY = 86_400_000; | |
| 88 | ||
| 89 | beforeAll(async () => { | |
| 90 | dataDir = await mkdtemp(join(tmpdir(), "forge-fleet-selection-")); | |
| 91 | app = createApp({ dataDir, port: 0 }); | |
| 92 | const made = await createUser(app.db, { | |
| 93 | origin: "seed", | |
| 94 | trusted: true, | |
| 95 | username: "alice", | |
| 96 | email: "[email protected]", | |
| 97 | password: "a-good-passphrase", | |
| 98 | adminIfFirst: true, | |
| 99 | }); | |
| 100 | if (!made.ok) throw new Error(made.error); | |
| 101 | alice = made.value; | |
| 102 | promote(app.db, alice); | |
| 103 | const repo = await createRepo(app, alice, { name: "engine", visibility: "public" }); | |
| 104 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 105 | const run = (name: string, state: string, cost: number, age: number) => { | |
| 106 | const r = createRun(app.db, { repoId: repo.value.id, kind: "agent" }); | |
| 107 | app.db | |
| 108 | .query("UPDATE run SET state = ?1, cost_usd = ?2, created_at = ?3 WHERE id = ?4") | |
| 109 | .run(state, cost, ago(age), r.id); | |
| 110 | ids[name] = r.id; | |
| 111 | }; | |
| 112 | run("cheapNew", "queued", 0.1, 60_000); | |
| 113 | run("dearNew", "running", 0.9, 2 * 3_600_000); | |
| 114 | run("dearOld", "ok", 2, 10 * DAY); | |
| 115 | for (const [n, age] of [[1, DAY], [2, 30 * DAY]] as const) { | |
| 116 | const i = createIssue(app.db, { repoId: repo.value.id, authorId: alice.id, title: `i${n}` }); | |
| 117 | if (!i.ok) throw new Error(i.error); | |
| 118 | app.db.query("UPDATE issue SET created_at = ?1 WHERE id = ?2").run(ago(age), i.value.id); | |
| 119 | ids[`issue${n}`] = i.value.id; | |
| 120 | } | |
| 121 | }, 30_000); | |
| 122 | ||
| 123 | afterAll(async () => { | |
| 124 | app.close(); | |
| 125 | await rm(dataDir, { recursive: true, force: true }); | |
| 126 | }); | |
| 127 | ||
| 128 | const sorted = (names: string[]) => names.map((n) => ids[n]!).sort(); | |
| 129 | const cases: [string, string[]][] = [ | |
| 130 | ["run cost:>0.5", ["dearNew", "dearOld"]], | |
| 131 | ["run cost:>=2", ["dearOld"]], | |
| 132 | ["run cost:<0.5", ["cheapNew"]], | |
| 133 | ["run cost:<=0.9", ["cheapNew", "dearNew"]], | |
| 134 | ["run cost:!>0.5", ["cheapNew"]], | |
| 135 | ["run cost:0.1,2", ["cheapNew", "dearOld"]], | |
| 136 | ["run created:<1h", ["cheapNew"]], | |
| 137 | ["run created:<7d", ["cheapNew", "dearNew"]], | |
| 138 | ["run created:7d", ["cheapNew", "dearNew"]], | |
| 139 | ["run created:>7d", ["dearOld"]], | |
| 140 | ["run state:queued,running", ["cheapNew", "dearNew"]], | |
| 141 | ["run state:!queued,running", ["dearOld"]], | |
| 142 | ["run state:queued,running cost:>0.5", ["dearNew"]], | |
| 143 | ["issue created:<7d", ["issue1"]], | |
| 144 | ["issue created:>2w", ["issue2"]], | |
| 145 | ["issue number:>1", ["issue2"]], | |
| 146 | ["issue number:1,2", ["issue1", "issue2"]], | |
| 147 | ]; | |
| 148 | for (const [line, want] of cases) | |
| 149 | test(line, () => expect([...select(line)].sort()).toEqual(sorted(want))); | |
| 150 | ||
| 151 | test("quotes, % and ; through every field are bound: nothing selected, nothing broken", () => { | |
| 152 | const hostile = [`'`, `"`, `%`, `;`, `x';DROP TABLE run;--`, `%'%`, `a"b;c`]; | |
| 153 | for (const kind of ["repo", "run", "issue"] as FleetKind[]) | |
| 154 | for (const field of fieldsOf(kind)) | |
| 155 | for (const value of hostile) { | |
| 156 | const parsed = parseQuery(`${kind} ${field}:${value}`); | |
| 157 | // A typed field refuses it at the boundary; a text field binds it. | |
| 158 | if (!parsed.ok) continue; | |
| 159 | const rows = selectRows(app.db, alice, parsed.value); | |
| 160 | if (field === "limb") expect(rows.ok).toBe(false); | |
| 161 | else expect(rows).toEqual({ ok: true, value: [] }); | |
| 162 | } | |
| 163 | expect(select("run state:queued,running,ok").length).toBe(3); | |
| 164 | }); | |
| 165 | }); |
test/fleet-table.test.ts +8
| @@ -89,6 +89,14 @@ describe("the fleet table", () => { | ||
| 89 | 89 | const body = await (await get("/fleet")).text(); |
| 90 | 90 | expect(body).toContain('name="q"'); |
| 91 | 91 | expect(body).not.toContain("data-count="); |
| 92 | // The grammar is listed under the query line (#566). | |
| 93 | const grammar = body.slice(body.indexOf("data-grammar")); | |
| 94 | expect(grammar).toContain("<code>run state:queued,running cost:>0.5</code>"); | |
| 95 | expect(grammar).toContain("<code>issue created:<7d assignee:none</code>"); | |
| 96 | // One row a kind naming its fields, never a middot-joined paragraph. | |
| 97 | expect(grammar).toMatch(/<dt class="mono">run<\/dt><dd>repo, [^<]*cost, created<\/dd>/); | |
| 98 | expect(grammar).toMatch(/<dt class="mono">issue<\/dt><dd>repo, [^<]*number, created, updated<\/dd>/); | |
| 99 | expect(grammar.slice(0, grammar.indexOf("</div>"))).not.toContain("·"); | |
| 92 | 100 | }); |
| 93 | 101 | |
| 94 | 102 | test("a query that does not parse shows its error beside the line, status 200, nothing selected", async () => { |
test/tui-fleet.test.ts +9
| @@ -93,6 +93,15 @@ test("the owner sees all three rows; a guest does not see the private one", asyn | ||
| 93 | 93 | expect(guest.text()).toContain("alice/tools"); |
| 94 | 94 | }); |
| 95 | 95 | |
| 96 | test("the screen shows the same grammar as the web page, fields per kind (#566)", async () => { | |
| 97 | const t = boot(getUserByName(forge.db, "alice")); | |
| 98 | await t.select("run cost:abc"); | |
| 99 | expect(t.text()).toContain("cost wants a number"); | |
| 100 | expect(t.text()).toContain("a comma means any of"); | |
| 101 | expect(t.text()).toMatch(/run +repo, [^\n]*cost, created/); | |
| 102 | expect(t.text()).toContain("e.g. run state:queued,running cost:>0.5"); | |
| 103 | }); | |
| 104 | ||
| 96 | 105 | test("a line that does not parse keeps the prompt open with its error", async () => { |
| 97 | 106 | const t = boot(getUserByName(forge.db, "alice")); |
| 98 | 107 | await t.select("repo spent:4"); |
Principles 1 and 7: two paragraphs explain the grammar twice (prose with examples, then a field list). Say it in one sentence and list fields per kind as dl rows, not a middot-joined paragraph. Plain words per docs/WRITING.md: spell out the operators less, show two examples.