quc v0 (1/4): validate, diff, run model
validate.js is shared by the page and the server; diff.js is a Myers line diff with unified patches (fuzzed against GNU patch); runs.js is #239's run model extended with notes, vetoes, the work store and a memory store for tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EVg99kQMY2Tumhobuyg7Fm
This commit is contained in:
1 parent
582695df5a
commit
4895ac430f
3 files changed
+148
No files matched your search
@@ -0,0 +1,74 @@
|
||||
// Line diffs for the chair view and the patch download. Myers' O(ND) algorithm, then unified-diff hunks.
|
||||
|
||||
const lines = (s) => (s === "" ? [] : s.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n"));
|
||||
|
||||
// Edit script between two line arrays: [{op: " " | "-" | "+", line}].
|
||||
export function diffLines(a, b) {
|
||||
const n = a.length, m = b.length, max = n + m;
|
||||
const v = new Int32Array(2 * max + 2), trace = [];
|
||||
for (let d = 0; d <= max; d++) {
|
||||
trace.push(v.slice());
|
||||
for (let k = -d; k <= d; k += 2) {
|
||||
let x = k === -d || (k !== d && v[max + k - 1] < v[max + k + 1]) ? v[max + k + 1] : v[max + k - 1] + 1;
|
||||
let y = x - k;
|
||||
while (x < n && y < m && a[x] === b[y]) { x++; y++; }
|
||||
v[max + k] = x;
|
||||
if (x >= n && y >= m) return backtrack(trace, a, b, max);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function backtrack(trace, a, b, max) {
|
||||
const out = [];
|
||||
let x = a.length, y = b.length;
|
||||
for (let d = trace.length - 1; d >= 0; d--) {
|
||||
const v = trace[d], k = x - y;
|
||||
const prevK = k === -d || (k !== d && v[max + k - 1] < v[max + k + 1]) ? k + 1 : k - 1;
|
||||
const prevX = v[max + prevK], prevY = prevX - prevK;
|
||||
while (x > prevX && y > prevY) out.push({ op: " ", line: a[--x] }), y--;
|
||||
if (d > 0) out.push(x === prevX ? { op: "+", line: b[--y] } : { op: "-", line: a[--x] });
|
||||
}
|
||||
return out.reverse();
|
||||
}
|
||||
|
||||
// Unified diff of one file. Returns {text, added, removed}; text is "" when nothing changed.
|
||||
export function unified(path, before, after, context = 3) {
|
||||
const a = lines(before ?? ""), b = lines(after ?? "");
|
||||
const ops = diffLines(a, b);
|
||||
let added = 0, removed = 0;
|
||||
for (const o of ops) o.op === "+" ? added++ : o.op === "-" ? removed++ : 0;
|
||||
if (!added && !removed) return { text: "", added, removed };
|
||||
const hunks = [];
|
||||
let i = 0, ai = 0, bi = 0;
|
||||
while (i < ops.length) {
|
||||
if (ops[i].op === " ") { i++; ai++; bi++; continue; }
|
||||
const start = Math.max(0, i - context);
|
||||
let end = i, j = i;
|
||||
while (j < ops.length) {
|
||||
if (ops[j].op !== " ") { end = j; j++; continue; }
|
||||
let run = 0;
|
||||
while (j + run < ops.length && ops[j + run].op === " ") run++;
|
||||
if (j + run >= ops.length || run > 2 * context) break;
|
||||
j += run;
|
||||
}
|
||||
const stop = Math.min(ops.length, end + 1 + context);
|
||||
const lead = i - start;
|
||||
let aStart = ai - lead, bStart = bi - lead, aLen = 0, bLen = 0;
|
||||
const body = [];
|
||||
for (let t = start; t < stop; t++) {
|
||||
const o = ops[t];
|
||||
body.push(o.op + o.line);
|
||||
if (o.op !== "+") aLen++;
|
||||
if (o.op !== "-") bLen++;
|
||||
}
|
||||
for (let t = i; t < stop; t++) {
|
||||
if (ops[t].op !== "+") ai++;
|
||||
if (ops[t].op !== "-") bi++;
|
||||
}
|
||||
hunks.push(`@@ -${aLen ? aStart + 1 : aStart},${aLen} +${bLen ? bStart + 1 : bStart},${bLen} @@\n${body.join("\n")}`);
|
||||
i = stop;
|
||||
}
|
||||
const from = before == null ? "/dev/null" : `a/${path}`, to = after == null ? "/dev/null" : `b/${path}`;
|
||||
return { text: `--- ${from}\n+++ ${to}\n${hunks.join("\n")}\n`, added, removed };
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// 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, opts?) / delete(key): a Workers KV namespace, or memoryStore() in tests.
|
||||
// Keys: run:<id> (record), events:<id> (array), work:<id> (repo text + edits), active:<visitor> (that visitor's queued/running run).
|
||||
// shortcut: KV has no compare-and-set and is eventually consistent across locations, so two simultaneous starts by one visitor can
|
||||
// both pass the check, and a reader far from the writer can lag a few seconds; move runs to a Durable Object when that matters.
|
||||
import { validate } from "./validate.js";
|
||||
|
||||
export const KINDS = ["diff", "commit", "commentary", "unlock", "note", "system"];
|
||||
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 putRun = (store, run) => save(store, `run:${run.id}`, run);
|
||||
export const getEvents = (store, id) => load(store, `events:${id}`, []);
|
||||
export const getWork = (store, id) => load(store, `work:${id}`, null);
|
||||
export const putWork = (store, id, work) => save(store, `work:${id}`, work);
|
||||
export const dropWork = (store, id) => store.delete(`work:${id}`);
|
||||
|
||||
export function memoryStore() {
|
||||
const m = new Map();
|
||||
return { m, get: async (k) => m.get(k) ?? null, put: async (k, v) => void m.set(k, v), delete: async (k) => void m.delete(k) };
|
||||
}
|
||||
|
||||
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 });
|
||||
const active = await store.get(`active:${visitor}`);
|
||||
if (active) {
|
||||
const r = await getRun(store, active);
|
||||
if (r && r.status !== "ended") throw fail("active", "you already have a run going", { id: active });
|
||||
}
|
||||
const run = { id: crypto.randomUUID().replace(/-/g, "").slice(0, 10), repo: repo.replace(/\.git\/?$|\/$/g, ""), task, status: "queued",
|
||||
visitor, createdAt: new Date().toISOString(), level: 0, notes: [], vetoed: [], plan: null, queue: [], done: [], stats: { files: 0, added: 0, removed: 0 } };
|
||||
await putRun(store, run);
|
||||
await store.put(`active:${visitor}`, run.id);
|
||||
return run;
|
||||
}
|
||||
|
||||
export async function setStatus(store, run, status, outcome) {
|
||||
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 putRun(store, 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("|"));
|
||||
const events = await getEvents(store, id);
|
||||
const event = { seq: events.length + 1, at: new Date().toISOString(), kind, data };
|
||||
events.push(event);
|
||||
await save(store, `events:${id}`, events);
|
||||
return event;
|
||||
}
|
||||
|
||||
// The run as anyone may see it: no visitor token.
|
||||
export const publicRun = ({ visitor, ...rest }) => rest;
|
||||
@@ -0,0 +1,8 @@
|
||||
// Shared by the page (inlined via toString) and the server. Returns {repo?, task?} errors; {} is valid.
|
||||
export function validate(repo, task) {
|
||||
const e = {};
|
||||
if (!/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+?(\.git)?\/?$/.test(repo)) e.repo = "Use a public GitHub repo URL, like https://github.com/owner/name.";
|
||||
if (task.length < 10) e.task = "Say a bit more: at least 10 characters.";
|
||||
else if (task.length > 200) e.task = "Keep it to one line: 200 characters at most.";
|
||||
return e;
|
||||
}
|
||||
Reference in new issue
Block a user