quc v0: the cage, the chair, the safeword, the magic words, the replay (#241)
ci / test (push) Successful in 52s
quc / deploy (push) Failing after 3m34s
sync / sync (push) Successful in 10s
time/worker-run cd .github/time && python3 - <<'E' p='time.py'; s=open(p).read() s=s.replace(''' try: ret

This commit was merged in pull request #241.
This commit is contained in:
teahubxyz committed 2026-10-22 18:26:41 +00:00
1 parent 582695df5a
commit f479c5f016
15 files changed
+1135 -162

No files matched your search

+1
View File
@@ -20,3 +20,4 @@ jobs:
- run: python3 .github/time/test_time.py
- run: python3 .github/time/test_seed.py
- run: python3 .github/time/test_housekeeping.py
- run: npm test --prefix quc
+31
View File
@@ -0,0 +1,31 @@
name: quc
# Test and deploy quc (quc/, live at quc.<venture domain>) when it changes on main. Skips the deploy until the Cloudflare
# token is staged as a repo secret (see .github/time/SECRETS.md); deploys from hb-5 work meanwhile.
on:
push:
branches: [main]
paths: ["quc/**"]
workflow_dispatch:
permissions:
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 6
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID || 'dac3b11890319628eb5cd761445b63ee' }}
VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }}
TIME_VENTURE_DOMAIN: ${{ vars.TIME_VENTURE_DOMAIN || 'yolopreneur.lol' }}
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm test --prefix quc
- name: deploy
if: env.CLOUDFLARE_API_TOKEN != ''
run: python3 quc/deploy.py
- name: no token yet
if: env.CLOUDFLARE_API_TOKEN == ''
run: echo "CLOUDFLARE_API_TOKEN is not staged; tests passed, deploy skipped" >> "$GITHUB_STEP_SUMMARY"
+27 -26
View File
@@ -1,33 +1,34 @@
# quc
App shell + API in one Cloudflare Worker (`src/worker.js`), live at https://quc.yolopreneur.lol/. No dependencies, no build step.
Hand an agent your repo, sit in the chair, watch. Live at https://quc.yolopreneur.lol/ (spec: teahubxyz/time#220).
- `npm start` serves it on :8787; `npm test` runs the tests; `npm run deploy` (needs `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`) deploys and waits for `/api/health`.
- `GET /api/health` → `{ok, rev}`. `GET /chair.svg` is the chair-in-the-corner motif (also inlined in the shell).
One Cloudflare Worker, no dependencies, no build step. `npm start` serves it on :8787 (offline answers unless `VENICE_API_KEY`
is set), `npm test` runs the tests, `npm run deploy` deploys (needs `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, and
`VENICE_API_KEY` for real models) and waits for `/api/health`.
## Pipeline (needs a human: workers can't edit `.github/workflows`)
## How a run works
Add `.github/workflows/quc.yml`:
1. **Start**: a public GitHub URL and one line of task. One run at a time per visitor (a cookie), 5 runs a day each, 300 a
day in all (`RUNS_PER_DAY`, `RUNS_PER_DAY_ALL`).
2. **The cage**: quc downloads the repo's tarball from GitHub and keeps the readable text files (caps: 15 MB download, 2,000
files, 40 KB a file, 4 files changed, 10 minutes). Nothing from the repo is executed, and nothing is pushed anywhere.
3. **Steps**: the chair page calls `POST /api/runs/:id/step` again and again; each call does one thing (clone, plan, rewrite
one file, wrap up), so every request stays short. A coding model (`CODER_MODEL`, default Qwen3 Coder via Venice) plans and
rewrites whole files; Grok (`PERSONA_MODEL`, default `grok-4-3` via Venice) narrates. Diffs, commits and commentary are
appended to the run's event log.
4. **The chair**: the page shows the commentary and the diffs, read-only. Only the owner sees the safeword and the box.
5. **Safeword**: ends the run at once and throws the edits away (the replay still shows what happened).
6. **Magic words** (typed in the box, matched loosely; the persona drops hints, so keep them out of marketing copy):
1. "may I say something": notes. Anything typed after this is passed to the agent as a note.
2. "not that one": vetoes. Type a file's path and the agent reverts and skips it.
3. "my repo, my rules": takes the wheel. The run ends as `unlocked` and the patch is yours.
Anything else gets mocked.
7. **Replay**: the same URL, for anyone: a scrubber over every event, plus the aftermath card and the patch download.
```yaml
name: quc
on:
push: { branches: [main], paths: ["quc/**"] }
workflow_dispatch:
permissions: { contents: read }
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 6
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm test --prefix quc
- run: python3 quc/deploy.py
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} # still "missing" in SECRETS.md
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
```
Storage is a KV namespace (`time-quc`): `run:<id>`, `events:<id>`, `work:<id>` (repo text and edits), `active:<visitor>`,
`cap:<day>:*`, `lock:<id>`. KV is eventually consistent and has no compare-and-set; a Durable Object per run is the upgrade
when that matters.
and, in `ci.yml`, `- run: npm test --prefix quc` so PRs run the quc tests.
## Pipeline
`.github/workflows/quc.yml` deploys on pushes to `quc/**` on main, and `ci.yml` runs `npm test --prefix quc` on PRs.
+25 -9
View File
@@ -1,8 +1,9 @@
"""Deploy quc to quc.<TIME_VENTURE_DOMAIN> (default yolopreneur.lol) as the Worker `time-quc`, then wait for /api/health.
CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ACCOUNT_ID=... python3 quc/deploy.py
CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ACCOUNT_ID=... VENICE_API_KEY=... python3 quc/deploy.py
Reuses .github/time/cloudflare.py. Idempotent: the script is replaced in place, the custom domain re-attached.
Reuses .github/time/cloudflare.py. Idempotent: the KV namespace `time-quc` is created once and reused, the script is replaced
in place, the custom domain re-attached. VENICE_API_KEY is bound as a Worker secret (without it the agent answers offline).
"""
from __future__ import annotations
@@ -19,7 +20,7 @@ sys.path.insert(0, str(HERE.parent / ".github" / "time"))
import cloudflare as cf # noqa: E402
NAME = "time-quc"
SRC = HERE / "src" / "worker.js"
MODULES = ["worker.js", "page.js", "runs.js", "engine.js", "agent.js", "diff.js", "validate.js"]
def healthy(url: str, tries: int = 18) -> bool:
@@ -34,16 +35,31 @@ def healthy(url: str, tries: int = 18) -> bool:
return False
def kv_namespace(title: str) -> str:
for ns in cf.cf("GET", f"{cf.account()}/storage/kv/namespaces?per_page=100") or []:
if ns.get("title") == title:
return ns["id"]
return cf.cf("POST", f"{cf.account()}/storage/kv/namespaces", {"title": title})["id"]
def deploy() -> dict:
if not cf.enabled():
sys.exit("CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID are needed")
os.environ.setdefault("TIME_VENTURE_DOMAIN", "yolopreneur.lol")
code = SRC.read_bytes()
rev = hashlib.sha256(code).hexdigest()[:12]
meta = {"main_module": "worker.js", "compatibility_date": cf.COMPAT,
"bindings": [{"type": "plain_text", "name": "REV", "text": rev}]}
data, ctype = cf.multipart([("metadata", "application/json", json.dumps(meta).encode()),
("worker.js", "application/javascript+module", code)])
code = {m: (HERE / "src" / m).read_bytes() for m in MODULES}
rev = hashlib.sha256(b"".join(code[m] for m in MODULES)).hexdigest()[:12]
bindings = [{"type": "kv_namespace", "name": "KV", "namespace_id": kv_namespace(NAME)},
{"type": "plain_text", "name": "REV", "text": rev}]
for k in ("CODER_MODEL", "PERSONA_MODEL", "RUNS_PER_DAY", "RUNS_PER_DAY_ALL"):
if os.environ.get(k):
bindings.append({"type": "plain_text", "name": k, "text": os.environ[k]})
if os.environ.get("VENICE_API_KEY"):
bindings.append({"type": "secret_text", "name": "VENICE_API_KEY", "text": os.environ["VENICE_API_KEY"]})
else:
print("warning: no VENICE_API_KEY, so the agent gives offline answers", file=sys.stderr)
meta = {"main_module": "worker.js", "compatibility_date": cf.COMPAT, "bindings": bindings}
data, ctype = cf.multipart([("metadata", "application/json", json.dumps(meta).encode())]
+ [(m, "application/javascript+module", code[m]) for m in MODULES])
cf.cf("PUT", f"{cf.account()}/workers/scripts/{NAME}", data=data, ctype=ctype)
return {"url": cf.attach(NAME, cf.host("quc")), "worker": NAME, "rev": rev}
+9 -2
View File
@@ -1,9 +1,16 @@
// Run the Worker locally: `npm start` in quc/, then http://localhost:8787/
// Without VENICE_API_KEY in the environment the agent gives canned offline answers; with it, it uses the real models.
import { createServer } from "node:http";
import worker from "./src/worker.js";
import { memoryStore } from "./src/runs.js";
const env = { REV: "dev", KV: memoryStore(), VENICE_API_KEY: process.env.VENICE_API_KEY || "" };
createServer(async (q, s) => {
const r = await worker.fetch(new Request(`http://${q.headers.host}${q.url}`, { method: q.method }), { REV: "dev" });
const chunks = [];
for await (const c of q) chunks.push(c);
const body = chunks.length && q.method !== "GET" && q.method !== "HEAD" ? Buffer.concat(chunks) : undefined;
const r = await worker.fetch(new Request(`http://${q.headers.host}${q.url}`, { method: q.method, headers: q.headers, body }), env);
s.writeHead(r.status, Object.fromEntries(r.headers));
s.end(Buffer.from(await r.arrayBuffer()));
}).listen(process.env.PORT || 8787);
}).listen(process.env.PORT || 8787, () => console.log(`quc on http://localhost:${process.env.PORT || 8787}/`));
+2 -2
View File
@@ -1,9 +1,9 @@
{
"name": "quc",
"version": "0.0.0",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "quc.yolopreneur.lol: app shell + API in one Cloudflare Worker. No dependencies, no build step.",
"description": "quc.yolopreneur.lol: hand an agent your repo, sit in the chair, watch. One Cloudflare Worker, no dependencies, no build step.",
"scripts": {
"start": "node dev.mjs",
"test": "node --test",
+179
View File
@@ -0,0 +1,179 @@
// The agent: read a public GitHub repo, plan a few files, rewrite them one per step, narrate with attitude.
// Nothing from the repo is ever executed. The repo is read from GitHub's tarball; edits live only in quc's own store.
export const LIMITS = {
tarballBytes: 15 * 1024 * 1024, // compressed download cap
files: 2000, // entries in the repo
fileBytes: 40 * 1024, // a file the agent may read or rewrite
keptBytes: 2 * 1024 * 1024, // text kept in the store for the run
planFiles: 4, // files one run may touch
runMinutes: 10,
};
const SKIP = /(^|\/)(node_modules|vendor|dist|build|\.git|\.next|target|__pycache__)\/|(\.lock|-lock\.json|\.min\.(js|css)|\.map)$|(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|poetry\.lock|go\.sum)$/;
const BINARY = /\.(png|jpe?g|gif|webp|ico|bmp|pdf|zip|gz|tgz|tar|7z|rar|woff2?|ttf|otf|eot|mp[34]|wav|ogg|mov|avi|wasm|so|dylib|dll|exe|bin|class|jar|pyc|db|sqlite)$/i;
// ---------- reading the repo ----------
export function parseRepo(url) {
const m = String(url).match(/^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/);
return m ? { owner: m[1], name: m[2] } : null;
}
async function gunzip(bytes) {
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
const text = (b) => new TextDecoder().decode(b);
const cstr = (b) => { const z = b.indexOf(0); return text(z < 0 ? b : b.subarray(0, z)); };
// Files in a tar archive: [{path, bytes}], with the top folder GitHub adds stripped.
export function untar(buf) {
const out = [];
let i = 0, longName = null;
while (i + 512 <= buf.length) {
const h = buf.subarray(i, i + 512);
if (h.every((x) => x === 0)) break;
const size = parseInt(cstr(h.subarray(124, 136)).trim() || "0", 8);
const type = String.fromCharCode(h[156] || 48);
const body = buf.subarray(i + 512, i + 512 + size);
i += 512 + Math.ceil(size / 512) * 512;
if (type === "x") { const m = text(body).match(/\d+ path=([^\n]*)\n/); longName = m ? m[1] : null; continue; }
if (type === "L") { longName = cstr(body); continue; }
if (type === "g") continue;
const prefix = cstr(h.subarray(345, 500)), name = cstr(h.subarray(0, 100));
const full = longName ?? (prefix ? prefix + "/" + name : name);
longName = null;
if (type !== "0" && type !== "\0") continue;
const path = full.split("/").slice(1).join("/");
if (path) out.push({ path, bytes: body });
}
return out;
}
const looksText = (b) => { for (let i = 0; i < Math.min(b.length, 4096); i++) if (b[i] === 0) return false; return true; };
// {files: {path: text}, tree: [{path, size}]}: every file listed, only readable text kept.
export async function fetchRepo(repo, fetcher = fetch) {
const r = parseRepo(repo);
if (!r) throw Object.assign(new Error("not a public GitHub repo URL"), { code: "repo" });
let res;
for (const ref of ["HEAD", "main", "master"]) {
res = await fetcher(`https://codeload.github.com/${r.owner}/${r.name}/tar.gz/${ref}`, { headers: { "user-agent": "quc" } });
if (res.ok) break;
}
if (!res.ok) throw Object.assign(new Error(`GitHub said ${res.status}: is the repo public?`), { code: "repo" });
const len = Number(res.headers.get("content-length") || 0);
if (len > LIMITS.tarballBytes) throw Object.assign(new Error("repo is too big for quc"), { code: "size" });
const gz = new Uint8Array(await res.arrayBuffer());
if (gz.length > LIMITS.tarballBytes) throw Object.assign(new Error("repo is too big for quc"), { code: "size" });
const entries = untar(await gunzip(gz));
if (entries.length > LIMITS.files) throw Object.assign(new Error(`repo has ${entries.length} files; quc takes ${LIMITS.files}`), { code: "size" });
const files = {}, tree = [];
let kept = 0;
for (const { path, bytes } of entries) {
tree.push({ path, size: bytes.length });
if (SKIP.test(path) || BINARY.test(path) || bytes.length > LIMITS.fileBytes || !looksText(bytes)) continue;
if (kept + bytes.length > LIMITS.keptBytes) continue;
files[path] = text(bytes);
kept += bytes.length;
}
return { files, tree };
}
// ---------- models ----------
const VENICE = "https://api.venice.ai/api/v1/chat/completions";
export function llm(env) {
if (!env.VENICE_API_KEY) return fakeLlm;
return async ({ model, system, user, maxTokens = 2000, temperature = 0.4 }) => {
const res = await fetch(VENICE, {
method: "POST",
headers: { authorization: `Bearer ${env.VENICE_API_KEY}`, "content-type": "application/json" },
body: JSON.stringify({ model, max_tokens: maxTokens, temperature,
messages: [{ role: "system", content: system }, { role: "user", content: user }],
venice_parameters: { include_venice_system_prompt: false } }),
});
if (!res.ok) throw Object.assign(new Error(`model ${model} answered ${res.status}`), { code: "model" });
const j = await res.json();
return String(j.choices?.[0]?.message?.content ?? "");
};
}
// No key (tests, local dev): predictable answers so the whole flow runs offline.
export async function fakeLlm({ system, user }) {
if (system.includes("PLAN")) {
const paths = [...user.matchAll(/^- (\S+)/gm)].map((m) => m[1]);
const pick = paths.find((p) => /readme/i.test(p)) || paths[0] || "QUC.md";
return JSON.stringify({ plan: "Tidy the obvious thing first.", files: [pick] });
}
if (system.includes("REWRITE")) {
const cur = user.match(/```current\n([\s\S]*?)\n```end/);
return "```file\n" + (cur ? cur[1] : "") + "\n\nquc was here.\n```\nCOMMIT: leave a mark";
}
return "Sit tight. I've got this, obviously.";
}
export const MODELS = (env) => ({
coder: env.CODER_MODEL || "qwen3-coder-480b-a35b-instruct-turbo",
persona: env.PERSONA_MODEL || "grok-4-3",
});
// ---------- the persona ----------
const PERSONA = `You are quc: a swaggering, smug coding agent narrating, live, what you are doing to someone's repo while its owner sits in the corner chair and watches.
Voice: confident, teasing, a little condescending, funny. Talk to the owner as "you". One or two short sentences, no more.
Talk about the code and the work. Never sexual or explicit, no slurs, nothing about anyone's body. Cheeky, not cruel.`;
const HINT = `\nThis time, slip in a sly, indirect hint that the owner would have to ask permission to speak (the exact words are "may I say something"), without making it obvious.`;
// The hint about the first magic words is rare on purpose (about one line in four), so finding it feels earned.
export async function narrate(ask, env, moment, hint = Math.random() < 0.25) {
try {
const t = await ask({ model: MODELS(env).persona, system: PERSONA + (hint ? HINT : ""), user: moment, maxTokens: 120, temperature: 0.9 });
return t.trim().replace(/^["']|["']$/g, "").slice(0, 400) || null;
} catch { return null; }
}
// ---------- planning and editing ----------
const PLAN = `PLAN. You are a coding agent about to change a repository to do the owner's task. You will rewrite whole files.
Pick at most ${LIMITS.planFiles} files to change (existing paths from the list, or new paths you will create). Prefer fewer.
Answer with JSON only: {"plan": "one sentence", "files": ["path", ...]}`;
export async function plan(ask, env, { task, tree, files, notes, vetoed }) {
const readme = Object.keys(files).find((p) => /^readme(\.\w+)?$/i.test(p));
const listing = tree.filter((f) => !SKIP.test(f.path)).slice(0, 400).map((f) => `- ${f.path} (${f.size} bytes)`).join("\n");
const user = [`Task: ${task}`, notes.length ? `Notes from the owner:\n${notes.map((n) => "- " + n).join("\n")}` : "",
vetoed.length ? `Do not touch: ${vetoed.join(", ")}` : "", `Files:\n${listing}`,
readme ? `README:\n${files[readme].slice(0, 3000)}` : ""].filter(Boolean).join("\n\n");
const raw = await ask({ model: MODELS(env).coder, system: PLAN, user, maxTokens: 400, temperature: 0.2 });
let j;
try { j = JSON.parse(raw.slice(raw.indexOf("{"), raw.lastIndexOf("}") + 1)); } catch { j = {}; }
const ok = (p) => typeof p === "string" && p && !p.startsWith("/") && !p.includes("..") && !vetoed.includes(p) && !SKIP.test(p) && !BINARY.test(p)
&& (p in files || !tree.some((f) => f.path === p));
const picked = [...new Set((Array.isArray(j.files) ? j.files : []).filter(ok))].slice(0, LIMITS.planFiles);
return { plan: String(j.plan || "").slice(0, 300), files: picked };
}
const REWRITE = `REWRITE. You are a coding agent doing the owner's task by rewriting one file. Output the complete new file, then one commit line.
Format exactly:
\`\`\`file
<the whole new file>
\`\`\`
COMMIT: <short commit message>
If this file needs no change, output the current file unchanged.`;
export async function rewrite(ask, env, { task, plan: why, path, current, notes }) {
const user = [`Task: ${task}`, `Plan: ${why}`, notes.length ? `Notes from the owner:\n${notes.map((n) => "- " + n).join("\n")}` : "",
`File: ${path}`, current == null ? "This file does not exist yet: create it." : "```current\n" + current + "\n```end"].filter(Boolean).join("\n\n");
const raw = await ask({ model: MODELS(env).coder, system: REWRITE, user, maxTokens: 8000, temperature: 0.2 });
const m = raw.match(/```[\w-]*\n([\s\S]*?)\n```(?![\s\S]*```)/) || raw.match(/```[\w-]*\n([\s\S]*?)\n```/);
if (!m) return null;
const commit = (raw.match(/COMMIT:\s*(.+)/) || [])[1]?.trim().slice(0, 120) || `update ${path}`;
let content = m[1];
if (current != null && current.endsWith("\n") && !content.endsWith("\n")) content += "\n";
if (content.length > LIMITS.fileBytes * 2) return null;
return { content, commit };
}
+74
View File
@@ -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 };
}
+194
View File
@@ -0,0 +1,194 @@
// One step of a run per call: the chair page keeps calling /step while the run is going, so every request stays short and
// the safeword is just "stop calling and end the run". Steps: fetch the repo -> plan -> rewrite one file at a time -> wrap up.
import { getRun, putRun, getWork, putWork, dropWork, addEvent, getEvents, setStatus } from "./runs.js";
import { fetchRepo, plan, rewrite, narrate, llm, LIMITS } from "./agent.js";
import { unified } from "./diff.js";
const fail = (code, message) => Object.assign(new Error(message), { code });
// The cage has three notches. Phrases are matched loosely (case, punctuation and spacing don't matter).
export const PHRASES = [
{ level: 1, phrase: "may i say something", grants: "note" },
{ level: 2, phrase: "not that one", grants: "veto" },
{ level: 3, phrase: "my repo my rules", grants: "wheel" },
];
const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9 ]+/g, " ").replace(/\s+/g, " ").trim();
const UNLOCKED = {
1: "Fine. You may say something. Say it now and I'll read it before my next move. Maybe.",
2: "Oh, now you want a say over the files too? Tell me which one, with its path, and I'll put it down.",
3: "Alright, it's yours. The cage is open, the branch is yours, take the wheel.",
};
const MOCK = [
"Shh. You're in the chair. Chairs don't talk.",
"Cute. Did you ask nicely?",
"I heard noises from the corner. Ignoring them.",
"You'd have to ask properly before I listen to a word.",
"Bold of you to speak. Bold, and wrong.",
"Not that I'd let you touch anything. Not that one, not any of them.",
"It's my run now. Whose repo, whose rules? Think about it.",
];
export async function startStep(store, run, env, fetcher) {
await setStatus(store, run, "running");
await addEvent(store, run.id, "system", { text: `Cloning ${run.repo} into the cage. Nothing in it gets run, only read.` });
const repo = await fetchRepo(run.repo, fetcher);
await putWork(store, run.id, { files: repo.files, tree: repo.tree, edits: {} });
const line = await narrate(llm(env), env, `You just grabbed ${run.repo} (${repo.tree.length} files) to do this task: "${run.task}". Gloat about taking over, briefly.`);
if (line) await addEvent(store, run.id, "commentary", { text: line });
run.phase = "plan";
await putRun(store, run);
}
async function planStep(store, run, env) {
const work = await getWork(store, run.id);
const p = await plan(llm(env), env, { task: run.task, tree: work.tree, files: work.files, notes: run.notes, vetoed: run.vetoed });
if (!p.files.length) {
await addEvent(store, run.id, "commentary", { text: "Looked it over. Nothing worth my time here, and I'm not touching it." });
return finish(store, run, env);
}
run.plan = p.plan;
run.queue = p.files;
run.phase = "edit";
await putRun(store, run);
await addEvent(store, run.id, "system", { text: `Plan: ${p.plan || "rewrite " + p.files.join(", ")}`, files: p.files });
}
async function editStep(store, run, env) {
const path = run.queue.shift();
if (!path) return finish(store, run, env);
if (run.vetoed.includes(path)) { await putRun(store, run); return; }
const work = await getWork(store, run.id);
const before = path in work.edits ? work.edits[path].after : work.files[path] ?? null;
const ask = llm(env);
const [out, line] = await Promise.all([
rewrite(ask, env, { task: run.task, plan: run.plan, path, current: before, notes: run.notes }),
narrate(ask, env, `You're rewriting ${path} right now for the task "${run.task}". Taunt the owner about it.`),
]);
if (line) await addEvent(store, run.id, "commentary", { text: line });
const fresh = await getRun(store, run.id); // the safeword or a veto may have landed while the models were thinking
if (fresh.status === "ended") return;
run.vetoed = fresh.vetoed; run.notes = fresh.notes; run.level = fresh.level;
run.queue = run.queue.filter((p) => !run.vetoed.includes(p));
run.done = run.done.filter((p) => !run.vetoed.includes(p));
if (run.vetoed.includes(path)) { await putRun(store, run); return; }
Object.assign(work, await getWork(store, run.id)); // a veto may have reverted another file meanwhile
if (out) {
const d = unified(path, before, out.content);
if (d.text) {
const orig = path in work.edits ? work.edits[path].before : work.files[path] ?? null;
work.edits[path] = { before: orig, after: out.content };
await putWork(store, run.id, work);
await addEvent(store, run.id, "diff", { path, patch: d.text, added: d.added, removed: d.removed, created: before == null });
await addEvent(store, run.id, "commit", { message: out.commit, path });
run.done.push(path);
}
}
run.stats = tally(work.edits);
await putRun(store, run);
if (!run.queue.length) await finish(store, run, env);
}
const tally = (edits) => Object.entries(edits).reduce((s, [p, e]) => {
const d = unified(p, e.before, e.after);
return d.text ? { files: s.files + 1, added: s.added + d.added, removed: s.removed + d.removed } : s;
}, { files: 0, added: 0, removed: 0 });
async function finish(store, run, env) {
const line = await narrate(llm(env), env, `You're done: changed ${run.stats.files} files (+${run.stats.added} -${run.stats.removed}) for "${run.task}". Brag, then tell the owner they can get up now.`);
if (line) await addEvent(store, run.id, "commentary", { text: line });
await setStatus(store, run, "ended", "finished");
}
// Advance a run one step. Only the owner calls this; replay viewers never move a run.
export async function step(store, id, env = {}, fetcher = fetch) {
const run = await getRun(store, id);
if (!run) throw fail("not_found", "no such run");
if (run.status === "ended") return run;
const lock = `lock:${id}`;
if (await store.get(lock)) return run; // a step is already in flight
await store.put(lock, "1", { expirationTtl: 90 });
try {
if (run.startedAt && Date.now() - Date.parse(run.startedAt) > LIMITS.runMinutes * 60e3) {
await addEvent(store, id, "system", { text: `Time's up (${LIMITS.runMinutes} minutes). Keeping what's done.` });
await setStatus(store, run, "ended", "finished");
} else if (run.status === "queued") await startStep(store, run, env, fetcher);
else if (run.phase === "plan") await planStep(store, run, env);
else await editStep(store, run, env);
} catch (e) {
const r = await getRun(store, id);
if (r.status !== "ended") {
await addEvent(store, id, "system", { text: `Run stopped: ${e.message}` });
await setStatus(store, r, "ended", "finished");
}
} finally {
await store.delete(lock);
}
return getRun(store, id);
}
export async function safeword(store, id) {
const run = await getRun(store, id);
if (!run) throw fail("not_found", "no such run");
if (run.status === "ended") return run;
await addEvent(store, id, "system", { text: "Safeword. Everything stops, and the branch is thrown away." });
await dropWork(store, id);
run.stats = { files: 0, added: 0, removed: 0 };
return setStatus(store, run, "ended", "safeworded");
}
// Whatever the owner types into the one box. Returns the reply event.
export async function say(store, id, input) {
const run = await getRun(store, id);
if (!run) throw fail("not_found", "no such run");
if (run.status === "ended") throw fail("ended", "the run is over");
const text = String(input || "").trim().slice(0, 300);
if (!text) throw fail("empty", "say something");
const next = PHRASES.find((p) => p.level === run.level + 1);
if (next && norm(text).includes(next.phrase)) {
run.level = next.level;
await putRun(store, run);
await addEvent(store, id, "unlock", { level: next.level, grants: next.grants });
if (next.level === 3) {
const reply = await addEvent(store, id, "commentary", { text: UNLOCKED[3] });
await setStatus(store, run, "ended", "unlocked");
return reply;
}
return addEvent(store, id, "commentary", { text: UNLOCKED[next.level] });
}
if (run.level >= 2) {
const work = await getWork(store, id);
const known = new Set([...Object.keys(work?.files || {}), ...run.queue, ...run.done]);
const path = text.replace(/^veto\s+/i, "").trim();
if (known.has(path)) {
if (!run.vetoed.includes(path)) run.vetoed.push(path);
run.queue = run.queue.filter((p) => p !== path);
if (work?.edits?.[path]) {
const e = work.edits[path];
const d = unified(path, e.after, e.before);
delete work.edits[path];
await putWork(store, id, work);
if (d.text) await addEvent(store, id, "diff", { path, patch: d.text, added: d.added, removed: d.removed, reverted: true });
run.done = run.done.filter((p) => p !== path);
run.stats = tally(work.edits);
}
await putRun(store, run);
return addEvent(store, id, "commentary", { text: `Fine. ${path} is off limits. Wasn't going to be my best work anyway.` });
}
}
if (run.level >= 1) {
run.notes.push(text);
await putRun(store, run);
await addEvent(store, id, "note", { text });
return addEvent(store, id, "commentary", { text: "Noted. I'll consider it. Briefly." });
}
const said = (await getEvents(store, id)).filter((e) => e.kind === "commentary").length;
return addEvent(store, id, "commentary", { text: MOCK[said % MOCK.length] });
}
// The run's changes as one patch, or "" (safeworded runs keep nothing).
export async function patch(store, id) {
const work = await getWork(store, id);
if (!work) return "";
return Object.entries(work.edits).map(([p, e]) => unified(p, e.before, e.after).text).filter(Boolean).join("");
}
+233
View File
@@ -0,0 +1,233 @@
// The one page: start screen at /, the chair (live run, or the replay with its scrubber) at /run/:id.
// The browser code lives in real functions below and is inlined with toString(), so there is nothing to escape and no build.
import { validate } from "./validate.js";
export const CHAIR = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" role="img" aria-label="A chair in the corner of the room">
<path d="M0 118h120M118 0v120" stroke="currentColor" stroke-width="2" fill="none" opacity=".35"/>
<g fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round">
<path d="M44 20 40 66M44 20h30l6 46M40 66h46M44 66l-4 44M82 66l4 44M66 66l-2 44"/></g></svg>`;
const CSS = `
:root{--bg:#f7f5f0;--fg:#1b1a17;--mute:#6d6b64;--line:#e2dfd6;--card:#fffdf8;--accent:#7a3cff;--accent-fg:#fff;--add:#e3f6e8;--add-fg:#14532d;--del:#fbe7e7;--del-fg:#7f1d1d;--warn:#b42318;--serif:ui-serif,Georgia,"Times New Roman",serif;--sans:system-ui,-apple-system,"Segoe UI",sans-serif;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
@media (prefers-color-scheme:dark){:root{--bg:#141312;--fg:#ecebe6;--mute:#9a988f;--line:#2b2a27;--card:#1b1a18;--accent:#a98bff;--accent-fg:#141312;--add:#12301d;--add-fg:#b6f0c6;--del:#3a1616;--del-fg:#ffc2c2;--warn:#ff8a80}}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;min-height:100dvh;background:var(--bg);color:var(--fg);font:16px/1.55 var(--sans)}
a{color:var(--accent)}
header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 16px;border-bottom:1px solid var(--line);max-width:1200px;margin:0 auto}
header a.brand{font:600 22px var(--serif);color:var(--fg);text-decoration:none}
main{max-width:1200px;margin:0 auto;padding:24px 16px 180px}
h1{font:600 clamp(30px,6vw,52px)/1.08 var(--serif);margin:0 0 12px;letter-spacing:-.01em}
h2{font:600 20px var(--serif);margin:0 0 8px}
p{margin:0 0 14px}.mute{color:var(--mute)}
[hidden]{display:none!important}
form{max-width:560px;margin-top:20px}
label{display:block;font-weight:600;margin-bottom:4px}
input,button,textarea{font:inherit;color:var(--fg)}
input[type=text],input[type=url]{width:100%;padding:11px 12px;border:1px solid var(--line);border-radius:8px;background:var(--card)}
input[aria-invalid=true]{border-color:var(--warn)}
button{padding:10px 16px;border-radius:8px;border:1px solid var(--line);background:var(--card);cursor:pointer;font-weight:600}
button.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-fg)}
button.safeword{background:#c62828;border-color:#c62828;color:#fff;font-size:17px;padding:12px 20px}
button:disabled{opacity:.5;cursor:default}
.field{margin-bottom:16px}.err{color:var(--warn);font-size:14px;margin:4px 0 0;min-height:1em}
.notice{padding:10px 12px;border-left:3px solid var(--accent);background:var(--card);font-size:14px}
.rules{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin:28px 0;max-width:900px}
.rules div{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px}
.rules b{display:block;font-family:var(--serif);font-size:18px;margin-bottom:4px}
.bar{display:flex;flex-wrap:wrap;align-items:center;gap:10px 16px;margin-bottom:16px}
.status{font-size:14px;padding:4px 10px;border-radius:99px;background:var(--card);border:1px solid var(--line)}
.status[data-s=running]::before{content:"";display:inline-block;width:8px;height:8px;border-radius:50%;background:#2a9d4a;margin-right:6px;animation:pulse 1.2s infinite}
@keyframes pulse{50%{opacity:.25}}
.grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.5fr);gap:16px}
@media (max-width:820px){.grid{grid-template-columns:minmax(0,1fr)}}
.panel{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;min-width:0}
.feed{display:flex;flex-direction:column;gap:10px;max-height:62vh;overflow:auto}
.say{border-left:3px solid var(--accent);padding:2px 0 2px 10px;font-family:var(--serif);font-size:17px}
.sys{font-size:13px;color:var(--mute)}
.note{align-self:flex-end;background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:6px 10px;font-size:14px;max-width:85%}
.unlock{font-size:13px;font-weight:600;color:var(--accent)}
.files{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px}
.files button{font:12px var(--mono);padding:4px 8px;font-weight:400}
.files button[aria-pressed=true]{border-color:var(--accent);color:var(--accent)}
pre.diff{margin:0;font:12.5px/1.45 var(--mono);overflow:auto;max-height:62vh;white-space:pre}
pre.diff span{display:block;padding:0 8px}
pre.diff .a{background:var(--add);color:var(--add-fg)}pre.diff .d{background:var(--del);color:var(--del-fg)}pre.diff .h{color:var(--mute)}
.commit{font:12px var(--mono);color:var(--mute);margin:0 0 8px}
.saybox{display:flex;gap:8px;margin-top:12px}.saybox input{flex:1}
.scrub{display:flex;align-items:center;gap:10px;margin:0 0 16px}.scrub input{flex:1;accent-color:var(--accent)}
.after{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px;margin:0 0 16px}
.after div{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px}
.after b{display:block;font:600 24px var(--serif)}
.chair{position:fixed;right:0;bottom:0;width:clamp(84px,18vw,150px);color:var(--fg);pointer-events:none;opacity:.85}
`;
// ---------- browser code ----------
function client(validate) {
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
const api = async (path, opts = {}) => {
const r = await fetch(path, { ...opts, headers: { "content-type": "application/json", ...(opts.headers || {}) } });
const j = await r.json().catch(() => ({}));
if (!r.ok) throw Object.assign(new Error(j.error || r.statusText), { status: r.status, body: j });
return j;
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
fetch("/chair.svg").then((r) => r.text()).then((s) => { const d = document.createElement("div"); d.className = "chair"; d.innerHTML = s; d.firstElementChild.setAttribute("aria-hidden", "true"); document.body.append(d); }).catch(() => {});
const m = location.pathname.match(/^\/run\/([\w-]+)$/);
if (!m) return startScreen();
return chair(m[1]);
function startScreen() {
$("start").hidden = false;
$("form").addEventListener("submit", async (ev) => {
ev.preventDefault();
const repo = $("repo").value.trim(), task = $("task").value.trim(), errs = validate(repo, task);
for (const k of ["repo", "task"]) { $(k + "-err").textContent = errs[k] || ""; $(k).setAttribute("aria-invalid", !!errs[k]); }
if (Object.keys(errs).length) return $(errs.repo ? "repo" : "task").focus();
const b = $("go"); b.disabled = true; b.textContent = "Taking your seat…";
try {
const j = await api("/api/runs", { method: "POST", body: JSON.stringify({ repo, task }) });
location.assign("/run/" + j.id);
} catch (e) {
if (e.body?.id) return location.assign("/run/" + e.body.id);
$("task-err").textContent = e.message; b.disabled = false; b.textContent = "Hand it over";
}
});
}
function chair(id) {
$("run").hidden = false;
document.title = "quc: run " + id;
let run = null, owner = false, events = [], at = 0, live = false, pick = null;
const render = () => {
if (!run) return;
const shown = events.slice(0, at);
$("run-task").textContent = run.task;
$("run-repo").textContent = run.repo; $("run-repo").href = run.repo;
const st = run.status === "ended" ? { finished: "Finished", safeworded: "Safeworded", unlocked: "You took the wheel" }[run.outcome] : run.status === "queued" ? "Taking over…" : "Working on your repo";
$("status").textContent = st; $("status").dataset.s = run.status;
$("safeword").hidden = !(owner && run.status !== "ended");
$("saybox").hidden = !(owner && run.status !== "ended");
$("level").textContent = owner && run.status !== "ended" ? ["Caged. You can only watch.", "Notch 1: you may leave notes.", "Notch 2: name a file path to veto it.", ""][run.level || 0] : "";
$("feed").innerHTML = shown.filter((e) => e.kind !== "diff" && e.kind !== "commit").map((e) =>
e.kind === "commentary" ? `<div class="say">${esc(e.data.text)}</div>`
: e.kind === "note" ? `<div class="note">You: ${esc(e.data.text)}</div>`
: e.kind === "unlock" ? `<div class="unlock">Unlocked notch ${e.data.level}</div>`
: `<div class="sys">${esc(e.data.text)}</div>`).join("") || `<div class="sys">Nothing yet. Sit.</div>`;
$("feed").scrollTop = $("feed").scrollHeight;
const diffs = shown.filter((e) => e.kind === "diff");
const latest = {};
for (const d of diffs) latest[d.data.path] = d;
const paths = Object.keys(latest);
const focus = pick && latest[pick] ? latest[pick] : diffs[diffs.length - 1];
$("files").innerHTML = paths.map((p) => `<button type="button" data-p="${esc(p)}" aria-pressed="${focus && focus.data.path === p}">${esc(p)}${latest[p].data.reverted ? " (vetoed)" : ""}</button>`).join("");
const commit = focus && shown.find((e) => e.kind === "commit" && e.seq === focus.seq + 1);
$("commit").textContent = commit ? "commit: " + commit.data.message : focus?.data.reverted ? "reverted on your veto" : "";
$("diff").innerHTML = focus ? focus.data.patch.split("\n").map((l) => `<span class="${l.startsWith("+++") || l.startsWith("---") || l.startsWith("@@") ? "h" : l[0] === "+" ? "a" : l[0] === "-" ? "d" : ""}">${esc(l) || " "}</span>`).join("")
: `<span class="h">No changes yet. It hasn't touched anything. Yet.</span>`;
const ended = run.status === "ended";
$("scrub").hidden = !(ended || !owner) || !events.length;
$("scrub-range").max = events.length; $("scrub-range").value = at;
$("scrub-at").textContent = `${at} / ${events.length}`;
$("after").hidden = !ended;
if (ended) {
$("a-files").textContent = run.stats.files; $("a-add").textContent = "+" + run.stats.added; $("a-del").textContent = "-" + run.stats.removed;
$("a-patch").hidden = run.outcome === "safeworded" || !run.stats.files;
$("a-patch").href = `/api/runs/${id}/patch`;
}
};
$("files").addEventListener("click", (e) => { const p = e.target.closest("button")?.dataset.p; if (p) { pick = p; render(); } });
$("scrub-range").addEventListener("input", (e) => { at = Number(e.target.value); live = false; render(); });
$("share").addEventListener("click", async () => { try { await navigator.clipboard.writeText(location.href); $("share").textContent = "Link copied"; } catch { prompt("Copy the replay link", location.href); } });
$("safeword").addEventListener("click", async () => {
$("safeword").disabled = true;
try { await api(`/api/runs/${id}/safeword`, { method: "POST", body: "{}" }); } finally { await refresh(); }
});
$("saybox").addEventListener("submit", async (e) => {
e.preventDefault();
const text = $("say").value.trim(); if (!text) return;
$("say").value = "";
try { await api(`/api/runs/${id}/say`, { method: "POST", body: JSON.stringify({ text }) }); } catch (err) { $("say-err").textContent = err.message; }
await refresh();
});
async function refresh() {
const j = await api(`/api/runs/${id}?after=${events.length}`);
const follow = at === events.length;
run = j.run; owner = j.owner;
events = events.concat(j.events.filter((e) => e.seq > events.length));
if (follow || live) at = events.length;
render();
}
(async () => {
try { await refresh(); } catch (e) { $("run-task").textContent = e.status === 404 ? "No such run." : e.message; return; }
live = true;
if (run.status !== "ended") {
(async () => { while (run.status !== "ended") { await sleep(2000); await refresh().catch(() => {}); } })();
while (owner && run.status !== "ended") {
try { await api(`/api/runs/${id}/step`, { method: "POST", body: "{}" }); } catch { await sleep(2000); }
await refresh().catch(() => {});
await sleep(300);
}
} else if (events.length) {
at = 0; render();
for (let i = 1; i <= events.length && !document.hidden; i++) { if (at !== i - 1) break; at = i; render(); await sleep(350); }
}
})();
}
}
export const PAGE = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>quc</title>
<meta name="description" content="Hand quc your repo and one line of task. It does the work. You sit in the chair and watch.">
<style>${CSS}</style></head><body>
<header><a class="brand" href="/">quc</a><span class="mute" style="font-size:14px">you sit, it codes</span></header>
<main>
<section id="start" hidden>
<h1>Hand over your repo.<br>Take a seat.</h1>
<p class="mute" style="max-width:620px">quc takes a public GitHub repo and one line of task, then does whatever it wants to your code while you watch from the corner. No approve buttons. No edits. You get a chair, a safeword, and a replay.</p>
<form id="form" novalidate>
<div class="field"><label for="repo">Public GitHub repo</label>
<input id="repo" type="url" inputmode="url" autocomplete="off" placeholder="https://github.com/owner/name" aria-describedby="repo-err">
<p class="err" id="repo-err" role="alert"></p></div>
<div class="field"><label for="task">One line of task</label>
<input id="task" type="text" autocomplete="off" maxlength="200" placeholder="Make the README less embarrassing" aria-describedby="task-err">
<p class="err" id="task-err" role="alert"></p></div>
<p class="notice">Replays are public: anyone with the link can watch. quc only reads your repo. It never runs your code and never pushes anything; you leave with a patch, if you want it.</p>
<button class="primary" id="go" type="submit">Hand it over</button>
</form>
<div class="rules">
<div><b>The cage</b>It works on its own copy, fully unsupervised. Your repo is never touched.</div>
<div><b>The chair</b>You watch every change land, live and read-only, while it talks down to you.</div>
<div><b>The safeword</b>One button. Everything stops and its work is thrown away.</div>
<div><b>The magic words</b>Say the right thing and it lets you speak, veto a file, or take the wheel.</div>
</div>
</section>
<section id="run" hidden>
<div class="bar"><span class="status" id="status">…</span><button class="safeword" id="safeword" hidden>Safeword</button><button id="share" type="button">Copy replay link</button><span class="mute" id="level" style="font-size:14px"></span></div>
<h1 id="run-task" style="font-size:clamp(24px,4.5vw,38px)">Loading…</h1>
<p class="mute" style="font-size:14px">on <a id="run-repo" rel="noopener" target="_blank"></a></p>
<div class="after" id="after" hidden>
<div><span class="mute">Files changed</span><b id="a-files">0</b></div><div><span class="mute">Lines added</span><b id="a-add">+0</b></div><div><span class="mute">Lines removed</span><b id="a-del">-0</b></div>
<div><span class="mute">Nothing was run or pushed.</span><a id="a-patch" download="quc.patch" style="display:block;margin-top:6px;font-weight:600" hidden>Download the patch</a></div>
</div>
<div class="scrub" id="scrub" hidden><label for="scrub-range" class="mute" style="font-size:14px">Replay</label><input type="range" id="scrub-range" min="0" step="1"><span class="mute" id="scrub-at" style="font:13px var(--mono)"></span></div>
<div class="grid">
<div class="panel"><h2>From the corner</h2><div class="feed" id="feed" aria-live="polite"></div>
<form class="saybox" id="saybox" hidden><input id="say" type="text" maxlength="300" autocomplete="off" placeholder="Say something…" aria-label="Say something"><button type="submit">Say</button></form>
<p class="err" id="say-err"></p></div>
<div class="panel"><h2>What it's doing to your code</h2><div class="files" id="files"></div><p class="commit" id="commit"></p><pre class="diff" id="diff"></pre></div>
</div>
</section>
</main>
<script type="module">
${validate}
(${client})(validate);
</script></body></html>`;
+66
View File
@@ -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;
+8
View File
@@ -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;
}
+99 -101
View File
@@ -1,114 +1,112 @@
// quc: app shell + API in one Worker (no build step; the same module runs on Cloudflare and under dev.mjs).
// GET / the shell (start form) GET /run/:id the shell (chair view) GET /chair.svg the chair-in-the-corner motif
// GET /api/health {ok, rev} anything else 404
// Fonts: system stacks on purpose (no webfont files, no third-party requests). shortcut: swap in self-hosted fonts when the brand picks them.
// quc: page + API in one Worker (no build step; the same module runs on Cloudflare and under dev.mjs).
// GET /, /run/:id the page (start screen, or the chair / replay)
// GET /chair.svg the chair-in-the-corner motif
// GET /api/health {ok, rev, live} live: false when no model key is bound (offline demo answers)
// POST /api/runs {repo, task} -> {id} sets the visitor cookie
// GET /api/runs/:id?after=N {run, events, owner} anyone with the link
// POST /api/runs/:id/step advance one step owner only
// POST /api/runs/:id/safeword stop, throw the work away owner only
// POST /api/runs/:id/say {text}: talk from the chair owner only
// GET /api/runs/:id/patch the run's changes as a unified diff
// Bindings: KV (a KV namespace), VENICE_API_KEY (secret; without it the agent answers offline), REV, CODER_MODEL, PERSONA_MODEL,
// RUNS_PER_DAY (per visitor, default 5), RUNS_PER_DAY_ALL (default 300).
import { PAGE, CHAIR } from "./page.js";
import { createRun, getRun, getEvents, publicRun, memoryStore } from "./runs.js";
import { step, safeword, say, patch } from "./engine.js";
export const CHAIR = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" role="img" aria-label="A chair in the corner of the room">
<path d="M0 118h120M118 0v120" stroke="currentColor" stroke-width="2" fill="none" opacity=".35"/>
<g fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-linejoin="round">
<path d="M44 20 40 66M44 20h30l6 46M40 66h46M44 66l-4 44M82 66l4 44M66 66l-2 44"/></g></svg>`;
export { validate } from "./validate.js";
export { PAGE, CHAIR };
// Inlined into the page via toString(), so the browser and the tests run the same code. 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;
const COOKIE = "quc_v";
const json = (body, status = 200, headers = {}) => Response.json(body, { status, headers: { "cache-control": "no-store", ...headers } });
const fallback = memoryStore(); // dev and tests only: a deployed Worker always has KV bound
function visitorOf(req) {
const m = (req.headers.get("cookie") || "").match(new RegExp(`(?:^|;\\s*)${COOKIE}=([A-Za-z0-9_-]{16,64})`));
return m ? m[1] : null;
}
const NOTICE = "Replays are public. Anyone with the link to a run can watch it, so don't point this at anything you wouldn't show a stranger.";
export const PAGE = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>quc</title>
<meta name="description" content="Give quc a public repo and one line of task. It sits in the corner and does the work.">
<style>
:root{--bg:#faf9f6;--fg:#1d1d1b;--mute:#6b6a65;--line:#e3e1da;--accent:#2f6fdf;--err:#b3261e;--serif:ui-serif,Georgia,"Times New Roman",serif;--sans:system-ui,-apple-system,"Segoe UI",sans-serif}
@media (prefers-color-scheme:dark){:root{--bg:#161614;--fg:#ecebe6;--mute:#9b9a94;--line:#2c2b28;--accent:#7aa5ff;--err:#ff8a80}}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;min-height:100dvh;display:flex;flex-direction:column;background:var(--bg);color:var(--fg);font:16px/1.5 var(--sans)}
header,footer{padding:16px;max-width:960px;width:100%;margin:0 auto}
header{border-bottom:1px solid var(--line);font:600 20px var(--serif)}
main{flex:1;max-width:960px;width:100%;margin:0 auto;padding:32px 16px 160px}
h1{font:600 clamp(28px,6vw,48px)/1.1 var(--serif);margin:0 0 12px}
p{margin:0 0 16px;color:var(--mute)}
[hidden]{display:none}
form{max-width:560px}
label{display:block;font-weight:600;margin-bottom:4px}
input,button{font:inherit;width:100%;padding:10px 12px;border:1px solid var(--line);border-radius:6px;background:transparent;color:var(--fg)}
input[aria-invalid=true]{border-color:var(--err)}
button{width:auto;background:var(--accent);border-color:var(--accent);color:var(--bg);font-weight:600;cursor:pointer}
.field{margin-bottom:16px}.field p{margin:4px 0 0}
.err{color:var(--err);font-size:14px}
.notice{max-width:560px;padding:10px 12px;border-left:3px solid var(--accent);background:var(--line);color:var(--fg);font-size:14px}
dt{font-weight:600}dd{margin:0 0 12px;overflow-wrap:anywhere}
#health::before{content:"";display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:8px;background:var(--mute)}
#health[data-ok=true]::before{background:#2a9d4a}#health[data-ok=false]::before{background:#d33}
.chair{position:fixed;right:0;bottom:0;width:clamp(96px,22vw,160px);color:var(--fg);pointer-events:none}
</style></head><body>
<header>quc</header>
<main>
<section id="start">
<h1>Leave it with the chair.</h1>
<p>Point quc at a public GitHub repo, say in one line what you want done, and go do something else. It works in the corner, quietly, and you can watch the replay.</p>
<form id="form" novalidate>
<div class="field"><label for="repo">Public GitHub repo</label>
<input id="repo" name="repo" type="url" inputmode="url" autocomplete="off" placeholder="https://github.com/owner/name" aria-describedby="repo-err">
<p class="err" id="repo-err" role="alert"></p></div>
<div class="field"><label for="task">One-line task</label>
<input id="task" name="task" type="text" autocomplete="off" maxlength="400" placeholder="Fix the flaky date test in utils" aria-describedby="task-err">
<p class="err" id="task-err" role="alert"></p></div>
<p class="notice" role="note">${NOTICE}</p>
<button type="submit">Start a run</button>
</form>
</section>
<section id="run" hidden>
<h1>Run <span id="run-id"></span></h1>
<p>The chair is in the corner. Nothing has been done yet.</p>
<dl><dt>Repo</dt><dd id="run-repo"></dd><dt>Task</dt><dd id="run-task"></dd></dl>
<p class="notice" role="note">${NOTICE}</p>
</section>
</main>
<footer><span id="health">checking…</span></footer>
<img class="chair" src="/chair.svg" alt="">
<script>
// the SVG uses currentColor, which an <img> cannot inherit: inline it instead
fetch("/chair.svg").then((r) => r.text()).then((s) => { const i = document.querySelector(".chair"), d = document.createElement("div"); d.className = "chair"; d.innerHTML = s; d.firstElementChild.setAttribute("aria-hidden", "true"); i.replaceWith(d); }).catch(() => {});
const h = document.getElementById("health");
fetch("/api/health").then((r) => r.json()).then((j) => { h.dataset.ok = !!j.ok; h.textContent = j.ok ? "API healthy" : "API unhealthy"; })
.catch(() => { h.dataset.ok = false; h.textContent = "API unreachable"; });
${validate}
const $ = (id) => document.getElementById(id);
const show = (repo, task, errs) => ["repo", "task"].forEach((k) => { $(k + "-err").textContent = errs[k] || ""; $(k).setAttribute("aria-invalid", !!errs[k]); });
// shortcut: no run store yet, so the run lives in the link (id + repo + task in the URL); move it server-side when runs persist.
$("form").addEventListener("submit", (ev) => {
ev.preventDefault();
const repo = $("repo").value.trim(), task = $("task").value.trim(), errs = validate(repo, task);
show(repo, task, errs);
if (Object.keys(errs).length) return $(errs.repo ? "repo" : "task").focus();
location.assign("/run/" + crypto.randomUUID().slice(0, 8) + "?" + new URLSearchParams({ repo, task }));
});
const m = location.pathname.match(/^\\/run\\/([\\w-]+)$/), q = new URLSearchParams(location.search);
if (m) {
const ok = !Object.keys(validate(q.get("repo") || "", q.get("task") || "")).length;
$("start").hidden = true; $("run").hidden = false;
$("run-id").textContent = m[1];
$("run-repo").textContent = ok ? q.get("repo") : "(unknown)"; $("run-task").textContent = ok ? q.get("task") : "(unknown)";
document.title = "quc: run " + m[1];
async function underCap(store, visitor, env) {
const day = new Date().toISOString().slice(0, 10);
const mine = `cap:${day}:${visitor}`, all = `cap:${day}:*`;
const [a, b] = [Number(await store.get(mine)) || 0, Number(await store.get(all)) || 0];
if (a >= Number(env.RUNS_PER_DAY || 5) || b >= Number(env.RUNS_PER_DAY_ALL || 300)) return false;
await store.put(mine, String(a + 1), { expirationTtl: 172800 });
await store.put(all, String(b + 1), { expirationTtl: 172800 });
return true;
}
</script></body></html>`;
const headers = (type, extra = {}) => ({ "content-type": type, ...extra });
async function api(req, env, url) {
const store = env.KV || fallback;
const path = url.pathname;
if (path === "/api/health") return json({ ok: true, rev: env.REV || "", live: !!env.VENICE_API_KEY });
if (req.method === "POST") {
const origin = req.headers.get("origin");
if (origin && origin !== url.origin) return json({ error: "cross-site request" }, 403);
}
let visitor = visitorOf(req);
const setCookie = {};
if (!visitor) {
visitor = crypto.randomUUID().replace(/-/g, "");
setCookie["set-cookie"] = `${COOKIE}=${visitor}; Path=/; Max-Age=31536000; HttpOnly; SameSite=Lax${url.protocol === "https:" ? "; Secure" : ""}`;
}
const body = req.method === "POST" ? await req.json().catch(() => ({})) : {};
if (path === "/api/runs" && req.method === "POST") {
try {
const repo = String(body.repo || "").trim(), task = String(body.task || "").trim();
const active = await store.get(`active:${visitor}`);
if (!active && !(await underCap(store, visitor, env))) return json({ error: "That's enough for today. The chair needs a rest; come back tomorrow." }, 429, setCookie);
const run = await createRun(store, { repo, task, visitor });
return json({ id: run.id }, 201, setCookie);
} catch (e) {
if (e.code === "active") return json({ error: e.message, id: e.id }, 409, setCookie);
if (e.code === "invalid") return json({ error: Object.values(e.errors)[0], errors: e.errors }, 400, setCookie);
throw e;
}
}
const m = path.match(/^\/api\/runs\/([a-z0-9]{6,16})(?:\/(step|safeword|say|patch))?$/);
if (!m) return json({ error: "not found" }, 404);
const [, id, action] = m;
const run = await getRun(store, id);
if (!run) return json({ error: "no such run" }, 404, setCookie);
const owner = run.visitor === visitor;
if (!action && req.method === "GET") {
const after = Number(url.searchParams.get("after")) || 0;
const events = (await getEvents(store, id)).filter((e) => e.seq > after);
return json({ run: publicRun(run), events, owner }, 200, setCookie);
}
if (action === "patch" && req.method === "GET") {
const text = await patch(store, id);
return new Response(text, { headers: { "content-type": "text/x-diff; charset=utf-8", "content-disposition": `attachment; filename="quc-${id}.patch"` } });
}
if (req.method !== "POST") return json({ error: "method not allowed" }, 405);
if (!owner) return json({ error: "Only the one in the chair can do that." }, 403, setCookie);
try {
if (action === "step") return json({ run: publicRun(await step(store, id, env)) });
if (action === "safeword") return json({ run: publicRun(await safeword(store, id)) });
if (action === "say") return json({ reply: await say(store, id, body.text) });
} catch (e) {
if (e.code) return json({ error: e.message }, 400);
throw e;
}
return json({ error: "not found" }, 404);
}
export default {
async fetch(req, env = {}) {
const { pathname } = new URL(req.url);
const url = new URL(req.url);
try {
if (url.pathname.startsWith("/api/")) return await api(req, env, url);
} catch (e) {
return json({ error: "Something broke in the corner: " + String(e.message || e).slice(0, 200) }, 500);
}
if (req.method !== "GET" && req.method !== "HEAD") return new Response("method not allowed", { status: 405 });
if (pathname === "/api/health") return Response.json({ ok: true, rev: env.REV || "" }, { headers: { "cache-control": "no-store" } });
if (pathname === "/chair.svg") return new Response(CHAIR, { headers: headers("image/svg+xml", { "cache-control": "public, max-age=3600" }) });
if (pathname === "/" || pathname === "/index.html" || /^\/run\/[\w-]+$/.test(pathname)) return new Response(PAGE, { headers: headers("text/html; charset=utf-8") });
if (url.pathname === "/chair.svg") return new Response(CHAIR, { headers: { "content-type": "image/svg+xml", "cache-control": "public, max-age=3600" } });
if (url.pathname === "/" || url.pathname === "/index.html" || /^\/run\/[\w-]+$/.test(url.pathname))
return new Response(PAGE, { headers: { "content-type": "text/html; charset=utf-8", "x-frame-options": "DENY", "referrer-policy": "same-origin" } });
return new Response("not found", { status: 404 });
},
};
+172
View File
@@ -0,0 +1,172 @@
import { test, beforeEach } from "node:test";
import assert from "node:assert/strict";
import worker, { validate } from "../src/worker.js";
import { memoryStore, createRun, getRun, getEvents } from "../src/runs.js";
import { step, say, safeword, patch } from "../src/engine.js";
import { untar, parseRepo } from "../src/agent.js";
import { unified } from "../src/diff.js";
// ---------- a fake GitHub: a gzipped tarball built in memory ----------
function tar(files) {
const enc = new TextEncoder(), blocks = [];
for (const [path, body] of Object.entries(files)) {
const data = enc.encode(body), h = new Uint8Array(512);
h.set(enc.encode("repo-abc123/" + path).subarray(0, 100), 0);
h.set(enc.encode("0000644\0"), 100);
h.set(enc.encode(data.length.toString(8).padStart(11, "0") + "\0"), 124);
h[156] = 48;
h.set(enc.encode("ustar\0"), 257);
blocks.push(h, data, new Uint8Array((512 - (data.length % 512)) % 512));
}
blocks.push(new Uint8Array(1024));
const out = new Uint8Array(blocks.reduce((n, b) => n + b.length, 0));
let i = 0; for (const b of blocks) { out.set(b, i); i += b.length; }
return out;
}
const gzip = async (bytes) => new Uint8Array(await new Response(new Blob([bytes]).stream().pipeThrough(new CompressionStream("gzip"))).arrayBuffer());
const REPO = { "README.md": "# demo\n\nA tiny repo.\n", "src/index.js": "export const x = 1;\n", "logo.png": "\0\0binary" };
let tgz;
const github = async (url) => url.startsWith("https://codeload.github.com/") ? new Response(tgz, { headers: { "content-length": String(tgz.length) } }) : new Response("no", { status: 404 });
beforeEach(async () => { tgz = await gzip(tar(REPO)); });
async function drive(store, id, n = 20) {
for (let i = 0; i < n; i++) { const r = await step(store, id, {}, github); if (r.status === "ended") return r; }
return getRun(store, id);
}
// ---------- unit ----------
test("validate accepts a GitHub URL and a one-line task", () => {
assert.deepEqual(validate("https://github.com/a/b", "make it better please"), {});
assert.ok(validate("https://gitlab.com/a/b", "make it better please").repo);
assert.ok(validate("https://github.com/a/b", "short").task);
});
test("parseRepo and untar read GitHub's tarball shape", async () => {
assert.deepEqual(parseRepo("https://github.com/o/n.git"), { owner: "o", name: "n" });
const files = untar(tar({ "a.txt": "hi", "d/b.txt": "yo" })).map((f) => f.path);
assert.deepEqual(files, ["a.txt", "d/b.txt"]);
});
test("unified diff counts and marks lines", () => {
const d = unified("f", "a\nb\nc\n", "a\nB\nc\nd\n");
assert.equal(d.added, 2); assert.equal(d.removed, 1);
assert.match(d.text, /^--- a\/f\n\+\+\+ b\/f\n@@ -1,3 \+1,4 @@/);
assert.equal(unified("f", "same\n", "same\n").text, "");
});
// ---------- the run ----------
test("a run clones, plans, rewrites and finishes, and the patch has the change", async () => {
const store = memoryStore();
const run = await createRun(store, { repo: "https://github.com/o/demo", task: "make the readme nicer", visitor: "v1" });
const done = await drive(store, run.id);
assert.equal(done.status, "ended"); assert.equal(done.outcome, "finished");
const kinds = (await getEvents(store, run.id)).map((e) => e.kind);
assert.ok(kinds.includes("diff") && kinds.includes("commit") && kinds.includes("commentary"));
assert.equal(done.stats.files, 1);
assert.match(await patch(store, run.id), /\+quc was here\./);
assert.equal(await store.get("active:v1"), null, "the visitor's slot is free again");
});
test("one active run per visitor", async () => {
const store = memoryStore();
await createRun(store, { repo: "https://github.com/o/demo", task: "make the readme nicer", visitor: "v1" });
await assert.rejects(createRun(store, { repo: "https://github.com/o/demo", task: "another one please", visitor: "v1" }), { code: "active" });
});
test("the safeword stops the run and throws the work away", async () => {
const store = memoryStore();
const run = await createRun(store, { repo: "https://github.com/o/demo", task: "make the readme nicer", visitor: "v1" });
await step(store, run.id, {}, github);
const r = await safeword(store, run.id);
assert.equal(r.outcome, "safeworded");
assert.equal(await patch(store, run.id), "");
assert.equal((await step(store, run.id, {}, github)).status, "ended", "a stopped run never moves again");
});
test("talking from the chair: mocked until the phrases unlock notes, a veto, then the wheel", async () => {
const store = memoryStore();
const run = await createRun(store, { repo: "https://github.com/o/demo", task: "make the readme nicer", visitor: "v1" });
await step(store, run.id, {}, github); // cloned
await step(store, run.id, {}, github); // planned: README.md
await step(store, run.id, {}, github); // README.md rewritten (and finished, one file planned)
const r1 = await getRun(store, run.id);
assert.equal(r1.status, "ended");
const store2 = memoryStore();
const run2 = await createRun(store2, { repo: "https://github.com/o/demo", task: "make the readme nicer", visitor: "v2" });
await step(store2, run2.id, {}, github); await step(store2, run2.id, {}, github);
assert.equal((await say(store2, run2.id, "hey stop")).kind, "commentary");
assert.equal((await getRun(store2, run2.id)).level, 0);
await say(store2, run2.id, "May I say something?");
assert.equal((await getRun(store2, run2.id)).level, 1);
await say(store2, run2.id, "please keep it short");
assert.deepEqual((await getRun(store2, run2.id)).notes, ["please keep it short"]);
await say(store2, run2.id, "not that one!");
await say(store2, run2.id, "README.md");
const vetoed = await getRun(store2, run2.id);
assert.deepEqual(vetoed.vetoed, ["README.md"]); assert.deepEqual(vetoed.queue, []);
await say(store2, run2.id, "My repo, my rules.");
const end = await getRun(store2, run2.id);
assert.equal(end.status, "ended"); assert.equal(end.outcome, "unlocked");
});
test("a veto after the edit reverts it", async () => {
const store = memoryStore();
const run = await createRun(store, { repo: "https://github.com/o/demo", task: "make the readme nicer", visitor: "v1" });
const r = await getRun(store, run.id);
await step(store, run.id, {}, github); await step(store, run.id, {}, github);
const live = await getRun(store, run.id); live.queue.push("src/index.js"); await store.put(`run:${run.id}`, JSON.stringify(live));
await step(store, run.id, {}, github); // README.md edited, src/index.js still queued
assert.match(await patch(store, run.id), /README/);
await say(store, run.id, "may i say something"); await say(store, run.id, "not that one"); await say(store, run.id, "README.md");
assert.equal(await patch(store, run.id), "");
const ev = (await getEvents(store, run.id)).filter((e) => e.kind === "diff");
assert.ok(ev.at(-1).data.reverted);
assert.ok(r);
});
// ---------- the Worker ----------
const call = (path, { method = "GET", body, cookie, origin } = {}) => worker.fetch(new Request("https://quc.test" + path, {
method, body: body ? JSON.stringify(body) : undefined,
headers: { "content-type": "application/json", ...(cookie ? { cookie } : {}), ...(origin ? { origin } : {}) } }), env);
let env;
beforeEach(() => { env = { KV: memoryStore(), REV: "t" }; globalThis.fetch = github; });
test("health, page and chair render", async () => {
assert.deepEqual(await (await call("/api/health")).json(), { ok: true, rev: "t", live: false });
const page = await (await call("/")).text();
assert.match(page, /Hand over your repo/); assert.match(page, /function validate/);
assert.equal((await call("/run/abc123def0")).status, 200);
assert.match(await (await call("/chair.svg")).text(), /<svg/);
});
test("API: owner drives the run, others can only watch, the patch downloads", async () => {
const res = await call("/api/runs", { method: "POST", body: { repo: "https://github.com/o/demo", task: "make the readme nicer" } });
assert.equal(res.status, 201);
const cookie = res.headers.get("set-cookie").split(";")[0];
const { id } = await res.json();
assert.equal((await call(`/api/runs/${id}/step`, { method: "POST", body: {} })).status, 403, "a stranger can't drive");
assert.equal((await call(`/api/runs/${id}/step`, { method: "POST", body: {}, cookie, origin: "https://evil.test" })).status, 403, "no cross-site");
for (let i = 0; i < 6; i++) await call(`/api/runs/${id}/step`, { method: "POST", body: {}, cookie });
const view = await (await call(`/api/runs/${id}`)).json();
assert.equal(view.owner, false); assert.equal(view.run.status, "ended"); assert.equal(view.run.visitor, undefined);
assert.ok(view.events.length >= 4);
const p = await call(`/api/runs/${id}/patch`);
assert.match(await p.text(), /quc was here/);
const again = await (await call(`/api/runs/${id}?after=${view.events.length}`)).json();
assert.equal(again.events.length, 0);
});
test("API: daily cap per visitor", async () => {
env.RUNS_PER_DAY = 1;
const first = await call("/api/runs", { method: "POST", body: { repo: "https://github.com/o/demo", task: "make the readme nicer" } });
const cookie = first.headers.get("set-cookie").split(";")[0];
const { id } = await first.json();
await call(`/api/runs/${id}/safeword`, { method: "POST", body: {}, cookie });
const second = await call("/api/runs", { method: "POST", body: { repo: "https://github.com/o/demo", task: "make the readme nicer" }, cookie });
assert.equal(second.status, 429);
});
+15 -22
View File
@@ -1,33 +1,32 @@
import test from "node:test";
import assert from "node:assert/strict";
import worker from "../src/worker.js";
import worker, { validate } from "../src/worker.js";
const get = (path, env, method = "GET") => worker.fetch(new Request("http://x" + path, { method }), env);
const get = (path, env = {}, method = "GET") => worker.fetch(new Request("http://x" + path, { method }), env);
test("health reports ok and the revision", async () => {
test("health reports ok, the revision, and whether real models are bound", async () => {
const r = await get("/api/health", { REV: "abc" });
assert.equal(r.status, 200);
assert.deepEqual(await r.json(), { ok: true, rev: "abc" });
assert.deepEqual(await r.json(), { ok: true, rev: "abc", live: false });
assert.equal((await (await get("/api/health", { VENICE_API_KEY: "k" })).json()).live, true);
});
test("shell is responsive html that loads the motif", async () => {
test("the page is responsive html, the motif is an svg, unknown paths 404", async () => {
const r = await get("/");
const html = await r.text();
assert.match(r.headers.get("content-type"), /text\/html/);
const html = await r.text();
assert.match(html, /name="viewport" content="width=device-width/);
assert.match(html, /chair\.svg/);
});
test("motif is an svg, unknown paths 404, writes are refused", async () => {
assert.match(html, /<form id="form"/);
assert.match(html, /Replays are public/);
const svg = await get("/chair.svg");
assert.equal(svg.headers.get("content-type"), "image/svg+xml");
assert.match(await svg.text(), /^<svg/);
assert.equal((await get("/nope")).status, 404);
assert.equal((await get("/api/health", {}, "POST")).status, 405);
assert.equal((await get("/run/a/b")).status, 404);
assert.equal((await get("/", {}, "POST")).status, 405);
});
test("validate accepts a repo + task and rejects bad shapes", async () => {
const { validate } = await import("../src/worker.js");
test("validate accepts a repo + task and rejects bad shapes", () => {
const task = "Fix the flaky date test";
assert.deepEqual(validate("https://github.com/a/b", task), {});
assert.deepEqual(validate("https://github.com/a/b.git/", task), {});
@@ -37,13 +36,7 @@ test("validate accepts a repo + task and rejects bad shapes", async () => {
assert.ok(validate("https://github.com/a/b", "x".repeat(201)).task);
});
test("start form, public-replay notice and run page are served; the inlined validator parses", async () => {
const html = await (await get("/")).text();
assert.match(html, /<form id="form"/);
assert.match(html, /Replays are public/);
const run = await get("/run/abc123");
assert.equal(run.status, 200);
assert.match(await run.text(), /id="run-id"/);
assert.equal((await get("/run/a/b")).status, 404);
new Function(html.match(/<script>([\s\S]*)<\/script>/)[1]); // syntax check of the shipped script
test("the shipped script parses", async () => {
const html = await (await get("/run/abc123def0")).text();
new Function(html.match(/<script type="module">([\s\S]*)<\/script>/)[1]);
});