Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 48 additions & 9 deletions cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,9 @@ is the only one accepted. Changing the hostname orphans existing passkeys;
people enroll new ones after signing in with a provider.

GitHub is authoritative for enrolled project state. D1 holds accounts,
sessions, GitHub App state, and jobs. The one Durable Object, `RunnerHub`, holds
runners' live connections and keeps no records of its own.
sessions, GitHub App state, and jobs. The R2 bucket bound as `OUTPUT` holds raw
agent output. The one Durable Object, `RunnerHub`, holds runners' live
connections and keeps no records of its own.

### Jobs

Expand All @@ -79,8 +80,8 @@ observations show ready and unclaimed. `iterate` jobs are untrusted and route
only to Linux runners that declare VM builds. Without a `flake.nix`, the route
offers a `bootstrap` job instead. A paused project gets no new jobs and none are
claimed; resuming it dispatches the jobs that waited. Disconnecting a project
deletes its jobs and tells their runners to stop. Duplicate requests return the
active job for the same target.
deletes its jobs and their output and tells their runners to stop. Duplicate
requests return the active job for the same target.

Runners are a user's own machines. Each one registers at `POST /api/runners`
and gets a bearer token, which kix stores only as a hash. With that token a
Expand All @@ -91,8 +92,7 @@ routed, and the open connection is the runner's presence.
Over the socket the runner sends `status` messages: its opaque accounts, the
attempts it is running, and its free job slots. It also sends `checkpoint`,
`question`, `output`, and `result` messages. Each gets a `reply` with the same
`id` (`src/jobs/protocol.ts`). Output chunks carry a per-attempt `seq`, so a
retried chunk lands once, and maintainers read output in pages. The hub pushes `assign` and `cancel` messages
`id` (`src/jobs/protocol.ts`). The hub pushes `assign` and `cancel` messages
whenever jobs or connections change. A status that lists an assigned attempt
acknowledges it; until then the assignment holds one of the runner's slots and
the hub resends it, and cancelling a
Expand All @@ -111,6 +111,36 @@ An account limit pauses the job with an account decision; kix never switches
accounts on its own. An answer requeues the job and resumes its session on the
runner that holds it.

### Raw output

Runners upload raw agent output as `output` messages of at most 1 MiB of UTF-8
(`src/jobs/output.ts`). Each attempt numbers its chunks with a contiguous `seq`
from 0 and sends them in order: kix accepts only the attempt's next `seq`,
once the chunk before it is stored, or an identical retry of a chunk it still
holds, which succeeds without writing again. A different chunk with a used `seq` is refused, and so is a retry of an
expired one. Each chunk is one R2 object at `output/<job>/<attempt>/<seq>`,
readable as soon as it lands, and D1 keeps a small index row per chunk plus each
attempt's totals.

An attempt keeps at most 64 MiB and 4096 chunks. The chunk that would pass
either cap, and every later one, gets a reply with `truncated: true` instead of
an error; the job keeps running and its summaries are unaffected. Expiring
chunks never give that capacity back.

Maintainers read output at `GET /projects/github/<owner>/<repo>/jobs/<job>/output`,
in pages of four chunks ordered by `(attempt, seq)`; `?after=<attempt>.<seq>`
continues from a page's `next`, and `truncated` lists the attempts that reached
the cap. Every request rechecks current maintainer authority.

Output is kept for 30 days from each chunk's upload. A bucket lifecycle rule
deletes `output/` objects, and a daily cron (`triggers.crons` in
`wrangler.jsonc`) deletes their index rows; reads skip a row whose object is
already gone. Disconnecting a project queues its jobs' output for deletion in
the same D1 batch that deletes the jobs, then deletes each `output/<job>/`
prefix at once. The cron retries any deletion that failed. An upload racing the
disconnect finds its index row gone, queues its job's prefix for the cron again,
and deletes its own object.

`cloudflare/` is a Bun workspace of the repository root, so the Worker
imports the CLI's core as `quest/core` (`../src/core`), the same parser, checks,
and readiness the CLI runs.
Expand Down Expand Up @@ -169,7 +199,8 @@ replay, and ownership. Job tests race dispatch passes, reassign a disconnected
runner's job and fence its late writes, and cover routing, questions, account
limits, and cancellation. They drive real WebSocket connections through the hub,
including a replaced connection and the alarm that reassigns work, and check the
maintainer-only routes. No OAuth credentials or
maintainer-only routes. Output tests use local R2 for chunk ordering, retries,
the caps, expiry, and cleanup after a disconnect. No OAuth credentials or
Cloudflare account are required for checks.

After changing bindings, regenerate types:
Expand All @@ -192,17 +223,25 @@ from CI. In the Nix shell, enter `cloudflare/` and authenticate:
cd cloudflare
bun run wrangler login
bun run wrangler d1 create quest-auth
bun run wrangler r2 bucket create quest-output
bun run wrangler r2 bucket lifecycle add quest-output output-expiry output/ --expire-days 30
```

The lifecycle rule is bucket configuration, not part of `wrangler.jsonc`, so
check it on an existing bucket with
`bun run wrangler r2 bucket lifecycle list quest-output`. Keep it at the
30 days the Worker's cron uses (`RETENTION` in `src/jobs/output.ts`).

The checked-in config deploys to `https://kix.quest` as a Workers custom
domain, which requires the `kix.quest` zone in the same Cloudflare account;
`workers.dev` is disabled so sign-in has one origin. `AUTH_URL` is the only
authentication origin and supplies generated absolute links.

For an existing deployment, retain the Worker name, D1 database ID, and
`AUTH_SECRET` so account data is preserved. The `d1 create` command above is
`AUTH_SECRET` so account data is preserved. The `create` commands above are
for a fresh deployment only. For another environment, change `routes`,
`AUTH_URL`, Worker name, account, database, and OAuth credentials together.
`AUTH_URL`, Worker name, account, database, bucket, and OAuth credentials
together.

Register the production GitHub OAuth app with callback
`https://kix.quest/api/auth/callback/github`. For Google sign-in, also register
Expand Down
37 changes: 37 additions & 0 deletions cloudflare/migrations/0008_output.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
-- Raw agent output moves to R2, one object per chunk at
-- output/<job>/<attempt>/<seq>. Nothing is deployed yet, so the D1 text is
-- dropped rather than copied.
DROP TABLE job_output;
-- Each attempt's output totals. It lives as long as the job, so expiring
-- chunks never give capacity back and an expired `seq` is never reused.
CREATE TABLE job_output_attempts (
job TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
attempt INTEGER NOT NULL,
runner TEXT NOT NULL,
-- The only `seq` the attempt accepts next; also its count of chunks.
next INTEGER NOT NULL DEFAULT 0,
bytes INTEGER NOT NULL DEFAULT 0,
-- Set once a chunk is refused for the attempt's cap; later chunks are too.
truncated INTEGER NOT NULL DEFAULT 0 CHECK(truncated IN (0,1)),
PRIMARY KEY(job,attempt)
);
-- The index of stored chunks, readable only by current project maintainers.
-- A row reserves its `seq` for one text, by hash, before the object is
-- written; `complete` marks the object written. The daily cron deletes rows
-- older than the bucket's 30-day lifecycle.
CREATE TABLE job_output (
job TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
attempt INTEGER NOT NULL,
seq INTEGER NOT NULL,
hash TEXT NOT NULL,
complete INTEGER NOT NULL DEFAULT 0 CHECK(complete IN (0,1)),
at INTEGER NOT NULL,
PRIMARY KEY(job,attempt,seq)
);
CREATE INDEX job_output_at ON job_output(at);
-- Jobs whose output objects a disconnect still has to delete. Written in the
-- same batch that deletes the project, and retried by the daily cron.
CREATE TABLE output_cleanup (
job TEXT PRIMARY KEY NOT NULL,
at INTEGER NOT NULL
);
6 changes: 5 additions & 1 deletion cloudflare/src/github/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -554,11 +554,15 @@ export class GitHubService {
// Recheck the local revocation gate after potentially long content reads.
await this.#gate(installation);
if (state === "disconnected") {
// Jobs go with the project, so read the running attempts in the same transaction.
// Jobs go with the project, so read the running attempts in the same transaction,
// and queue the deletion of any output objects, which R2 cannot do in it.
const [running] = await this.#env.DB.batch<Stopped>([
this.#env.DB.prepare(
"SELECT actor,runner,id AS job,attempt FROM jobs WHERE repository=? AND state='running'",
).bind(repo.id),
this.#env.DB.prepare(
"INSERT OR IGNORE INTO output_cleanup(job,at) SELECT DISTINCT a.job,? FROM job_output_attempts a JOIN jobs j ON j.id=a.job WHERE j.repository=?",
).bind(Date.now(), repo.id),
this.#env.DB.prepare("DELETE FROM github_projects WHERE id=?").bind(repo.id),
]);
return running!.results;
Expand Down
11 changes: 9 additions & 2 deletions cloudflare/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ import { Home } from "./home";
import { type Enrollment, GitHubError, GitHubService } from "./github/service";
import { ProjectsPage, ProjectSetupPage, ProjectStopControls, ProjectStatePage, projectURL } from "./projects";
import { githubWebhooks } from "./github/webhook";
import { purge, retain } from "./jobs/output";
import { jobs, resume, stop } from "./jobs/routes";
import { JobError } from "./jobs/store";
import { Layout, type Page, type User } from "./layout";

const app = new Hono<{ Bindings: Env }>();
export const app = new Hono<{ Bindings: Env }>();
// Browsers send `Origin: null` on form posts under the default "no-referrer"
// policy, which would fail the origin check on /sign-in and /sign-out.
app.use(secureHeaders({ referrerPolicy: "same-origin" }));
Expand Down Expand Up @@ -164,6 +165,7 @@ app.post("/projects/github/:owner/:repository", async (c) => {
);
if (state === "enabled") await resume(c.env, Number(id));
await stop(c.env, stopped);
if (state === "disconnected") await purge(c.env);
return c.redirect(
state === "disconnected" ? "/projects" : projectURL(c.req.param("owner"), c.req.param("repository")),
303,
Expand Down Expand Up @@ -436,5 +438,10 @@ app.onError((error, c) => {
console.error({ event: "request_failed", path: c.req.path, name: error.name });
return c.json({ error: "Request failed" }, 500);
});
export default app;
export default {
fetch: app.fetch,
async scheduled(_controller, env) {
await retain(env, Date.now());
},
} satisfies ExportedHandler<Env>;
export { RunnerHub } from "./jobs/hub";
5 changes: 3 additions & 2 deletions cloudflare/src/jobs/hub.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DurableObject } from "cloudflare:workers";
import * as output from "./output";
import { MessageError, type RunnerMessage, parse } from "./protocol";
import * as store from "./store";
import { JobError } from "./store";
Expand Down Expand Up @@ -149,8 +150,8 @@ export class RunnerHub extends DurableObject<Env> {
case "question":
return store.ask(db, runner, message.job, message.attempt, message, now);
case "output":
await store.output(db, runner, message, now);
return {};
// Past the attempt's cap the reply says `truncated`, and the job carries on.
return output.append(this.env, runner, message, now);
case "result":
await store.finish(db, runner, message.job, message.attempt, message, now);
return {};
Expand Down
183 changes: 183 additions & 0 deletions cloudflare/src/jobs/output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// Raw agent output: one R2 object per chunk, indexed in D1.
//
// An attempt's chunks arrive in order, `seq` 0, 1, 2, ... Each is reserved
// first: an index row records its text's hash while the job exists, so a
// conflicting chunk is refused before anything reaches R2. The object is
// written next and the row marked complete, only while the attempt is
// current. A chunk is reserved only once the one before it is complete, so an
// attempt's one pending chunk is its last, and readers that see complete rows
// never page past a chunk that may still land. R2 cannot join a D1 batch, so
// a disconnect or reassignment between those steps leaves no row to complete,
// and the upload deletes its own object.
import { JobError, type Attempt, type Runner } from "./store";

type Storage = Pick<Env, "DB" | "OUTPUT">;

/** Raw output kept per attempt, in UTF-8 bytes. */
export const MAX_BYTES = 64 * 1024 * 1024;
/** Chunks kept per attempt, so tiny chunks cannot multiply rows and objects. */
export const MAX_CHUNKS = 4096;
/** How long output is kept after each chunk's upload; the bucket lifecycle rule matches it. */
export const RETENTION = 30 * 24 * 60 * 60_000;

const key = (job: string, attempt: number, seq: number) => `output/${job}/${attempt}/${seq}`;

async function digest(text: string) {
const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
return Array.from(new Uint8Array(bytes), (b) => b.toString(16).padStart(2, "0")).join("");
}

/**
* Store a chunk of the current attempt's output, in any job state. Only the
* attempt's next `seq` is accepted, or an identical retry of one still
* indexed. Past the attempt's cap, chunks are refused as `truncated`; the
* job carries on.
*/
export async function append(
{ DB: db, OUTPUT: bucket }: Storage,
runner: Runner,
{ job, attempt, seq, text }: Attempt & { seq: number; text: string },
now: number,
): Promise<{ truncated?: true }> {
const bytes = new TextEncoder().encode(text).length;
const hash = await digest(text);
const current = "EXISTS(SELECT 1 FROM jobs WHERE id=?1 AND attempt=?2 AND runner=?3)";
const next = "job=?1 AND attempt=?2 AND runner=?3 AND next=?4";
const params = [job, attempt, runner.id, seq, bytes, hash, now];
const statement = (sql: string, n: number) => db.prepare(sql).bind(...params.slice(0, n));
const [, , , , read] = await db.batch([
statement(
`INSERT INTO job_output_attempts(job,attempt,runner) SELECT ?1,?2,?3 WHERE ${current} ON CONFLICT DO NOTHING`,
3,
),
// The truncation is recorded once, by the first chunk over the cap.
statement(
`UPDATE job_output_attempts SET truncated=1 WHERE ${next} AND truncated=0 AND (bytes+?5>${MAX_BYTES} OR next>=${MAX_CHUNKS}) AND ${current}`,
5,
),
statement(
`INSERT INTO job_output(job,attempt,seq,hash,at) SELECT ?1,?2,?4,?6,?7 FROM job_output_attempts WHERE ${next} AND truncated=0 AND ${current}
Comment thread
kixelated marked this conversation as resolved.
Resolved
AND NOT EXISTS(SELECT 1 FROM job_output WHERE job=?1 AND attempt=?2 AND seq=?4-1 AND complete=0)`,
7,
),
statement(
`UPDATE job_output_attempts SET next=next+1,bytes=bytes+?5 WHERE ${next} AND EXISTS(SELECT 1 FROM job_output WHERE job=?1 AND attempt=?2 AND seq=?4)`,
5,
),
statement(
`SELECT a.runner,a.next,a.truncated,o.hash,o.complete,${current} AS current FROM (SELECT 1)
LEFT JOIN job_output_attempts a ON a.job=?1 AND a.attempt=?2 LEFT JOIN job_output o ON o.job=?1 AND o.attempt=?2 AND o.seq=?4`,
4,
),
]);
const row = read!.results[0] as {
runner: string | null;
next: number | null;
truncated: number | null;
hash: string | null;
complete: number | null;
current: number;
};
if (row.hash !== null && row.runner === runner.id) {
if (row.hash !== hash) throw new JobError("A different chunk has that sequence number");
// A retry of a completed chunk writes nothing, so its lifecycle does not restart.
if (row.complete) return {};
const name = key(job, attempt, seq);
await bucket.put(name, text);
const done = await db
.prepare(
`UPDATE job_output SET complete=1,at=?6 WHERE job=?1 AND attempt=?2 AND seq=?4 AND hash=?5 AND ${current}`,
)
// Retention starts with the upload, as the bucket lifecycle's does.
.bind(job, attempt, runner.id, seq, hash, now)
.run();
if (done.meta.changes === 1) return {};
// The job was disconnected or reassigned while the object was written. A
// disconnect's cleanup may already have listed the prefix, so queue
// another in case this delete fails.
await db
.prepare(
"INSERT OR IGNORE INTO output_cleanup(job,at) SELECT ?1,?2 WHERE NOT EXISTS(SELECT 1 FROM jobs WHERE id=?1)",
)
.bind(job, now)
.run();
await bucket.delete(name);
Comment thread
kixelated marked this conversation as resolved.
Resolved
throw new JobError("Attempt is no longer current");
}
if (!row.current || row.runner !== runner.id || row.next === null)
throw new JobError("Attempt is no longer current");
if (seq < row.next) throw new JobError("That chunk has expired");
if (row.truncated) return { truncated: true };
if (seq === row.next) throw new JobError(`Chunk ${seq - 1} is not stored yet`);
throw new JobError(`Expected sequence ${row.next}`);
}

/**
* A page of stored raw output, at most a few MiB, in order; `next` continues
* it. Only stored chunks are listed: an attempt's one pending chunk is its
* last and lands only while the attempt is current, so a cursor never passes
* a chunk that may still land.
* `truncated` lists the attempts whose output reached the cap.
*/
export async function read(
{ DB: db, OUTPUT: bucket }: Storage,
job: string,
after: { attempt: number; seq: number } | null,
) {
const page = 4;
const [rows, capped] = await db.batch([
db
.prepare(
"SELECT attempt,seq,at FROM job_output WHERE job=? AND complete=1 AND (attempt,seq)>(?,?) ORDER BY attempt,seq LIMIT ?",
)
.bind(job, after?.attempt ?? 0, after?.seq ?? -1, page),
db.prepare("SELECT attempt FROM job_output_attempts WHERE job=? AND truncated=1 ORDER BY attempt").bind(job),
]);
const index = rows!.results as { attempt: number; seq: number; at: number }[];
const chunks = await Promise.all(
index.map(async (chunk) => {
const object = await bucket.get(key(job, chunk.attempt, chunk.seq));
// The lifecycle rule may delete an object before the cron deletes its row.
return object && { ...chunk, text: await object.text() };
}),
);
const last = index.length === page ? index.at(-1)! : null;
return {
output: chunks.filter((chunk) => chunk !== null),
next: last && { attempt: last.attempt, seq: last.seq },
truncated: (capped!.results as { attempt: number }[]).map((row) => row.attempt),
};
}

/** Delete the output objects of jobs a disconnect removed. A failure stays queued for the next run. */
export async function purge({ DB: db, OUTPUT: bucket }: Storage) {
const { results } = await db.prepare("SELECT job FROM output_cleanup ORDER BY at,job").all<{ job: string }>();
for (const { job } of results) {
try {
const prefix = `output/${job}/`;
for (
let listed = await bucket.list({ prefix });
listed.objects.length;
listed = await bucket.list({ prefix })
)
await bucket.delete(listed.objects.map((object) => object.key));
await db.prepare("DELETE FROM output_cleanup WHERE job=?").bind(job).run();
} catch (error) {
console.error({ event: "output_cleanup_failed", name: error instanceof Error ? error.name : "unknown" });
}
}
}

/** The daily cron: forget expired chunks, whose objects the lifecycle rule deletes, and retry cleanups. */
export async function retain(storage: Storage, now: number) {
const batch = 1000;
for (;;) {
const deleted = await storage.DB.prepare(
"DELETE FROM job_output WHERE rowid IN (SELECT rowid FROM job_output WHERE at<=? LIMIT ?)",
)
.bind(now - RETENTION, batch)
.run();
if (deleted.meta.changes < batch) break;
}
await purge(storage);
}
Loading
Loading