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

Merged opened this in answer to

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

Merged

torque/forge:torque/11596-a-durable-job-an-app-writes-steps-sleeps → main 8 commits fork

  • Merged. torque/11596-a-durable-job-an-app-writes-steps-sleeps was merged into main as 05dcc56.

Review

Sign in to review this change.
24 files changed +763 −17
AGENTS.md +3 −1

Diff not shown — a line 2,248 characters wide (limit 2,000). +3 −1

View file
docs/APPS.md +6
@@ -1279,6 +1279,12 @@ Declare schedules in `forge.json`:
12791279
12801280 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`.
12811281
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
12821288 ## Where state lives
12831289
12841290 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`;
4949 | `accounts` | — | Accounts, newest first: role, verified, suspended; `cursor` pages | 264 |
5050 | `agent` | — | Admin: create\|update\|remove an agent | 371 |
5151 | `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 |
5353 | `app_domains` | — | App domains: list, add, check, remove | 201 |
5454 | `app_env` | `list`, `set`, `unset` | An app's environment variables: list names, set or unset by scope; write-only | 393 |
5555 | `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";
1010 import { getAppByRepo } from "../core/apps.ts";
1111 import { canAdmin, listReposWritableTo, type Repo } from "../core/repos.ts";
1212 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";
1414 import { err, ok, type Result } from "../kernel/result.ts";
1515 import { requireReader, requireWriter } from "./access.ts";
1616
@@ -108,7 +108,7 @@ export function steerSchedule(app: App, actor: User | undefined, repo: Repo, nam
108108 * with the reason. The item is the schedule, not the run, so a schedule that
109109 * keeps failing is one ask, not one a day (`crew/notify.ts` says only what is new).
110110 */
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 }[] {
112112 if (user.kind !== "user") return [];
113113 const out: { line: string; items: string[]; link: string }[] = [];
114114 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
118118 const why = run.status === "failed" ? `Schedule ${run.name} failed: ${run.error}` : run.error;
119119 out.push({ line: `${repo.name}: ${why}`, items: [`schedule:${record.id}:${run.env}:${run.name}`], link: `/${repo.ownerName}/${repo.name}#schedules` });
120120 }
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 }
121129 }
122130 return out;
123131 }
src/apps/timer_fire.ts +22 −3
@@ -63,6 +63,23 @@ export function deliveryHeaders(key: string, t: Timer, attempt: number, expires:
6363 };
6464 }
6565
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
6683 async function readHead(res: Response): Promise<string> {
6784 const reader = res.body?.getReader();
6885 if (reader === undefined) return "";
@@ -78,7 +95,7 @@ async function readHead(res: Response): Promise<string> {
7895 return new TextDecoder().decode(Buffer.concat(chunks)).slice(0, RESPONSE_KEPT).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "?");
7996 }
8097
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 }
8299
83100 async function attemptOnce(reach: Reach, app: AppRecord, t: Timer, attempt: number, now: () => number): Promise<Attempt> {
84101 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
94111 });
95112 const head = await readHead(res);
96113 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}` };
97116 return { ok: false, status: res.status, response: head, error: `the app answered ${res.status}` };
98117 } catch (e) {
99118 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: () =>
153172 finishTimer(db, t, next);
154173 return;
155174 }
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);
157176 recordRun(db, {
158177 ...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,
160179 });
161180 finishTimer(db, t, next);
162181 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
162162 export { route as getApiV1ReposOwnerNameIssuesNumberPlan } from "./getApiV1ReposOwnerNameIssuesNumberPlan.ts";
163163 export { route as getApiV1ReposOwnerNameIssuesNumberTeams } from "./getApiV1ReposOwnerNameIssuesNumberTeams.ts";
164164 export { route as getApiV1ReposOwnerNameIterations } from "./getApiV1ReposOwnerNameIterations.ts";
165 export { route as getApiV1ReposOwnerNameJobs } from "./getApiV1ReposOwnerNameJobs.ts";
165166 export { route as getApiV1ReposOwnerNameLabels } from "./getApiV1ReposOwnerNameLabels.ts";
166167 export { route as getApiV1ReposOwnerNameMap } from "./getApiV1ReposOwnerNameMap.ts";
167168 export { route as getApiV1ReposOwnerNameMilestones } from "./getApiV1ReposOwnerNameMilestones.ts";
@@ -343,6 +344,7 @@ export { route as postApiV1ReposOwnerNameIssuesNumberComments } from "./postApiV
343344 export { route as postApiV1ReposOwnerNameIssuesNumberDependencies } from "./postApiV1ReposOwnerNameIssuesNumberDependencies.ts";
344345 export { route as postApiV1ReposOwnerNameIssuesNumberDependenciesBlockerConfirm } from "./postApiV1ReposOwnerNameIssuesNumberDependenciesBlockerConfirm.ts";
345346 export { route as postApiV1ReposOwnerNameIssuesNumberPrivate } from "./postApiV1ReposOwnerNameIssuesNumberPrivate.ts";
347 export { route as postApiV1ReposOwnerNameJobsIdAction } from "./postApiV1ReposOwnerNameJobsIdAction.ts";
346348 export { route as postApiV1ReposOwnerNameLabels } from "./postApiV1ReposOwnerNameLabels.ts";
347349 export { route as postApiV1ReposOwnerNameMakeItYours } from "./postApiV1ReposOwnerNameMakeItYours.ts";
348350 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>> = {
99 app: "apps",
1010 timers: "apps",
1111 schedules: "apps",
12 jobs: "apps",
1213 "app-templates": "apps",
1314 display: "builds",
1415 following: "forks",
src/core/timers.ts +12
@@ -247,6 +247,18 @@ export function failingNow(db: Database, appId: string): TimerRun[] {
247247 return out;
248248 }
249249
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
250262 /** Drop the schedule rows of environments that are gone (a closed pull's preview). */
251263 export function dropGoneEnvs(db: Database, appId: string, live: ReadonlySet<string>): void {
252264 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";
354354 import { egressGrantRoutes } from "./api/egress_grants.ts";
355355 import { printRoutes } from "./api/print.ts";
356356 import { timersRoutes } from "./api/timers.ts";
357 import { jobsRoutes } from "./api/jobs.ts";
357358 import { schedulesRoutes } from "./api/schedules.ts";
358359 import { forgeRoutes } from "./api/forges.ts";
359360 import { packRoutes } from "./api/packs.ts";
@@ -3862,6 +3863,7 @@ export function apiRoutes(router: Router<Handler>): void {
38623863 printRoutes(router);
38633864 timersRoutes(router);
38643865 schedulesRoutes(router);
3866 jobsRoutes(router);
38653867
38663868 promptRoutes(router);
38673869
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 @@
11 import { envRefusalText } from "../../../actions/env_words.ts";
22 import { pullNow, PULL_NOW_REFUSALS } from "../../../actions/app_pull.ts";
33 import { redeployEnv, stopEnv } from "../../../actions/apps.ts";
4 import { JOB_WORDS, jobRows, steerJob } from "../../../actions/jobs.ts";
45 import { scheduleRows, steerSchedule } from "../../../actions/schedules.ts";
56 import { good, bad, type Tool, str, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
67
78 export const tool: Tool = {
89 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",
1011 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.",
1213 inputSchema: {
1314 type: "object",
1415 properties: {
1516 repo: REPO_ARG,
1617 env: { type: "string" },
1718 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"] },
1920 },
2021 required: ["repo", "action"],
2122 },
@@ -33,12 +34,20 @@ export const tool: Tool = {
3334 const rows = scheduleRows(ctx.app, user, repo);
3435 return rows.ok ? good({ items: rows.value }) : bad("not_found", "No such app.");
3536 }
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 }
3645 if (action === "run_schedule" || action === "pause_schedule" || action === "resume_schedule") {
3746 const done = steerSchedule(ctx.app, user, repo, str(args["name"]) ?? "", env || "production", action.slice(0, action.indexOf("_")) as "run" | "pause" | "resume");
3847 return done.ok ? good({ done: true }) : bad(done.error, done.error === "forbidden" ? "A writer steers a schedule." : "No such schedule.");
3948 }
4049 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");
4251 if (action === "stop") {
4352 const stopped = await stopEnv(ctx.app, user, repo, env);
4453 return stopped.ok
src/http/server.ts +3
@@ -240,6 +240,7 @@ import {
240240 newIssueSubmit,
241241 } from "../web/pages/issues.ts";
242242 import { adapterPull } from "../web/studio/adapter.ts";
243 import { jobsPage, steerJobPage } from "../web/studio/jobs.ts";
243244 import { steerSchedulePage } from "../web/studio/schedules.ts";
244245 import { noteModerate } from "../web/studio/notes.ts";
245246 import { tellPage } from "../web/studio/tell.ts";
@@ -1410,6 +1411,8 @@ export function buildRouter(): Router<Handler> {
14101411 router.add("GET", "/apps/open", appsOpen);
14111412 router.add("POST", "/:owner/:name/app/notes", noteModerate);
14121413 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);
14131416 router.add("POST", "/:owner/:name/app/schedules/:schedule/:action", steerSchedulePage);
14141417 router.add("POST", "/:owner/:name/app/:env/redeploy", appRedeploy);
14151418 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";
171171 import { trafficCommands } from "./trafficview.ts";
172172 import { chaptersCommands } from "./chapters.ts";
173173 import { schedulesCommands } from "./schedules.ts";
174 import { jobsCommands } from "./jobs.ts";
174175 import { queueCommands } from "./queuegrowth.ts";
175176 import { blockedCommands } from "./blocked.ts";
176177 import { statementCommands } from "./statement.ts";
@@ -1996,6 +1997,7 @@ export function workspaceScreen(
19961997 ...autonomyCommands(f, ctx, repo),
19971998 ...trafficCommands(f, ctx, repo),
19981999 ...schedulesCommands(f, ctx, repo),
2000 ...jobsCommands(f, ctx, repo),
19992001 ...chaptersCommands(f, ctx, repo),
20002002 ...queueCommands(f, ctx, repo),
20012003 ...blockedCommands(f, ctx, repo),
src/web/studio/app.ts +2 −1
@@ -60,6 +60,7 @@ import { nowCard, nowRegion } from "./now.ts";
6060 import { repoAsks } from "./asks.ts";
6161 import { notesCard } from "./notes.ts";
6262 import { adapterCard } from "./adapter.ts";
63 import { jobsLink } from "./jobs.ts";
6364 import { schedulesCard } from "./schedules.ts";
6465 import { appPanels } from "./previews.ts";
6566 import { noCrewLine, pausedWords, servingHistory, type WordCtx } from "./words.ts";
@@ -495,7 +496,7 @@ export async function studioApp(
495496 const adapter = ownerFacts
496497 ? await timePhaseAsync(req, "adapter", () => adapterCard(ctx, record, repo))
497498 : null;
498 const schedules = ownerFacts ? schedulesCard(ctx, repo) : null;
499 const schedules = ownerFacts ? html`${schedulesCard(ctx, repo)}${jobsLink(ctx, repo)}` : null;
499500 const rendering = performance.now();
500501 const tellRegion = html`${tell}
501502 ${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 file
test/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 &lt;b&gt;declined&lt;/b&gt;");
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`);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs