Compare commits
2
Commits
main
...
time/card-223
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1bd32d617 | ||
|
|
5b64e59b46 |
No files matched your search
@@ -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:<id> (record), events:<id> (array), active:<visitor> (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;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRun, setStatus, addEvent, getRun, getEvents } from "../src/runs.js";
|
||||
|
||||
const mem = () => { const m = new Map(); return { get: async (k) => m.get(k) ?? null, put: async (k, v) => void m.set(k, v), delete: async (k) => void m.delete(k) }; };
|
||||
const input = (visitor = "v1") => ({ repo: "https://github.com/a/b", task: "Fix the flaky date test", visitor });
|
||||
const code = (p) => p.then(() => null, (e) => e.code);
|
||||
|
||||
test("a run walks queued -> running -> ended and persists in the store", async () => {
|
||||
const s = mem();
|
||||
const run = await createRun(s, input());
|
||||
assert.equal(run.status, "queued");
|
||||
assert.equal((await setStatus(s, run.id, "running")).status, "running");
|
||||
const done = await setStatus(s, run.id, "ended", "finished");
|
||||
assert.deepEqual([done.status, done.outcome], ["ended", "finished"]);
|
||||
assert.ok(done.startedAt && done.endedAt);
|
||||
assert.deepEqual(await getRun(s, run.id), done);
|
||||
});
|
||||
|
||||
test("bad transitions, outcomes and inputs are refused", async () => {
|
||||
const s = mem();
|
||||
const { id } = await createRun(s, input());
|
||||
assert.equal(await code(setStatus(s, id, "ended")), "outcome");
|
||||
assert.equal(await code(setStatus(s, id, "queued")), "transition");
|
||||
await setStatus(s, id, "ended", "safeworded");
|
||||
assert.equal(await code(setStatus(s, id, "running")), "transition");
|
||||
assert.equal(await code(setStatus(s, "nope", "running")), "not_found");
|
||||
assert.equal(await code(createRun(s, { ...input(), repo: "x" })), "invalid");
|
||||
assert.equal(await code(createRun(s, input(""))), "visitor");
|
||||
});
|
||||
|
||||
test("one active run per visitor; ending frees the slot; other visitors are unaffected", async () => {
|
||||
const s = mem();
|
||||
const a = await createRun(s, input("v1"));
|
||||
assert.equal(await code(createRun(s, input("v1"))), "active");
|
||||
await setStatus(s, a.id, "running");
|
||||
assert.equal(await code(createRun(s, input("v1"))), "active");
|
||||
await createRun(s, input("v2"));
|
||||
await setStatus(s, a.id, "ended", "unlocked");
|
||||
await createRun(s, input("v1"));
|
||||
});
|
||||
|
||||
test("events append in order and unknown kinds are refused", async () => {
|
||||
const s = mem();
|
||||
const { id } = await createRun(s, input());
|
||||
await addEvent(s, id, "commentary", "reading the tests");
|
||||
await addEvent(s, id, "diff", "--- a\n+++ b");
|
||||
assert.deepEqual((await getEvents(s, id)).map((e) => [e.seq, e.kind]), [[1, "commentary"], [2, "diff"]]);
|
||||
assert.equal(await code(addEvent(s, id, "nope", "")), "kind");
|
||||
assert.equal(await code(addEvent(s, "nope", "commit", "")), "not_found");
|
||||
});
|
||||
Reference in new issue
Block a user