From 5b64e59b4674086bcb67217938b1b48f7bdc5391 Mon Sep 17 00:00:00 2001 From: Time Date: Thu, 22 Oct 2026 10:08:59 +0000 Subject: [PATCH] card #223: update quc/src/runs.js --- quc/src/runs.js | 50 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 quc/src/runs.js diff --git a/quc/src/runs.js b/quc/src/runs.js new file mode 100644 index 0000000..c6ce85f --- /dev/null +++ b/quc/src/runs.js @@ -0,0 +1,50 @@ +// Run model: queued -> running -> ended(finished|safeworded|unlocked), an append-only event log per run, one active run per visitor. +// `store` is anything with async get(key) / put(key, string) / delete(key): a Workers KV namespace, or a Map wrapper in tests. +// Keys: run: (record), events: (array), active: (id of that visitor's queued/running run). +// shortcut: KV has no compare-and-set, so two simultaneous starts by one visitor can both pass the check; move to a Durable Object when that matters. +import { validate } from "./worker.js"; + +export const KINDS = ["diff", "commit", "commentary", "unlock"]; +export const OUTCOMES = ["finished", "safeworded", "unlocked"]; +const NEXT = { queued: ["running", "ended"], running: ["ended"] }; + +const fail = (code, message, extra) => Object.assign(new Error(message), { code }, extra); +const load = async (s, k, empty = null) => JSON.parse((await s.get(k)) ?? "null") ?? empty; +const save = (s, k, v) => s.put(k, JSON.stringify(v)); + +export const getRun = (store, id) => load(store, `run:${id}`); +export const getEvents = (store, id) => load(store, `events:${id}`, []); + +export async function createRun(store, { repo, task, visitor }) { + if (typeof visitor !== "string" || !visitor) throw fail("visitor", "visitor token required"); + const errors = validate(repo, task); + if (Object.keys(errors).length) throw fail("invalid", "invalid run", { errors }); + if (await store.get(`active:${visitor}`)) throw fail("active", "visitor already has an active run"); + const run = { id: crypto.randomUUID().slice(0, 8), repo, task, status: "queued", visitor, createdAt: new Date().toISOString() }; + await save(store, `run:${run.id}`, run); + await store.put(`active:${visitor}`, run.id); + return run; +} + +export async function setStatus(store, id, status, outcome) { + const run = await getRun(store, id); + if (!run) throw fail("not_found", "no such run"); + if (!NEXT[run.status]?.includes(status)) throw fail("transition", `cannot go ${run.status} -> ${status}`); + if (status === "ended" && !OUTCOMES.includes(outcome)) throw fail("outcome", "ended needs an outcome: " + OUTCOMES.join("|")); + const at = new Date().toISOString(); + run.status = status; + if (status === "running") run.startedAt = at; + else Object.assign(run, { endedAt: at, outcome }); + await save(store, `run:${id}`, run); + if (status === "ended") await store.delete(`active:${run.visitor}`); + return run; +} + +export async function addEvent(store, id, kind, data) { + if (!KINDS.includes(kind)) throw fail("kind", "event kind must be " + KINDS.join("|")); + if (!(await getRun(store, id))) throw fail("not_found", "no such run"); + const events = await getEvents(store, id); + const event = { seq: events.length + 1, at: new Date().toISOString(), kind, data }; + await save(store, `events:${id}`, [...events, event]); + return event; +}