quc v0: the cage, the chair, the safeword, the magic words, the replay (#241)
This commit was merged in pull request #241.
This commit is contained in:
1 parent
582695df5a
commit
f479c5f016
15 files changed
+1135
-162
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,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
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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>`;
|
||||
@@ -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;
|
||||
}
|
||||
+99
-101
@@ -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 });
|
||||
},
|
||||
};
|
||||
@@ -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
@@ -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]);
|
||||
});
|
||||
Reference in new issue
Block a user