Public: anyone can read this; the crew can change it.
A durable job: an app writes steps, sleeps and waits in plain code, and the forge carries it across sleeps, restarts and days #11678
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11596-a-durable-job-an-app-writes-steps-sleeps → main 8 commits fork
24 files
changed +763
−17
AGENTS.md +3 −1
Diff not shown — a line 2,248 characters wide (limit 2,000). +3 −1
View filedocs/APPS.md +6
| @@ -1279,6 +1279,12 @@ Declare schedules in `forge.json`: | ||
| 1279 | 1279 | |
| 1280 | 1280 | A fired job is an ordinary request to the app's port and counts toward its own limits. How to try it: `docs/features/timers.md`. |
| 1281 | 1281 | |
| 1282 | ### Jobs | |
| 1283 | ||
| 1284 | A **durable job** is code in the app that reads top to bottom, waits an hour or a week with no process running, survives a deploy, and never repeats a step that finished (`src/jobs.ts` in the app template; no dependency). The forge's part is only the timer above: `sleep` and `waitFor` save where the run is in the app's own database and arm a timer with key `job:<run>:<wake>` that `POST`s `/jobs/run` with `{ run, wake }`; the function then runs again from the top and finished steps answer from their saved results. A step must be safe to retry until it returns (it gets an `idempotencyKey`). A step that throws is retried with backoff (default 3 retries) and then the run is failed: the app answers `422` with `x-forge-no-retry: 1`, which the forge records as failed at once (no redelivery) and asks the owner once, by run. A step or job name that no longer matches what a run did fails that run plainly; `version` keeps an old path for old runs. A step result is JSON up to 256 KB; unfinished runs per app are `JOBS_ACTIVE_MAX` (default 1000) and pending timers `FORGE_TIMERS_PER_APP`. | |
| 1285 | - **Seen and steered.** The runs live in the app, so the forge asks it (waking it), signed as a timer's delivery is: `GET /jobs/runs`, `POST /jobs/runs/:id/cancel|retry`. The app's page links to `/:owner/:name/app/jobs` while it holds a job timer: name, key, started, where it is now (running, sleeping until, waiting for, done, failed), **Cancel** and **Retry from the failed step**. `GET /api/v1/repos/:owner/:name/jobs`, `POST …/jobs/:id/(cancel|retry)` and the `app_deploy` MCP tool (`jobs`, `cancel_job`, `retry_job`) answer the same rows (`src/actions/jobs.ts`). A writer's: a run's error and result are the app's own text. | |
| 1286 | - How to try it: `docs/features/jobs.md`. | |
| 1287 | ||
| 1282 | 1288 | ## Where state lives |
| 1283 | 1289 | |
| 1284 | 1290 | Git is truth for code. The app's serving configuration (slug, access, the |
docs/MCP.md +1 −1
| @@ -49,7 +49,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 49 | 49 | | `accounts` | — | Accounts, newest first: role, verified, suspended; `cursor` pages | 264 | |
| 50 | 50 | | `agent` | — | Admin: create\|update\|remove an agent | 371 | |
| 51 | 51 | | `agents` | — | Agents you may see, as GET /api/v1/agents | 160 | |
| 52 | | `app_deploy` | `redeploy`, `stop`, `pull`, `schedules`, `run_schedule`, `pause_schedule`, `resume_schedule` | Redeploy or stop production or a pull-N preview, pull an adapter now, or list and steer the app's schedules | 419 | | |
| 52 | | `app_deploy` | `redeploy`, `stop`, `pull`, `schedules`, `run_schedule`, `pause_schedule`, `resume_schedule`, `jobs`, `cancel_job`, `retry_job` | Redeploy or stop an env, pull, list and steer schedules and jobs | 408 | | |
| 53 | 53 | | `app_domains` | — | App domains: list, add, check, remove | 201 | |
| 54 | 54 | | `app_env` | `list`, `set`, `unset` | An app's environment variables: list names, set or unset by scope; write-only | 393 | |
| 55 | 55 | | `app_members` | — | People a private app names, who may enter it and its previews | 297 | |
docs/features/jobs.md added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | # Durable jobs | |
| 2 | ||
| 3 | A job is code that waits (`sleep`, `waitFor`) without a process, survives restarts and deploys, and runs each step once. The rules are the "Jobs" part of "Time" in `docs/APPS.md`; the library and a worked example are in every new app (`src/jobs.ts`, `src/reminder.ts`, README "Jobs"). | |
| 4 | ||
| 5 | 1. In a new app, open a note signed in and press **Remind me tomorrow** (`POST /notes/:id/remind`): a run of the `remind` job starts, loads the note, and waits a day for the note to be removed. | |
| 6 | 2. Open the app on the forge: while the run holds a timer, its page has **Jobs → See the job runs** (`/:owner/:name/app/jobs`). The row says `waiting for removed:<id>, until <time> at the latest`. **Cancel** stops it; a failed run offers **Retry from the failed step**. | |
| 7 | 3. The same from a script: `curl -H "authorization: Bearer $TOKEN" $FORGE_URL/api/v1/repos/<owner>/<name>/jobs`, `curl -X POST …/jobs/<run>/cancel`; the `app_deploy` MCP tool does it with `action: "jobs"`, `"cancel_job"` or `"retry_job"` (`name` is the run id). | |
| 8 | 4. The terminal: with the app's repository open, the palette offers **Job runs** (a writer's, while the app holds a job timer); `c` cancels the run under the cursor, `t` retries a failed one, `g` reads again (`src/tui/screens/jobs.ts`). | |
| 9 | 5. A run that fails for good (a step out of retries, or a changed job) asks the owner once. | |
| 10 | ||
| 11 | Where the code is: the runtime `src/jobs.ts` in the app template (`templates/app-template.json`; its tests, `src/jobs.test.ts`, run by `test/template-jobs.test.ts`), the forge's rows and steering `src/actions/jobs.ts`, `src/http/api/jobs.ts`, `src/web/studio/jobs.ts`, the no-retry answer in `src/apps/timer_fire.ts`, and the ask in `scheduleLines` (`src/actions/schedules.ts`). Tests: `test/jobs-doors.test.ts`. | |
| 12 | ||
| 13 | What the forge could not do: `trevato/app-template` is a separate repository, which this change cannot push to. The bundle carries the new files, so a forge without that repository hands them out; a forge that reads the repository needs the same files (`src/jobs.ts`, `src/reminder.ts`, `src/jobs.test.ts`, the routes and button, the README section) landed there, and `scripts/take-template.ts` then replaces the bundle. |
src/actions/jobs.ts added +154
| @@ -0,0 +1,154 @@ | ||
| 1 | /** | |
| 2 | * An app's durable job runs, seen and steered (#11596). The runs live in the | |
| 3 | * app's own data (`src/jobs.ts` of the app template), so the forge asks the app, waking it if | |
| 4 | * it sleeps: a signed `GET /jobs/runs`, and a signed `POST /jobs/runs/:id/cancel` | |
| 5 | * or `…/retry`, signed as a timer's delivery is (`controlHeaders`). The page, | |
| 6 | * `GET …/jobs`, the MCP tool and the terminal read these functions, so they | |
| 7 | * say the same words. Writers only: a run's error and result are the app's | |
| 8 | * own text. Production's runs; a preview keeps its own in its own data. | |
| 9 | */ | |
| 10 | ||
| 11 | import type { App } from "../app.ts"; | |
| 12 | import { runtimeReach } from "../apps/timer_service.ts"; | |
| 13 | import { controlHeaders, type Reach } from "../apps/timer_fire.ts"; | |
| 14 | import { getAppByRepo } from "../core/apps.ts"; | |
| 15 | import type { Repo } from "../core/repos.ts"; | |
| 16 | import type { User } from "../core/users.ts"; | |
| 17 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 18 | import { requireWriter } from "./access.ts"; | |
| 19 | ||
| 20 | /** How the forge reaches the app: the real runtime, unless a test hands in its own. */ | |
| 21 | export const jobReach: { override: Reach | undefined } = { override: undefined }; | |
| 22 | const reachOf = (app: App): Reach => jobReach.override ?? runtimeReach(app); | |
| 23 | ||
| 24 | export const JOB_STATUSES = ["running", "sleeping", "waiting", "done", "failed", "cancelled"] as const; | |
| 25 | export type JobStatus = (typeof JOB_STATUSES)[number]; | |
| 26 | ||
| 27 | export interface JobRunRow { | |
| 28 | readonly id: string; | |
| 29 | readonly job: string; | |
| 30 | readonly key: string; | |
| 31 | readonly status: JobStatus; | |
| 32 | /** ms epoch: when a sleeping run wakes, or a waiting run's timeout. */ | |
| 33 | readonly until: number | null; | |
| 34 | readonly event: string | null; | |
| 35 | readonly error: string | null; | |
| 36 | readonly failedStep: string | null; | |
| 37 | readonly started: number; | |
| 38 | readonly updated: number; | |
| 39 | /** A finished run's result, as text, cut at 500 characters. */ | |
| 40 | readonly result: string | null; | |
| 41 | /** Where it is now, in words. */ | |
| 42 | readonly now: string; | |
| 43 | } | |
| 44 | ||
| 45 | export type JobError = "not_found" | "unauthorized" | "forbidden" | "unreachable" | "not_now" | "invalid"; | |
| 46 | ||
| 47 | export const JOB_WORDS: Record<"unreachable" | "not_now" | "invalid", string> = { | |
| 48 | unreachable: "The app did not answer, so its job runs cannot be read or changed now. It may be asleep and failing to start; look at its deploy.", | |
| 49 | not_now: "That run is not in a state that allows it: only an unfinished run can be cancelled and only a failed one retried.", | |
| 50 | invalid: "The action is cancel or retry, on a run id.", | |
| 51 | }; | |
| 52 | ||
| 53 | const iso = (ms: number): string => new Date(ms).toISOString().replace("T", " ").slice(0, 16) + " UTC"; | |
| 54 | const str = (v: unknown, max: number): string | null => (typeof v === "string" ? v.slice(0, max).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "?") : null); | |
| 55 | const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null); | |
| 56 | ||
| 57 | /** Where a run is now: the words the page, the tool and the terminal share. */ | |
| 58 | export function nowWords(r: Pick<JobRunRow, "status" | "until" | "event" | "error" | "failedStep">): string { | |
| 59 | switch (r.status) { | |
| 60 | case "sleeping": | |
| 61 | return r.until === null ? "sleeping" : `sleeping until ${iso(r.until)}${r.error === null ? "" : " (retrying a step)"}`; | |
| 62 | case "waiting": | |
| 63 | return `waiting for ${r.event ?? "an event"}${r.until === null ? "" : `, until ${iso(r.until)} at the latest`}`; | |
| 64 | case "failed": | |
| 65 | return `failed${r.failedStep === null ? "" : ` at ${r.failedStep}`}`; | |
| 66 | default: | |
| 67 | return r.status; | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | /** One row of the app's answer, or null when it is not one: the app's text is never trusted for its shape. */ | |
| 72 | function rowOf(v: unknown): JobRunRow | null { | |
| 73 | if (typeof v !== "object" || v === null) return null; | |
| 74 | const o = v as Record<string, unknown>; | |
| 75 | const status = JOB_STATUSES.find((s) => s === o["status"]); | |
| 76 | const id = str(o["id"], 40); | |
| 77 | if (status === undefined || id === null || !/^[A-Za-z0-9-]{1,40}$/.test(id)) return null; | |
| 78 | const base = { | |
| 79 | id, job: str(o["job"], 60) ?? "", key: str(o["key"], 100) ?? "", status, until: num(o["until"]), event: str(o["event"], 200), | |
| 80 | error: str(o["error"], 500), failedStep: str(o["failedStep"], 100), started: num(o["started"]) ?? 0, updated: num(o["updated"]) ?? 0, | |
| 81 | }; | |
| 82 | const result = o["result"] === null || o["result"] === undefined ? null : (str(typeof o["result"] === "string" ? o["result"] : JSON.stringify(o["result"]), 500)); | |
| 83 | return { ...base, result, now: nowWords(base) }; | |
| 84 | } | |
| 85 | ||
| 86 | /** What the app may answer with: a hundred rows of capped text fit well inside it. */ | |
| 87 | export const BODY_MAX = 1_000_000; | |
| 88 | ||
| 89 | /** The body read as a stream with a byte cap; undefined (and the stream cancelled) once it passes the cap, so a large answer is never held in memory. */ | |
| 90 | export async function readCapped(res: Response, max: number): Promise<string | undefined> { | |
| 91 | const reader = res.body?.getReader(); | |
| 92 | if (reader === undefined) return ""; | |
| 93 | const chunks: Uint8Array[] = []; | |
| 94 | let n = 0; | |
| 95 | for (;;) { | |
| 96 | const { done, value } = await reader.read(); | |
| 97 | if (done || value === undefined) break; | |
| 98 | n += value.length; | |
| 99 | if (n > max) { | |
| 100 | void reader.cancel().catch(() => undefined); | |
| 101 | return undefined; | |
| 102 | } | |
| 103 | chunks.push(value); | |
| 104 | } | |
| 105 | return new TextDecoder().decode(Buffer.concat(chunks)); | |
| 106 | } | |
| 107 | ||
| 108 | async function ask(app: App, repo: Repo, method: "GET" | "POST", path: string, reach: Reach): Promise<Result<unknown, "not_found" | "unreachable" | "not_now">> { | |
| 109 | const record = getAppByRepo(app.db, repo.id); | |
| 110 | if (record === undefined) return err("not_found"); | |
| 111 | const port = await reach.ensure(record, "production").catch(() => undefined); | |
| 112 | if (port === undefined) return err("unreachable"); | |
| 113 | try { | |
| 114 | const res = await reach.fetch(`http://127.0.0.1:${port}${path}`, { | |
| 115 | method, headers: controlHeaders(reach.identityKey(record.id), Date.now()), redirect: "manual", signal: AbortSignal.timeout(10_000), | |
| 116 | }); | |
| 117 | if (res.status === 409) return err("not_now"); | |
| 118 | if (!res.ok) return err("unreachable"); | |
| 119 | const body = await readCapped(res, BODY_MAX); | |
| 120 | return body === undefined ? err("unreachable") : ok(JSON.parse(body) as unknown); | |
| 121 | } catch { | |
| 122 | return err("unreachable"); | |
| 123 | } | |
| 124 | } | |
| 125 | ||
| 126 | /** The runs, newest first. */ | |
| 127 | export async function jobRows(app: App, actor: User | undefined, repo: Repo, reach: Reach = reachOf(app)): Promise<Result<JobRunRow[], JobError>> { | |
| 128 | const writer = requireWriter(app, actor, repo); | |
| 129 | if (!writer.ok) return err(writer.error); | |
| 130 | const got = await ask(app, repo, "GET", "/jobs/runs?limit=100", reach); | |
| 131 | if (!got.ok) return err(got.error); | |
| 132 | const runs = (got.value as { runs?: unknown } | null)?.runs; | |
| 133 | return ok(Array.isArray(runs) ? runs.slice(0, 100).map(rowOf).filter((r): r is JobRunRow => r !== null) : []); | |
| 134 | } | |
| 135 | ||
| 136 | export type JobAction = "cancel" | "retry"; | |
| 137 | ||
| 138 | export async function steerJob(app: App, actor: User | undefined, repo: Repo, id: string, action: string, reach: Reach = reachOf(app)): Promise<Result<true, JobError>> { | |
| 139 | const writer = requireWriter(app, actor, repo); | |
| 140 | if (!writer.ok) return err(writer.error); | |
| 141 | if ((action !== "cancel" && action !== "retry") || !/^[A-Za-z0-9-]{1,40}$/.test(id)) return err("invalid"); | |
| 142 | const done = await ask(app, repo, "POST", `/jobs/runs/${id}/${action}`, reach); | |
| 143 | if (!done.ok) return err(done.error); | |
| 144 | // The same event a schedule's steering is, so every door already listening redraws. | |
| 145 | app.bus.append({ kind: "schedule.steered", repoId: repo.id, actorId: writer.value.id, payload: { name: `job:${id}`, env: "production", action } }); | |
| 146 | return ok(true); | |
| 147 | } | |
| 148 | ||
| 149 | /** How many job timers the app holds: what says an app has jobs without waking it. */ | |
| 150 | export function jobTimers(app: App, repo: Repo): number { | |
| 151 | const record = getAppByRepo(app.db, repo.id); | |
| 152 | if (record === undefined) return 0; | |
| 153 | return app.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM timer WHERE app_id = ?1 AND kind = 'once' AND key LIKE 'job:%'").get(record.id)?.n ?? 0; | |
| 154 | } |
src/actions/schedules.ts +10 −2
| @@ -10,7 +10,7 @@ import type { App } from "../app.ts"; | ||
| 10 | 10 | import { getAppByRepo } from "../core/apps.ts"; |
| 11 | 11 | import { canAdmin, listReposWritableTo, type Repo } from "../core/repos.ts"; |
| 12 | 12 | import type { User } from "../core/users.ts"; |
| 13 | import { failingNow, runNow, runsOf, scheduleTimer, scheduleTimers, setPaused, type TimerRun } from "../core/timers.ts"; | |
| 13 | import { failedJobs, failingNow, runNow, runsOf, scheduleTimer, scheduleTimers, setPaused, type TimerRun } from "../core/timers.ts"; | |
| 14 | 14 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 15 | 15 | import { requireReader, requireWriter } from "./access.ts"; |
| 16 | 16 | |
| @@ -108,7 +108,7 @@ export function steerSchedule(app: App, actor: User | undefined, repo: Repo, nam | ||
| 108 | 108 | * with the reason. The item is the schedule, not the run, so a schedule that |
| 109 | 109 | * keeps failing is one ask, not one a day (`crew/notify.ts` says only what is new). |
| 110 | 110 | */ |
| 111 | export function scheduleLines(app: App, user: User): { line: string; items: string[]; link: string }[] { | |
| 111 | export function scheduleLines(app: App, user: User, now = Date.now()): { line: string; items: string[]; link: string }[] { | |
| 112 | 112 | if (user.kind !== "user") return []; |
| 113 | 113 | const out: { line: string; items: string[]; link: string }[] = []; |
| 114 | 114 | for (const repo of listReposWritableTo(app.db, user, { limit: 200, withoutArchived: true })) { |
| @@ -118,6 +118,14 @@ export function scheduleLines(app: App, user: User): { line: string; items: stri | ||
| 118 | 118 | const why = run.status === "failed" ? `Schedule ${run.name} failed: ${run.error}` : run.error; |
| 119 | 119 | out.push({ line: `${repo.name}: ${why}`, items: [`schedule:${record.id}:${run.env}:${run.name}`], link: `/${repo.ownerName}/${repo.name}#schedules` }); |
| 120 | 120 | } |
| 121 | // A durable job that failed for good is one ask, named by its run (`job:<run>:<wake>`), never one per wake. | |
| 122 | const seen = new Set<string>(); | |
| 123 | for (const run of failedJobs(app.db, record.id, now)) { | |
| 124 | const id = run.name.split(":")[1] ?? run.name; | |
| 125 | if (seen.has(id)) continue; | |
| 126 | seen.add(id); | |
| 127 | out.push({ line: `${repo.name}: a job run failed: ${run.error ?? "no reason given"}`, items: [`job:${record.id}:${id}`], link: `/${repo.ownerName}/${repo.name}/app/jobs` }); | |
| 128 | } | |
| 121 | 129 | } |
| 122 | 130 | return out; |
| 123 | 131 | } |
src/apps/timer_fire.ts +22 −3
| @@ -63,6 +63,23 @@ export function deliveryHeaders(key: string, t: Timer, attempt: number, expires: | ||
| 63 | 63 | }; |
| 64 | 64 | } |
| 65 | 65 | |
| 66 | /** The `error` an app's JSON answer names, when it names one. */ | |
| 67 | function failureText(head: string): string | undefined { | |
| 68 | try { | |
| 69 | const e = (JSON.parse(head) as { error?: unknown }).error; | |
| 70 | return typeof e === "string" && e !== "" ? e : undefined; | |
| 71 | } catch { | |
| 72 | return undefined; | |
| 73 | } | |
| 74 | } | |
| 75 | ||
| 76 | /** The headers of a request the forge makes of an app outside a timer (listing or steering its jobs): the same signature, an idempotency key of its own. */ | |
| 77 | export function controlHeaders(key: string, now: number): Record<string, string> { | |
| 78 | const idem = `control:${crypto.randomUUID()}`; | |
| 79 | const identity = `forge-timer|${idem}|timer|${now + 60_000}`; | |
| 80 | return { "idempotency-key": idem, "x-forge-identity": identity, "x-forge-signature": createHmac("sha256", key).update(identity).digest("base64url") }; | |
| 81 | } | |
| 82 | ||
| 66 | 83 | async function readHead(res: Response): Promise<string> { |
| 67 | 84 | const reader = res.body?.getReader(); |
| 68 | 85 | if (reader === undefined) return ""; |
| @@ -78,7 +95,7 @@ async function readHead(res: Response): Promise<string> { | ||
| 78 | 95 | return new TextDecoder().decode(Buffer.concat(chunks)).slice(0, RESPONSE_KEPT).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "?"); |
| 79 | 96 | } |
| 80 | 97 | |
| 81 | interface Attempt { readonly ok: boolean; readonly status: number | null; readonly response: string | null; readonly error: string | null } | |
| 98 | interface Attempt { readonly final?: boolean; readonly ok: boolean; readonly status: number | null; readonly response: string | null; readonly error: string | null } | |
| 82 | 99 | |
| 83 | 100 | async function attemptOnce(reach: Reach, app: AppRecord, t: Timer, attempt: number, now: () => number): Promise<Attempt> { |
| 84 | 101 | const port = await reach.ensure(app, t.env).catch(() => undefined); |
| @@ -94,6 +111,8 @@ async function attemptOnce(reach: Reach, app: AppRecord, t: Timer, attempt: numb | ||
| 94 | 111 | }); |
| 95 | 112 | const head = await readHead(res); |
| 96 | 113 | if (res.status >= 200 && res.status < 300) return { ok: true, status: res.status, response: head, error: null }; |
| 114 | // An app that says `x-forge-no-retry` has decided: delivering again would only repeat it (a durable job that failed, #11596). | |
| 115 | if (res.headers.get("x-forge-no-retry") === "1") return { ok: false, final: true, status: res.status, response: head, error: failureText(head) ?? `the app answered ${res.status}` }; | |
| 97 | 116 | return { ok: false, status: res.status, response: head, error: `the app answered ${res.status}` }; |
| 98 | 117 | } catch (e) { |
| 99 | 118 | const timedOut = e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError"); |
| @@ -153,10 +172,10 @@ export async function fireTimer(db: Database, reach: Reach, t: Timer, now: () => | ||
| 153 | 172 | finishTimer(db, t, next); |
| 154 | 173 | return; |
| 155 | 174 | } |
| 156 | if (attempt <= t.retries) return retryTimer(db, t.id, finished + backoffFor(t.attempt), attempt); | |
| 175 | if (result.final !== true && attempt <= t.retries) return retryTimer(db, t.id, finished + backoffFor(t.attempt), attempt); | |
| 157 | 176 | recordRun(db, { |
| 158 | 177 | ...base, startedAt: started, finishedAt: finished, attempts: attempt, status: "failed", httpStatus: result.status, response: result.response, |
| 159 | error: `${result.error ?? "failed"}, after ${attempt} ${attempt === 1 ? "attempt" : "attempts"}`, ...late, durationMs: finished - started, | |
| 178 | error: result.final === true ? (result.error ?? "failed") : `${result.error ?? "failed"}, after ${attempt} ${attempt === 1 ? "attempt" : "attempts"}`, ...late, durationMs: finished - started, | |
| 160 | 179 | }); |
| 161 | 180 | finishTimer(db, t, next); |
| 162 | 181 | if (t.kind === "schedule" && failingDays(runsOf(db, t.appId, t.name, 30).filter((r) => r.env === t.env)) >= PAUSE_AFTER_DAYS) { |
src/contract/openapi/routes/getApiV1ReposOwnerNameJobs.ts added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: `${REPO}/jobs`, | |
| 6 | summary: "An app's durable job runs, newest first: job, key, status (running, sleeping, waiting, done, failed, cancelled), where it is now, when it wakes, the failed step and error, a finished run's result; read from the app itself, waking it; a writer's; 404 for anyone else, 502 when the app does not answer", | |
| 7 | auth: true, | |
| 8 | role: "member", | |
| 9 | }; |
src/contract/openapi/routes/index.ts +2
| @@ -162,6 +162,7 @@ export { route as getApiV1ReposOwnerNameIssuesNumberDependencies } from "./getAp | ||
| 162 | 162 | export { route as getApiV1ReposOwnerNameIssuesNumberPlan } from "./getApiV1ReposOwnerNameIssuesNumberPlan.ts"; |
| 163 | 163 | export { route as getApiV1ReposOwnerNameIssuesNumberTeams } from "./getApiV1ReposOwnerNameIssuesNumberTeams.ts"; |
| 164 | 164 | export { route as getApiV1ReposOwnerNameIterations } from "./getApiV1ReposOwnerNameIterations.ts"; |
| 165 | export { route as getApiV1ReposOwnerNameJobs } from "./getApiV1ReposOwnerNameJobs.ts"; | |
| 165 | 166 | export { route as getApiV1ReposOwnerNameLabels } from "./getApiV1ReposOwnerNameLabels.ts"; |
| 166 | 167 | export { route as getApiV1ReposOwnerNameMap } from "./getApiV1ReposOwnerNameMap.ts"; |
| 167 | 168 | export { route as getApiV1ReposOwnerNameMilestones } from "./getApiV1ReposOwnerNameMilestones.ts"; |
| @@ -343,6 +344,7 @@ export { route as postApiV1ReposOwnerNameIssuesNumberComments } from "./postApiV | ||
| 343 | 344 | export { route as postApiV1ReposOwnerNameIssuesNumberDependencies } from "./postApiV1ReposOwnerNameIssuesNumberDependencies.ts"; |
| 344 | 345 | export { route as postApiV1ReposOwnerNameIssuesNumberDependenciesBlockerConfirm } from "./postApiV1ReposOwnerNameIssuesNumberDependenciesBlockerConfirm.ts"; |
| 345 | 346 | export { route as postApiV1ReposOwnerNameIssuesNumberPrivate } from "./postApiV1ReposOwnerNameIssuesNumberPrivate.ts"; |
| 347 | export { route as postApiV1ReposOwnerNameJobsIdAction } from "./postApiV1ReposOwnerNameJobsIdAction.ts"; | |
| 346 | 348 | export { route as postApiV1ReposOwnerNameLabels } from "./postApiV1ReposOwnerNameLabels.ts"; |
| 347 | 349 | export { route as postApiV1ReposOwnerNameMakeItYours } from "./postApiV1ReposOwnerNameMakeItYours.ts"; |
| 348 | 350 | export { route as postApiV1ReposOwnerNameMilestonesNumberLimb } from "./postApiV1ReposOwnerNameMilestonesNumberLimb.ts"; |
src/contract/openapi/routes/postApiV1ReposOwnerNameJobsIdAction.ts added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "post", | |
| 5 | path: `${REPO}/jobs/{id}/{action}`, | |
| 6 | summary: "Steer a job run: cancel an unfinished one, or retry a failed one from its failed step (rw scope, writer); 422 when the run is not in a state that allows it or another action, 502 when the app does not answer", | |
| 7 | auth: true, | |
| 8 | role: "member", | |
| 9 | }; |
src/contract/tags.ts +1
| @@ -9,6 +9,7 @@ const ALIAS: Readonly<Record<string, string>> = { | ||
| 9 | 9 | app: "apps", |
| 10 | 10 | timers: "apps", |
| 11 | 11 | schedules: "apps", |
| 12 | jobs: "apps", | |
| 12 | 13 | "app-templates": "apps", |
| 13 | 14 | display: "builds", |
| 14 | 15 | following: "forks", |
src/core/timers.ts +12
| @@ -247,6 +247,18 @@ export function failingNow(db: Database, appId: string): TimerRun[] { | ||
| 247 | 247 | return out; |
| 248 | 248 | } |
| 249 | 249 | |
| 250 | /** | |
| 251 | * Job runs (#11596) the app failed for good in the last `days` days: one per run, newest first. | |
| 252 | * A durable job's timers are named `job:<run>:<wake>`; the app answers a failed run with | |
| 253 | * `x-forge-no-retry`, which records it as failed at once. | |
| 254 | */ | |
| 255 | export function failedJobs(db: Database, appId: string, now: number, days = 3, limit = 20): TimerRun[] { | |
| 256 | const rows = db | |
| 257 | .query<Row, [string, number, number]>("SELECT * FROM timer_run WHERE app_id = ?1 AND name LIKE 'job:%' AND status = 'failed' AND finished_at > ?2 ORDER BY id DESC LIMIT ?3") | |
| 258 | .all(appId, now - days * 86_400_000, limit); | |
| 259 | return rows.map(toRun); | |
| 260 | } | |
| 261 | ||
| 250 | 262 | /** Drop the schedule rows of environments that are gone (a closed pull's preview). */ |
| 251 | 263 | export function dropGoneEnvs(db: Database, appId: string, live: ReadonlySet<string>): void { |
| 252 | 264 | for (const t of scheduleTimers(db, appId)) if (!live.has(t.env)) db.query("DELETE FROM timer WHERE id = ?1").run(t.id); |
src/http/api.ts +2
| @@ -354,6 +354,7 @@ import { passportRoutes } from "./passport_edge.ts"; | ||
| 354 | 354 | import { egressGrantRoutes } from "./api/egress_grants.ts"; |
| 355 | 355 | import { printRoutes } from "./api/print.ts"; |
| 356 | 356 | import { timersRoutes } from "./api/timers.ts"; |
| 357 | import { jobsRoutes } from "./api/jobs.ts"; | |
| 357 | 358 | import { schedulesRoutes } from "./api/schedules.ts"; |
| 358 | 359 | import { forgeRoutes } from "./api/forges.ts"; |
| 359 | 360 | import { packRoutes } from "./api/packs.ts"; |
| @@ -3862,6 +3863,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3862 | 3863 | printRoutes(router); |
| 3863 | 3864 | timersRoutes(router); |
| 3864 | 3865 | schedulesRoutes(router); |
| 3866 | jobsRoutes(router); | |
| 3865 | 3867 | |
| 3866 | 3868 | promptRoutes(router); |
| 3867 | 3869 |
src/http/api/jobs.ts added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | /** | |
| 2 | * `GET /api/v1/repos/:owner/:name/jobs` and `POST …/jobs/:id/(cancel|retry)` | |
| 3 | * (#11596): an app's durable job runs and the two things a writer may do to | |
| 4 | * one, the same answers as the app's page and the MCP tool (`actions/jobs.ts`). | |
| 5 | */ | |
| 6 | ||
| 7 | import { JOB_WORDS, jobRows, steerJob } from "../../actions/jobs.ts"; | |
| 8 | import { loadRepo } from "../api.ts"; | |
| 9 | import type { Handler } from "../context.ts"; | |
| 10 | import { apiError, json, notFound, requireScope, respondAccess } from "../respond.ts"; | |
| 11 | import type { Router } from "../router.ts"; | |
| 12 | ||
| 13 | export function jobsRoutes(router: Router<Handler>): void { | |
| 14 | router.add("GET", "/api/v1/repos/:owner/:name/jobs", async (ctx) => { | |
| 15 | const repo = loadRepo(ctx); | |
| 16 | if (!repo) return notFound(); | |
| 17 | const rows = await jobRows(ctx.app, ctx.auth.user, repo); | |
| 18 | if (!rows.ok) return rows.error === "not_found" ? notFound() : rows.error === "unreachable" ? apiError("unreachable", 502, JOB_WORDS.unreachable) : respondAccess(rows.error, 422); | |
| 19 | return json({ items: rows.value, next_cursor: null }); | |
| 20 | }); | |
| 21 | router.add("POST", "/api/v1/repos/:owner/:name/jobs/:id/:action", async (ctx) => { | |
| 22 | const denied = requireScope(ctx, "rw"); | |
| 23 | if (denied) return denied; | |
| 24 | const repo = loadRepo(ctx); | |
| 25 | if (!repo) return notFound(); | |
| 26 | const done = await steerJob(ctx.app, ctx.auth.user, repo, ctx.params["id"] ?? "", ctx.params["action"] ?? ""); | |
| 27 | if (done.ok) return json({ run: ctx.params["id"], action: ctx.params["action"] }, 200); | |
| 28 | if (done.error === "not_found") return notFound(); | |
| 29 | if (done.error === "unreachable") return apiError("unreachable", 502, JOB_WORDS.unreachable); | |
| 30 | if (done.error === "not_now" || done.error === "invalid") return apiError(done.error, 422, JOB_WORDS[done.error]); | |
| 31 | return respondAccess(done.error, 422); | |
| 32 | }); | |
| 33 | } |
src/http/mcp/tools/app_deploy.ts +13 −4
| @@ -1,21 +1,22 @@ | ||
| 1 | 1 | import { envRefusalText } from "../../../actions/env_words.ts"; |
| 2 | 2 | import { pullNow, PULL_NOW_REFUSALS } from "../../../actions/app_pull.ts"; |
| 3 | 3 | import { redeployEnv, stopEnv } from "../../../actions/apps.ts"; |
| 4 | import { JOB_WORDS, jobRows, steerJob } from "../../../actions/jobs.ts"; | |
| 4 | 5 | import { scheduleRows, steerSchedule } from "../../../actions/schedules.ts"; |
| 5 | 6 | import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 6 | 7 | |
| 7 | 8 | export const tool: Tool = { |
| 8 | 9 | name: "app_deploy", |
| 9 | doc: "`{ repo, env, action }`: `redeploy` or `stop` an app's `production` or a pull's `pull-N` preview; a writer's, queued as `POST …/app/:env/redeploy` and `…/stop` are; answers `{ queued, run }` or `{ stopped }`. `{ repo, action: \"pull\" }` runs an adapter's pull now as `POST …/app/pull` does; answers `{ run }`. `{ repo, action: \"schedules\" }` lists the app's schedules (`{ items }`, as `GET …/schedules`); `run_schedule`, `pause_schedule` or `resume_schedule` with `name` steers one, a writer's, as `POST …/schedules/:name/:action` does", | |
| 10 | doc: "`{ repo, env, action }`: `redeploy` or `stop` an app's `production` or a pull's `pull-N` preview; a writer's, queued as `POST …/app/:env/redeploy` and `…/stop` are; answers `{ queued, run }` or `{ stopped }`. `{ repo, action: \"pull\" }` runs an adapter's pull now as `POST …/app/pull` does; answers `{ run }`. `{ repo, action: \"schedules\" }` lists the app's schedules (`{ items }`, as `GET …/schedules`); `run_schedule`, `pause_schedule` or `resume_schedule` with `name` steers one, a writer's, as `POST …/schedules/:name/:action` does. `jobs` lists the durable job runs (`{ items }`, as `GET …/jobs`); `cancel_job` or `retry_job` with `name` the run id steers one, as `POST …/jobs/:id/:action` does", | |
| 10 | 11 | description: |
| 11 | "Redeploy or stop production or a pull-N preview, pull an adapter now, or list and steer the app's schedules.", | |
| 12 | "Redeploy or stop an env, pull, list and steer schedules and jobs.", | |
| 12 | 13 | inputSchema: { |
| 13 | 14 | type: "object", |
| 14 | 15 | properties: { |
| 15 | 16 | repo: REPO_ARG, |
| 16 | 17 | env: { type: "string" }, |
| 17 | 18 | name: { type: "string" }, |
| 18 | action: { type: "string", enum: ["redeploy", "stop", "pull", "schedules", "run_schedule", "pause_schedule", "resume_schedule"] }, | |
| 19 | action: { type: "string", enum: ["redeploy", "stop", "pull", "schedules", "run_schedule", "pause_schedule", "resume_schedule", "jobs", "cancel_job", "retry_job"] }, | |
| 19 | 20 | }, |
| 20 | 21 | required: ["repo", "action"], |
| 21 | 22 | }, |
| @@ -33,12 +34,20 @@ export const tool: Tool = { | ||
| 33 | 34 | const rows = scheduleRows(ctx.app, user, repo); |
| 34 | 35 | return rows.ok ? good({ items: rows.value }) : bad("not_found", "No such app."); |
| 35 | 36 | } |
| 37 | if (action === "jobs") { | |
| 38 | const rows = await jobRows(ctx.app, user, repo); | |
| 39 | return rows.ok ? good({ items: rows.value }) : bad(rows.error, rows.error === "unreachable" ? JOB_WORDS.unreachable : "No such app, or you may not read its jobs."); | |
| 40 | } | |
| 41 | if (action === "cancel_job" || action === "retry_job") { | |
| 42 | const done = await steerJob(ctx.app, user, repo, str(args["name"]) ?? "", action.slice(0, 5)); | |
| 43 | return done.ok ? good({ done: true }) : bad(done.error, done.error === "unreachable" || done.error === "not_now" || done.error === "invalid" ? JOB_WORDS[done.error] : "No such app, or you may not steer its jobs."); | |
| 44 | } | |
| 36 | 45 | if (action === "run_schedule" || action === "pause_schedule" || action === "resume_schedule") { |
| 37 | 46 | const done = steerSchedule(ctx.app, user, repo, str(args["name"]) ?? "", env || "production", action.slice(0, action.indexOf("_")) as "run" | "pause" | "resume"); |
| 38 | 47 | return done.ok ? good({ done: true }) : bad(done.error, done.error === "forbidden" ? "A writer steers a schedule." : "No such schedule."); |
| 39 | 48 | } |
| 40 | 49 | if (action !== "redeploy" && action !== "stop") |
| 41 | return bad("invalid_action", "action is redeploy, stop, pull, schedules, run_schedule, pause_schedule or resume_schedule"); | |
| 50 | return bad("invalid_action", "action is redeploy, stop, pull, schedules, run_schedule, pause_schedule, resume_schedule, jobs, cancel_job or retry_job"); | |
| 42 | 51 | if (action === "stop") { |
| 43 | 52 | const stopped = await stopEnv(ctx.app, user, repo, env); |
| 44 | 53 | return stopped.ok |
src/http/server.ts +3
| @@ -240,6 +240,7 @@ import { | ||
| 240 | 240 | newIssueSubmit, |
| 241 | 241 | } from "../web/pages/issues.ts"; |
| 242 | 242 | import { adapterPull } from "../web/studio/adapter.ts"; |
| 243 | import { jobsPage, steerJobPage } from "../web/studio/jobs.ts"; | |
| 243 | 244 | import { steerSchedulePage } from "../web/studio/schedules.ts"; |
| 244 | 245 | import { noteModerate } from "../web/studio/notes.ts"; |
| 245 | 246 | import { tellPage } from "../web/studio/tell.ts"; |
| @@ -1410,6 +1411,8 @@ export function buildRouter(): Router<Handler> { | ||
| 1410 | 1411 | router.add("GET", "/apps/open", appsOpen); |
| 1411 | 1412 | router.add("POST", "/:owner/:name/app/notes", noteModerate); |
| 1412 | 1413 | router.add("POST", "/:owner/:name/app/pull", adapterPull); |
| 1414 | router.add("GET", "/:owner/:name/app/jobs", jobsPage); | |
| 1415 | router.add("POST", "/:owner/:name/app/jobs/:id/:action", steerJobPage); | |
| 1413 | 1416 | router.add("POST", "/:owner/:name/app/schedules/:schedule/:action", steerSchedulePage); |
| 1414 | 1417 | router.add("POST", "/:owner/:name/app/:env/redeploy", appRedeploy); |
| 1415 | 1418 | router.add("POST", "/:owner/:name/app/:env/stop", appStop); |
src/tui/screens/jobs.ts added +87
| @@ -0,0 +1,87 @@ | ||
| 1 | /** | |
| 2 | * An app's durable job runs, in the terminal (#11596): the page's rows | |
| 3 | * (`web/studio/jobs.ts`) from the same actions (`actions/jobs.ts`), gated the | |
| 4 | * same way: a writer reads and steers. The runs live in the app itself, so | |
| 5 | * the screen asks it (waking it) on open and after each write, and keeps the | |
| 6 | * answer; `c` cancels the run under the cursor, `t` retries a failed one from | |
| 7 | * its failed step, `g` reads again. A write is an event on the bus, so an | |
| 8 | * open web page redraws. | |
| 9 | */ | |
| 10 | ||
| 11 | import { requireWriter } from "../../actions/access.ts"; | |
| 12 | import { JOB_WORDS, jobRows, jobTimers, steerJob, type JobError, type JobRunRow } from "../../actions/jobs.ts"; | |
| 13 | import type { Repo } from "../../core/repos.ts"; | |
| 14 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 15 | import type { Command } from "../app/palette.ts"; | |
| 16 | import { binding } from "../app/keys.ts"; | |
| 17 | import { style } from "../term/style.ts"; | |
| 18 | import { truncate } from "../term/width.ts"; | |
| 19 | import type { Session } from "../session.ts"; | |
| 20 | import { fill, len, split } from "../ui/layout.ts"; | |
| 21 | import { ListState, drawList, type ListRow } from "./common.ts"; | |
| 22 | ||
| 23 | export const JOB_TUI_WORDS: Record<JobError, string> = { | |
| 24 | ...JOB_WORDS, | |
| 25 | not_found: "No such app.", | |
| 26 | unauthorized: "Sign in to see job runs.", | |
| 27 | forbidden: "A writer reads and steers job runs.", | |
| 28 | }; | |
| 29 | ||
| 30 | export function jobsScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | |
| 31 | const state = new ListState(); | |
| 32 | let rows: JobRunRow[] = []; | |
| 33 | let note = "Reading the runs from the app…"; | |
| 34 | const read = async (): Promise<void> => { | |
| 35 | const got = await jobRows(f.app, f.user, repo); | |
| 36 | rows = got.ok ? got.value : []; | |
| 37 | note = got.ok ? (rows.length === 0 ? "This app has no job runs." : "c cancels an unfinished run, t retries a failed one, g reads again.") : JOB_TUI_WORDS[got.error]; | |
| 38 | ctx.invalidate(); | |
| 39 | }; | |
| 40 | const steer = async (action: "cancel" | "retry"): Promise<void> => { | |
| 41 | const at = rows[state.index]; | |
| 42 | if (at === undefined) return; | |
| 43 | const done = await steerJob(f.app, f.user, repo, at.id, action); | |
| 44 | ctx.toast(done.ok ? `${at.job} ${at.key}: ${action === "cancel" ? "cancelled" : "retrying"}.` : JOB_TUI_WORDS[done.error], done.ok ? undefined : "danger"); | |
| 45 | await read(); | |
| 46 | }; | |
| 47 | return { | |
| 48 | title: `${repo.ownerName}/${repo.name} — Job runs`, | |
| 49 | keys: [binding(["j", "k"], "move cursor"), binding(["c"], "cancel"), binding(["t"], "retry from the failed step"), binding(["g"], "read again")], | |
| 50 | async load() { | |
| 51 | await read(); | |
| 52 | }, | |
| 53 | render(area, s) { | |
| 54 | const c = ctx.theme.c; | |
| 55 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 56 | s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 57 | const lines: ListRow[] = rows.map((r) => ({ | |
| 58 | title: `${r.job} ${r.key}`, | |
| 59 | meta: r.now, | |
| 60 | ...(r.error === null ? {} : { note: r.error }), | |
| 61 | })); | |
| 62 | drawList(s, list!, c, state, lines); | |
| 63 | }, | |
| 64 | key(e) { | |
| 65 | if (state.key(e, rows.length)) return true; | |
| 66 | if (e.ctrl || e.alt) return false; | |
| 67 | if (e.key === "g") void read(); | |
| 68 | else if (e.key === "c") void steer("cancel"); | |
| 69 | else if (e.key === "t") void steer("retry"); | |
| 70 | else return false; | |
| 71 | return true; | |
| 72 | }, | |
| 73 | }; | |
| 74 | } | |
| 75 | ||
| 76 | /** The palette's command while a repository whose app holds job timers is open; a writer's only. */ | |
| 77 | export function jobsCommands(f: Session, ctx: Ctx, repo: Repo | undefined): Command[] { | |
| 78 | if (repo === undefined || !requireWriter(f.app, f.user, repo).ok || jobTimers(f.app, repo) === 0) return []; | |
| 79 | return [ | |
| 80 | { | |
| 81 | id: "jobs-app", | |
| 82 | title: `${repo.ownerName}/${repo.name} — Job runs`, | |
| 83 | hint: "this app's durable job runs: cancel one, retry a failed one", | |
| 84 | run: () => ctx.push(jobsScreen(f, ctx, repo)), | |
| 85 | }, | |
| 86 | ]; | |
| 87 | } |
src/tui/screens/workspace.ts +2
| @@ -171,6 +171,7 @@ import { autonomyCommands } from "./autonomy.ts"; | ||
| 171 | 171 | import { trafficCommands } from "./trafficview.ts"; |
| 172 | 172 | import { chaptersCommands } from "./chapters.ts"; |
| 173 | 173 | import { schedulesCommands } from "./schedules.ts"; |
| 174 | import { jobsCommands } from "./jobs.ts"; | |
| 174 | 175 | import { queueCommands } from "./queuegrowth.ts"; |
| 175 | 176 | import { blockedCommands } from "./blocked.ts"; |
| 176 | 177 | import { statementCommands } from "./statement.ts"; |
| @@ -1996,6 +1997,7 @@ export function workspaceScreen( | ||
| 1996 | 1997 | ...autonomyCommands(f, ctx, repo), |
| 1997 | 1998 | ...trafficCommands(f, ctx, repo), |
| 1998 | 1999 | ...schedulesCommands(f, ctx, repo), |
| 2000 | ...jobsCommands(f, ctx, repo), | |
| 1999 | 2001 | ...chaptersCommands(f, ctx, repo), |
| 2000 | 2002 | ...queueCommands(f, ctx, repo), |
| 2001 | 2003 | ...blockedCommands(f, ctx, repo), |
src/web/studio/app.ts +2 −1
| @@ -60,6 +60,7 @@ import { nowCard, nowRegion } from "./now.ts"; | ||
| 60 | 60 | import { repoAsks } from "./asks.ts"; |
| 61 | 61 | import { notesCard } from "./notes.ts"; |
| 62 | 62 | import { adapterCard } from "./adapter.ts"; |
| 63 | import { jobsLink } from "./jobs.ts"; | |
| 63 | 64 | import { schedulesCard } from "./schedules.ts"; |
| 64 | 65 | import { appPanels } from "./previews.ts"; |
| 65 | 66 | import { noCrewLine, pausedWords, servingHistory, type WordCtx } from "./words.ts"; |
| @@ -495,7 +496,7 @@ export async function studioApp( | ||
| 495 | 496 | const adapter = ownerFacts |
| 496 | 497 | ? await timePhaseAsync(req, "adapter", () => adapterCard(ctx, record, repo)) |
| 497 | 498 | : null; |
| 498 | const schedules = ownerFacts ? schedulesCard(ctx, repo) : null; | |
| 499 | const schedules = ownerFacts ? html`${schedulesCard(ctx, repo)}${jobsLink(ctx, repo)}` : null; | |
| 499 | 500 | const rendering = performance.now(); |
| 500 | 501 | const tellRegion = html`${tell} |
| 501 | 502 | ${ownerFacts && ctx.auth.user ? answersRegion(ctx, repo, ctx.auth.user) : null}`; |
src/web/studio/jobs.ts added +86
| @@ -0,0 +1,86 @@ | ||
| 1 | /** | |
| 2 | * An app's durable job runs on the forge (#11596): `/:owner/:name/app/jobs` | |
| 3 | * lists each run (name, key, started, where it is now: running, sleeping until, | |
| 4 | * waiting for, done, failed) and, for a writer, **Cancel** on an unfinished run | |
| 5 | * and **Retry from the failed step** on a failed one. The words are `jobRows` | |
| 6 | * in `actions/jobs.ts`, shared with the API and the MCP tool; this module adds | |
| 7 | * markup. The runs are read from the app itself, so the page wakes it; the app | |
| 8 | * page only links here, and only when the app holds a job timer. A run's text | |
| 9 | * is the app's own and goes through `html`, which escapes it. The forms POST to | |
| 10 | * `/:owner/:name/app/jobs/:id/:action`, same-origin checked by `readForm`. | |
| 11 | */ | |
| 12 | ||
| 13 | import { requireWriter } from "../../actions/access.ts"; | |
| 14 | import { JOB_WORDS, jobRows, jobTimers, steerJob, type JobRunRow } from "../../actions/jobs.ts"; | |
| 15 | import type { Repo } from "../../core/repos.ts"; | |
| 16 | import { notePhase, takeSessionTime, timePhaseAsync } from "../../core/route_timing.ts"; | |
| 17 | import type { Ctx } from "../../http/context.ts"; | |
| 18 | import { crumbsFor, loadRepo, notFoundPage, pageHead, repoBase, signInFirst } from "../chrome.ts"; | |
| 19 | import { readForm } from "../form.ts"; | |
| 20 | import { html, type Html } from "../html.ts"; | |
| 21 | import { page, redirect } from "../layout.ts"; | |
| 22 | import { badge, btn, empty, time, type Tone } from "../ui.ts"; | |
| 23 | ||
| 24 | const TONE: Record<JobRunRow["status"], Tone> = { running: "attn", sleeping: "default", waiting: "default", done: "success", failed: "danger", cancelled: "default" }; | |
| 25 | ||
| 26 | /** A line on the app page, when the app has job timers pending: where to see the runs. */ | |
| 27 | export function jobsLink(ctx: Ctx, repo: Repo): Html | null { | |
| 28 | if (!requireWriter(ctx.app, ctx.auth.user, repo).ok) return null; | |
| 29 | const n = jobTimers(ctx.app, repo); | |
| 30 | if (n === 0) return null; | |
| 31 | return html`<section class="jobs" id="jobs"><h2>Jobs</h2> | |
| 32 | <p>${String(n)} ${n === 1 ? "run is" : "runs are"} waiting on a timer. <a href="${repoBase(repo)}/app/jobs">See the job runs</a>.</p></section>`; | |
| 33 | } | |
| 34 | ||
| 35 | function runRow(base: string, r: JobRunRow): Html { | |
| 36 | const act = (action: string, label: string): Html => html`<form method="post" action="${`${base}/app/jobs/${r.id}/${action}`}">${btn(label, { size: "sm", variant: "outline" })}</form>`; | |
| 37 | return html`<tr> | |
| 38 | <td><code>${r.job}</code></td> | |
| 39 | <td class="break"><code>${r.key}</code></td> | |
| 40 | <td>${r.started === 0 ? null : time(new Date(r.started).toISOString())}</td> | |
| 41 | <td>${badge(r.status, { tone: TONE[r.status] })} <span class="break">${r.now}</span> | |
| 42 | ${r.error === null ? null : html`<br><span class="muted break">${r.error}</span>`} | |
| 43 | ${r.result === null ? null : html`<br><span class="muted break">${r.result}</span>`}</td> | |
| 44 | <td>${r.status === "failed" ? act("retry", "Retry from the failed step") : r.status === "running" || r.status === "sleeping" || r.status === "waiting" ? act("cancel", "Cancel") : null}</td> | |
| 45 | </tr>`; | |
| 46 | } | |
| 47 | ||
| 48 | // GET /:owner/:name/app/jobs | |
| 49 | export async function jobsPage(ctx: Ctx): Promise<Response> { | |
| 50 | const repo = loadRepo(ctx); | |
| 51 | if (!repo) return notFoundPage(ctx); | |
| 52 | if (ctx.auth.user === undefined) return signInFirst(ctx); | |
| 53 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 54 | const rows = await timePhaseAsync(ctx.request, "read", () => jobRows(ctx.app, ctx.auth.user!, repo)); | |
| 55 | if (!rows.ok && (rows.error === "not_found" || rows.error === "forbidden" || rows.error === "unauthorized")) { | |
| 56 | notePhase(ctx.request, "render", 0); | |
| 57 | return notFoundPage(ctx); | |
| 58 | } | |
| 59 | const base = repoBase(repo); | |
| 60 | const body = !rows.ok | |
| 61 | ? empty({ icon: "alert", title: "The job runs could not be read", note: rows.error === "unreachable" ? JOB_WORDS.unreachable : "Try again in a moment." }) | |
| 62 | : rows.value.length === 0 | |
| 63 | ? empty({ icon: "clock", title: "No job runs yet", note: "A run appears here when the app starts one with `job(…).start(…)`." }) | |
| 64 | : html`<table class="table"><thead><tr><th>Job</th><th>Key</th><th>Started</th><th>Where it is now</th><th></th></tr></thead> | |
| 65 | <tbody>${rows.value.map((r) => runRow(base, r))}</tbody></table>`; | |
| 66 | const rendering = performance.now(); | |
| 67 | const out = page({ | |
| 68 | ctx, | |
| 69 | title: `Jobs · ${repo.name}`, | |
| 70 | crumbs: crumbsFor(repo), | |
| 71 | body: html`${pageHead("Job runs")}<section data-live="schedule.steered,run.finished" data-live-repo="${repo.ownerName}/${repo.name}">${body}</section>`, | |
| 72 | }); | |
| 73 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 74 | return out; | |
| 75 | } | |
| 76 | ||
| 77 | // POST /:owner/:name/app/jobs/:id/:action — then back to the list. | |
| 78 | export async function steerJobPage(ctx: Ctx): Promise<Response> { | |
| 79 | const repo = loadRepo(ctx); | |
| 80 | if (!repo || !requireWriter(ctx.app, ctx.auth.user, repo).ok) return notFoundPage(ctx); | |
| 81 | const form = await readForm(ctx, "rw"); | |
| 82 | if (form instanceof Response) return form; | |
| 83 | const done = await steerJob(ctx.app, ctx.auth.user, repo, ctx.params["id"] ?? "", ctx.params["action"] ?? ""); | |
| 84 | const why = done.ok ? undefined : done.error === "unreachable" || done.error === "not_now" || done.error === "invalid" ? JOB_WORDS[done.error] : "Not allowed."; | |
| 85 | return redirect(`${repoBase(repo)}/app/jobs`, { url: ctx.url, forgeUrl: ctx.app.config.url, ...(why === undefined ? {} : { flash: { tone: "danger", message: why } }) }); | |
| 86 | } |
templates/app-template.json +8 −5
Diff not shown — 256,191 bytes of diff (limit 100,000). +8 −5
View filetest/helpers/template-app.ts added +22
| @@ -0,0 +1,22 @@ | ||
| 1 | /** | |
| 2 | * The app template's files, as a new app gets them (`templates/app-template.json`), | |
| 3 | * written to a directory so the forge's tests can import and run the very text it | |
| 4 | * hands out (#11596: the job runtime and its own tests live in the template). | |
| 5 | */ | |
| 6 | import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { dirname, join } from "node:path"; | |
| 9 | ||
| 10 | const bundle = JSON.parse(readFileSync(new URL("../../templates/app-template.json", import.meta.url), "utf8")) as { files: Record<string, string> }; | |
| 11 | ||
| 12 | /** The template written under a fresh directory; the path of that directory. */ | |
| 13 | export function writeTemplate(): string { | |
| 14 | const dir = mkdtempSync(join(process.env["TMPDIR"] ?? tmpdir(), "forge-tpl-")); | |
| 15 | for (const [path, body] of Object.entries(bundle.files)) { | |
| 16 | mkdirSync(dirname(join(dir, path)), { recursive: true }); | |
| 17 | writeFileSync(join(dir, path), body); | |
| 18 | } | |
| 19 | return dir; | |
| 20 | } | |
| 21 | ||
| 22 | export const TEMPLATE_DIR = writeTemplate(); |
test/jobs-doors.test.ts added +259
| @@ -0,0 +1,259 @@ | ||
| 1 | /** | |
| 2 | * Durable job runs through the forge's doors (#11596): the page, the API and | |
| 3 | * the MCP tool read the runs from the app itself, signed as a timer's delivery | |
| 4 | * is, and steer them with Cancel and Retry; a writer only. The app here is the | |
| 5 | * template's own runtime (`src/jobs.ts` in `templates/app-template.json`) behind a fake reach that | |
| 6 | * checks the signature the way `once.ts`'s `fromForge` does. A job that failed | |
| 7 | * for good asks the owner once, however many wakes it had. | |
| 8 | */ | |
| 9 | import { createHmac } from "node:crypto"; | |
| 10 | import { Database } from "bun:sqlite"; | |
| 11 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 12 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 13 | import { tmpdir } from "node:os"; | |
| 14 | import { join } from "node:path"; | |
| 15 | import { createApp, type App } from "../src/app.ts"; | |
| 16 | import { createRepo } from "../src/actions/repos.ts"; | |
| 17 | import { JOB_WORDS, BODY_MAX, jobReach, jobRows, readCapped, steerJob } from "../src/actions/jobs.ts"; | |
| 18 | import { scheduleLines } from "../src/actions/schedules.ts"; | |
| 19 | import { fireTimer, type Reach } from "../src/apps/timer_fire.ts"; | |
| 20 | import { TEMPLATE_DIR } from "./helpers/template-app.ts"; | |
| 21 | import { createApp as insertApp } from "../src/core/apps.ts"; | |
| 22 | import type { Repo } from "../src/core/repos.ts"; | |
| 23 | import { createToken } from "../src/core/tokens.ts"; | |
| 24 | import { armTimer, dueTimers } from "../src/core/timers.ts"; | |
| 25 | import { createUser, type User } from "../src/core/users.ts"; | |
| 26 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 27 | import { jobsCommands, jobsScreen } from "../src/tui/screens/jobs.ts"; | |
| 28 | import type { Ctx as TuiCtx } from "../src/tui/app/app.ts"; | |
| 29 | import type { Session } from "../src/tui/session.ts"; | |
| 30 | import { promote } from "./helpers/members.ts"; | |
| 31 | ||
| 32 | // The app's runtime is the template's own file, as a new app carries it; tsc cannot see into it, so what these tests use is named here. | |
| 33 | interface Run { id: string; key: string; status: string } | |
| 34 | interface Jobs { | |
| 35 | job(name: string, fn: (ctx: any, input: any) => Promise<unknown>): unknown; | |
| 36 | start(name: string, input: unknown, key?: string): Promise<Run>; | |
| 37 | deliver(id: string, wake?: number): Promise<string>; | |
| 38 | get(id: string): Run | undefined; | |
| 39 | list(): Run[]; | |
| 40 | } | |
| 41 | const { Jobs, jobsRoute } = (await import(`${TEMPLATE_DIR}/src/jobs.ts`)) as { Jobs: new (db: Database, o: unknown) => Jobs; jobsRoute: (j: Jobs, r: Request, u: URL, good: boolean) => Promise<Response> }; | |
| 42 | ||
| 43 | const KEY = "an-identity-key"; | |
| 44 | const DAY = 86_400_000; | |
| 45 | ||
| 46 | describe("job runs over the page, the API and the MCP tool", () => { | |
| 47 | let dataDir: string; | |
| 48 | let app: App; | |
| 49 | let servers: Servers; | |
| 50 | let cy: User; | |
| 51 | let dee: User; | |
| 52 | let repo: Repo; | |
| 53 | let appId: string; | |
| 54 | let ownerRw: string; | |
| 55 | let readerRo: string; | |
| 56 | let cookie: string; | |
| 57 | let jobs: Jobs; | |
| 58 | let appAsleep = false; | |
| 59 | const clock = Date.parse("2026-11-12T00:00:00Z"); | |
| 60 | ||
| 61 | // The app: the template's jobs behind the signature check, no process. | |
| 62 | const reach: Reach = { | |
| 63 | ensure: async () => (appAsleep ? undefined : 4321), | |
| 64 | identityKey: () => KEY, | |
| 65 | fetch: (async (input: string | URL, init?: RequestInit) => { | |
| 66 | const req = new Request(String(input), init); | |
| 67 | const identity = req.headers.get("x-forge-identity") ?? ""; | |
| 68 | const [who, signed, , expires] = identity.split("|"); | |
| 69 | const good = createHmac("sha256", KEY).update(identity).digest("base64url") === req.headers.get("x-forge-signature") && who === "forge-timer" && signed === req.headers.get("idempotency-key") && Number(expires) > clock; | |
| 70 | return jobsRoute(jobs, req, new URL(req.url), good); | |
| 71 | }) as typeof fetch, | |
| 72 | }; | |
| 73 | ||
| 74 | const call = (method: string, path: string, token: string | undefined, body?: unknown) => | |
| 75 | fetch(`${servers.url}${path}`, { method, headers: { connection: "close", ...(token ? { authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { "content-type": "application/json" }) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); | |
| 76 | ||
| 77 | beforeAll(async () => { | |
| 78 | dataDir = await mkdtemp(join(tmpdir(), "forge-jobs-")); | |
| 79 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 80 | servers = await serve(app); | |
| 81 | const make = async (username: string, adminIfFirst = false) => { | |
| 82 | const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst }); | |
| 83 | if (!made.ok) throw new Error(made.error); | |
| 84 | return made.value; | |
| 85 | }; | |
| 86 | await make("ada", true); | |
| 87 | cy = await make("cy"); | |
| 88 | promote(app.db, cy); | |
| 89 | dee = await make("dee"); | |
| 90 | const made = await createRepo(app, cy, { name: "shop", visibility: "public", description: "" }); | |
| 91 | if (!made.ok) throw new Error(String(made.error)); | |
| 92 | repo = made.value; | |
| 93 | const row = insertApp(app.db, { repoId: repo.id, slug: "shop", access: "public" }); | |
| 94 | if (!row.ok) throw new Error(row.error); | |
| 95 | appId = row.value.id; | |
| 96 | ownerRw = createToken(app.db, cy.id, "me", "rw").token; | |
| 97 | readerRo = createToken(app.db, dee.id, "me", "ro").token; | |
| 98 | const r = await fetch(`${servers.url}/signin`, { method: "POST", body: new URLSearchParams({ username: "cy", password: "a-good-passphrase" }), redirect: "manual" }); | |
| 99 | cookie = `${(r.headers.get("set-cookie") ?? "").split(";")[0]!}; forge_mode=dev`; | |
| 100 | jobReach.override = reach; | |
| 101 | ||
| 102 | // The app's runs: one sleeping, one waiting, one failed for good. | |
| 103 | jobs = new Jobs(new Database(":memory:"), { now: () => clock, timers: { arm: async () => undefined, cancel: async () => undefined } }); | |
| 104 | jobs.job("remind", async (ctx) => { | |
| 105 | await ctx.step("load", () => 1); | |
| 106 | await ctx.sleep("wait", { days: 3 }); | |
| 107 | }); | |
| 108 | jobs.job("order", async (ctx) => ctx.waitFor("reply", { event: "reply:1" })); | |
| 109 | jobs.job("pay", async (ctx) => ctx.step("charge", () => { throw new Error("card <b>declined</b>"); }, { retries: 0 })); | |
| 110 | }, 60_000); | |
| 111 | ||
| 112 | afterAll(async () => { | |
| 113 | jobReach.override = undefined; | |
| 114 | servers?.stop(); | |
| 115 | app?.close(); | |
| 116 | await rm(dataDir, { recursive: true, force: true }); | |
| 117 | }); | |
| 118 | ||
| 119 | test("a writer reads each run and where it is now; a reader and a guest are refused", async () => { | |
| 120 | const a = await jobs.start("remind", {}, "remind:1"); | |
| 121 | const b = await jobs.start("order", {}, "order:1"); | |
| 122 | const c = await jobs.start("pay", {}, "pay:1"); | |
| 123 | for (const r of [a, b, c]) await jobs.deliver(r.id); | |
| 124 | const rows = await jobRows(app, cy, repo); | |
| 125 | expect(rows.ok && rows.value.map((r) => [r.key, r.now])).toEqual([ | |
| 126 | ["pay:1", "failed at charge"], | |
| 127 | ["order:1", "waiting for reply:1"], | |
| 128 | ["remind:1", "sleeping until 2026-11-15 00:00 UTC"], | |
| 129 | ]); | |
| 130 | expect(await jobRows(app, dee, repo)).toEqual({ ok: false, error: "forbidden" }); | |
| 131 | expect(await jobRows(app, undefined, repo)).toMatchObject({ ok: false }); | |
| 132 | // The API says the same, to a writer, and 404s or refuses the rest. | |
| 133 | const api = await call("GET", "/api/v1/repos/cy/shop/jobs", ownerRw); | |
| 134 | expect(api.status).toBe(200); | |
| 135 | expect(((await api.json()) as { items: { key: string }[] }).items.map((i) => i.key)).toEqual(["pay:1", "order:1", "remind:1"]); | |
| 136 | expect((await call("GET", "/api/v1/repos/cy/shop/jobs", readerRo)).status).toBeGreaterThanOrEqual(403); | |
| 137 | }); | |
| 138 | ||
| 139 | test("the page lists the runs with their errors escaped, Cancel on an unfinished run and Retry on a failed one", async () => { | |
| 140 | const page = await (await fetch(`${servers.url}/cy/shop/app/jobs`, { headers: { cookie, connection: "close" } })).text(); | |
| 141 | expect(page).toContain("waiting for reply:1"); | |
| 142 | expect(page).toContain("sleeping until 2026-11-15 00:00 UTC"); | |
| 143 | expect(page).toContain("card <b>declined</b>"); | |
| 144 | expect(page).not.toContain("card <b>declined"); | |
| 145 | expect(page).toContain("Retry from the failed step"); | |
| 146 | expect(page.match(/>\s*Cancel\s*</g)?.length).toBe(2); | |
| 147 | // The app page links to it once a job holds a timer. | |
| 148 | expect(await (await fetch(`${servers.url}/cy/shop/app`, { headers: { cookie, connection: "close" } })).text()).not.toContain("See the job runs"); | |
| 149 | armTimer(app.db, { appId, env: "production", key: "job:abc:1", path: "/jobs/run", at: clock + DAY, timeoutMs: 1000, retries: 0 }, clock); | |
| 150 | expect(await (await fetch(`${servers.url}/cy/shop/app`, { headers: { cookie, connection: "close" } })).text()).toContain("See the job runs"); | |
| 151 | }); | |
| 152 | ||
| 153 | test("Cancel and Retry steer the run through the API, the MCP tool and the page's form; a state that does not allow it is 422", async () => { | |
| 154 | const [pay, order] = [jobs.list().find((r) => r.key === "pay:1")!, jobs.list().find((r) => r.key === "order:1")!]; | |
| 155 | expect((await call("POST", `/api/v1/repos/cy/shop/jobs/${order.id}/cancel`, readerRo)).status).toBeGreaterThanOrEqual(403); | |
| 156 | expect(jobs.get(order.id)?.status).toBe("waiting"); | |
| 157 | expect((await call("POST", `/api/v1/repos/cy/shop/jobs/${order.id}/cancel`, ownerRw)).status).toBe(200); | |
| 158 | expect(jobs.get(order.id)?.status).toBe("cancelled"); | |
| 159 | expect((await call("POST", `/api/v1/repos/cy/shop/jobs/${order.id}/cancel`, ownerRw)).status).toBe(422); | |
| 160 | expect((await call("POST", `/api/v1/repos/cy/shop/jobs/${order.id}/explode`, ownerRw)).status).toBe(422); | |
| 161 | ||
| 162 | const heard: string[] = []; | |
| 163 | const off = app.bus.subscribe((e) => heard.push(e.kind)); | |
| 164 | const mcp = await call("POST", "/mcp", ownerRw, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "app_deploy", arguments: { repo: "cy/shop", action: "retry_job", name: pay.id } } }); | |
| 165 | expect(JSON.stringify(await mcp.json())).not.toContain("isError\":true"); | |
| 166 | expect(jobs.get(pay.id)?.status).toBe("running"); | |
| 167 | off(); | |
| 168 | expect(heard).toContain("schedule.steered"); | |
| 169 | const listed = await call("POST", "/mcp", ownerRw, { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "app_deploy", arguments: { repo: "cy/shop", action: "jobs" } } }); | |
| 170 | expect(JSON.stringify(await listed.json())).toContain("remind:1"); | |
| 171 | ||
| 172 | // The form on the page: the same-origin check holds and the run changes. | |
| 173 | const form = await fetch(`${servers.url}/cy/shop/app/jobs/${jobs.list().find((r) => r.key === "remind:1")!.id}/cancel`, { method: "POST", headers: { cookie, connection: "close", origin: servers.url }, redirect: "manual" }); | |
| 174 | expect(form.status).toBe(303); | |
| 175 | expect(jobs.list().find((r) => r.key === "remind:1")?.status).toBe("cancelled"); | |
| 176 | const foreign = await fetch(`${servers.url}/cy/shop/app/jobs/${pay.id}/cancel`, { method: "POST", headers: { cookie, connection: "close", origin: "https://evil.example" }, redirect: "manual" }); | |
| 177 | expect(foreign.status).not.toBe(303); | |
| 178 | }); | |
| 179 | ||
| 180 | test("Cancel and Retry are plain POST forms, so the page works with JavaScript off", async () => { | |
| 181 | const page = await (await fetch(`${servers.url}/cy/shop/app/jobs`, { headers: { cookie, connection: "close" } })).text(); | |
| 182 | const forms = page.match(/<form method="post" action="[^"]*\/app\/jobs\/[^"]+\/(?:cancel|retry)">/g) ?? []; | |
| 183 | expect(forms.length).toBeGreaterThan(0); | |
| 184 | // A guest never sees the steering forms. | |
| 185 | expect(await (await fetch(`${servers.url}/cy/shop/app/jobs`, { headers: { connection: "close" } })).text()).not.toContain("/app/jobs/"); | |
| 186 | }); | |
| 187 | ||
| 188 | test("an answer past the byte cap is read as a stream, stopped, and called unreachable", async () => { | |
| 189 | const big = new Response(new ReadableStream({ pull: (c) => c.enqueue(new Uint8Array(400_000).fill(32)) })); | |
| 190 | expect(await readCapped(big, BODY_MAX)).toBeUndefined(); | |
| 191 | expect(await readCapped(new Response('{"runs":[]}'), BODY_MAX)).toBe('{"runs":[]}'); | |
| 192 | const saved = reach.fetch; | |
| 193 | (reach as { fetch: typeof fetch }).fetch = (async () => new Response(new ReadableStream({ pull: (c) => c.enqueue(new Uint8Array(400_000).fill(32)) }))) as unknown as typeof fetch; | |
| 194 | try { | |
| 195 | expect(await jobRows(app, cy, repo)).toEqual({ ok: false, error: "unreachable" }); | |
| 196 | } finally { | |
| 197 | (reach as { fetch: typeof fetch }).fetch = saved; | |
| 198 | } | |
| 199 | }); | |
| 200 | ||
| 201 | test("the terminal door lists the runs and cancels or retries them for a writer only", async () => { | |
| 202 | const session = (user: User | undefined) => ({ user, db: app.db, app }) as unknown as Session; | |
| 203 | const toasts: string[] = []; | |
| 204 | const ctx = { toast: (t: string) => toasts.push(t), invalidate() {}, push() {}, theme: {} } as unknown as TuiCtx; | |
| 205 | // The palette offers it to a writer once the app holds a job timer, and to no one else. | |
| 206 | expect(jobsCommands(session(cy), ctx, repo)).toHaveLength(1); | |
| 207 | expect(jobsCommands(session(dee), ctx, repo)).toEqual([]); | |
| 208 | expect(jobsCommands(session(undefined), ctx, repo)).toEqual([]); | |
| 209 | expect(jobsCommands(session(cy), ctx, undefined)).toEqual([]); | |
| 210 | ||
| 211 | const run = await jobs.start("remind", {}, "remind:tui"); | |
| 212 | await jobs.deliver(run.id); | |
| 213 | expect(jobs.get(run.id)?.status).toBe("sleeping"); | |
| 214 | const screen = jobsScreen(session(cy), ctx, repo); | |
| 215 | await screen.load?.(new AbortController().signal); | |
| 216 | // The newest run is the cursor's: c cancels it through the same action the page uses. | |
| 217 | screen.key?.({ key: "c" } as never); | |
| 218 | await new Promise((r) => setTimeout(r, 50)); | |
| 219 | expect(jobs.get(run.id)?.status).toBe("cancelled"); | |
| 220 | expect(toasts.at(-1)).toBe("remind remind:tui: cancelled."); | |
| 221 | // A cancelled run cannot be retried: the app's refusal is said in words. | |
| 222 | screen.key?.({ key: "t" } as never); | |
| 223 | await new Promise((r) => setTimeout(r, 50)); | |
| 224 | expect(toasts.at(-1)).toBe(JOB_WORDS.not_now); | |
| 225 | ||
| 226 | // A reader's screen reads nothing and steers nothing. | |
| 227 | const readerToasts: string[] = []; | |
| 228 | const readerCtx = { toast: (t: string) => readerToasts.push(t), invalidate() {}, push() {}, theme: {} } as unknown as TuiCtx; | |
| 229 | const reader = jobsScreen(session(dee), readerCtx, repo); | |
| 230 | await reader.load?.(new AbortController().signal); | |
| 231 | reader.key?.({ key: "c" } as never); | |
| 232 | await new Promise((r) => setTimeout(r, 50)); | |
| 233 | expect(readerToasts.at(-1)).toBeUndefined(); | |
| 234 | }); | |
| 235 | ||
| 236 | test("an app that does not answer is said so, not guessed at", async () => { | |
| 237 | appAsleep = true; | |
| 238 | expect(await jobRows(app, cy, repo)).toEqual({ ok: false, error: "unreachable" }); | |
| 239 | expect(await steerJob(app, cy, repo, "abc", "cancel")).toEqual({ ok: false, error: "unreachable" }); | |
| 240 | expect((await call("GET", "/api/v1/repos/cy/shop/jobs", ownerRw)).status).toBe(502); | |
| 241 | appAsleep = false; | |
| 242 | }); | |
| 243 | ||
| 244 | test("a run that failed for good is recorded at once, not delivered again, and asks the owner once", async () => { | |
| 245 | const run = await jobs.start("pay", {}, "pay:2"); | |
| 246 | const body = JSON.stringify({ run: run.id, wake: 1 }); | |
| 247 | armTimer(app.db, { appId, env: "production", key: `job:${run.id}:1`, path: "/jobs/run", at: clock, body: new TextEncoder().encode(body), timeoutMs: 1000, retries: 3 }, clock); | |
| 248 | const [t] = dueTimers(app.db, clock).filter((x) => x.key === `job:${run.id}:1`); | |
| 249 | // The app would sign this delivery itself; here the forge's own signer is the one under test. | |
| 250 | await fireTimer(app.db, reach, t!, () => clock); | |
| 251 | expect(dueTimers(app.db, clock + 10 * DAY).some((x) => x.key === `job:${run.id}:1`)).toBe(false); | |
| 252 | const lines = scheduleLines(app, cy, clock); | |
| 253 | const mine = lines.filter((l) => l.items[0]!.startsWith("job:")); | |
| 254 | expect(mine).toHaveLength(1); | |
| 255 | expect(mine[0]!.line).toContain('Step "charge" failed after 1 attempt: card <b>declined</b>'); | |
| 256 | expect(scheduleLines(app, cy, clock)[0]!.items).toEqual(lines[0]!.items); | |
| 257 | expect(scheduleLines(app, dee, clock)).toEqual([]); | |
| 258 | }); | |
| 259 | }); |
test/template-jobs.test.ts added +4
| @@ -0,0 +1,4 @@ | ||
| 1 | // The template's own job tests (src/jobs.test.ts in a new app), run here too, on the files as the bundle carries them. | |
| 2 | import { TEMPLATE_DIR } from "./helpers/template-app.ts"; | |
| 3 | ||
| 4 | await import(`${TEMPLATE_DIR}/src/jobs.test.ts`); |
new above
offline