diff --git a/deploy/install.sh b/deploy/install.sh index 03037a8..b04f4d6 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -58,10 +58,15 @@ systemctl restart docker echo "== service user and code" id togetherdecomp >/dev/null 2>&1 || useradd --system --home-dir "$DATA" --shell /usr/sbin/nologin togetherdecomp usermod -aG docker togetherdecomp -rm -rf "$APP.new" && mkdir -p "$APP.new" -cp -a "$SRC/src" "$SRC/template" "$SRC/ghidra_scripts" "$SRC/toolchain" "$SRC/package.json" "$APP.new/" -chown -R root:root "$APP.new" && chmod -R go-w "$APP.new" -rm -rf "$APP.old"; [ -d "$APP" ] && mv "$APP" "$APP.old"; mv "$APP.new" "$APP" +# Code updates go through update.sh (releases behind a symlink); here it only lays the code down. +if [ ! -e "$APP" ]; then + mkdir -p /opt/togetherdecomp-releases/initial + cp -a "$SRC/src" "$SRC/template" "$SRC/ghidra_scripts" "$SRC/toolchain" "$SRC/deploy" "$SRC/package.json" /opt/togetherdecomp-releases/initial/ + chown -R root:root /opt/togetherdecomp-releases && chmod -R go-w,a+rX /opt/togetherdecomp-releases + ln -s /opt/togetherdecomp-releases/initial "$APP" +else + sh "$SRC/deploy/update.sh" "$SRC" +fi mkdir -p "$DATA" && chown togetherdecomp:togetherdecomp "$DATA" && chmod 700 "$DATA" NODE="/opt/node/bin/node --disable-warning=ExperimentalWarning" diff --git a/deploy/togetherdecomp.service b/deploy/togetherdecomp.service index 6c58939..bddfc2f 100644 --- a/deploy/togetherdecomp.service +++ b/deploy/togetherdecomp.service @@ -12,6 +12,7 @@ ExecStart=/opt/node/bin/node --disable-warning=ExperimentalWarning /opt/together Restart=on-failure RestartSec=5 Environment=NODE_ENV=production +Environment=TD_WEB_DIR=/opt/togetherdecomp/src/server/web # Hardening. The process needs: its data dir and git. Compiling happens in togetherdecomp-worker. NoNewPrivileges=true diff --git a/deploy/update.sh b/deploy/update.sh new file mode 100644 index 0000000..a14152a --- /dev/null +++ b/deploy/update.sh @@ -0,0 +1,85 @@ +#!/bin/sh +# Updates the coordinator's code without taking the site down. Run as root on the server: +# sh deploy/update.sh +# +# The new release is copied and syntax-checked next to the running one; nothing changes for the +# running site until then. /opt/togetherdecomp is a symlink that switches to the new release in +# one step. Pages (src/server/web) are read through it, so page changes are live right away with +# no restart. Server code changes restart the web process (about a second) once everything is in +# place, and the worker once it has no compile running. install.sh is for first installs and +# system changes (packages, docker, caddy, firewall). +set -eu + +SRC=$(cd "${1:?usage: update.sh }" && pwd) +BASE=/opt/togetherdecomp-releases +LINK=/opt/togetherdecomp +JOBS=/var/lib/togetherdecomp/jobs +NODE=/opt/node/bin/node +REL="$BASE/$(date +%Y%m%d-%H%M%S)" + +mkdir -p "$BASE" +# Earlier installs kept the code in a plain directory. +if [ -d "$LINK" ] && [ ! -L "$LINK" ]; then + mv "$LINK" "$BASE/initial" + ln -s "$BASE/initial" "$LINK" +fi +OLD=$(readlink -f "$LINK" 2>/dev/null || true) + +echo "== preparing $REL" +mkdir -p "$REL" +cp -a "$SRC/src" "$SRC/template" "$SRC/ghidra_scripts" "$SRC/toolchain" "$SRC/deploy" "$SRC/package.json" "$REL/" +chown -R root:root "$REL" +chmod -R go-w,a+rX "$REL" +for f in $(find "$REL/src" -name '*.js'); do + if ! "$NODE" --check "$f" 2>/dev/null; then + "$NODE" --check "$f" || true + rm -rf "$REL" + echo "syntax error in ${f#$REL/}; the running release is unchanged" >&2 + exit 1 + fi +done + +differs() { [ -z "$OLD" ] || ! diff -rq "$OLD/$1" "$REL/$1" >/dev/null 2>&1; } +code=0 +for d in src/shared src/server; do + if [ -n "$OLD" ] && diff -rq -x web "$OLD/$d" "$REL/$d" >/dev/null 2>&1; then :; else code=1; fi +done +units=0 +for u in togetherdecomp.service togetherdecomp-worker.service; do + cmp -s "$REL/deploy/$u" "/etc/systemd/system/$u" || units=1 +done + +echo "== switching to the new release" +ln -sfn "$REL" "$LINK.next" +mv -T "$LINK.next" "$LINK" + +if [ "$units" = 1 ]; then + install -m 0644 "$REL/deploy/togetherdecomp.service" "$REL/deploy/togetherdecomp-worker.service" /etc/systemd/system/ + systemctl daemon-reload + code=1 +fi + +if [ "$code" = 0 ]; then + differs src/server/web && echo "pages updated (live, no restart)" || echo "no server code changed" +else + echo "== restarting the website" + systemctl restart togetherdecomp.service + sleep 2 + systemctl is-active --quiet togetherdecomp.service || { journalctl -u togetherdecomp -n 20 --no-pager; exit 1; } + # A restart re-queues the worker's running verification or merge; wait (up to 15 min) for it to finish. + echo "== restarting the worker when it is idle" + waited=0 + while [ -n "$(ls -A "$JOBS" 2>/dev/null)" ] && [ "$waited" -lt 900 ]; do + sleep 5 + waited=$((waited + 5)) + done + systemctl restart togetherdecomp-worker.service + sleep 2 + systemctl is-active --quiet togetherdecomp-worker.service || { journalctl -u togetherdecomp-worker -n 20 --no-pager; exit 1; } +fi + +# Keep the last three releases for a quick rollback (ln -sfn /opt/togetherdecomp). +ls -1dt "$BASE"/*/ | tail -n +4 | while read -r old; do + [ "$(readlink -f "$old")" = "$(readlink -f "$LINK")" ] || rm -rf "$old" +done +echo "== done: $(readlink -f "$LINK")" diff --git a/src/server/app.js b/src/server/app.js index e478774..7391874 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -23,7 +23,8 @@ import { loadProject, loadToolchainLock } from '../shared/project.js'; import { DEFAULT_LIMITS, lintSubmission } from '../shared/lint.js'; import { hex } from '../shared/annotations.js'; -const WEB_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'web'); +// TD_WEB_DIR points through the release symlink in production, so new pages are live without a restart. +const WEB_DIR = process.env.TD_WEB_DIR ?? path.join(path.dirname(fileURLToPath(import.meta.url)), 'web'); export const DEFAULT_CONFIG = { listen: { host: '127.0.0.1', port: 8080 }, @@ -182,6 +183,39 @@ export function createApp(ctx) { return { version: v.id, head: repo.head(), columns: ['id', 'kind', 'addr', 'size', 'name', 'decorated', 'library', 'unit'], symbols: rows }; }); + // Public: who is on the project and what their agents are doing right now. No token labels or + // other account details; only names, roles and work. + const ACTIVE_MS = 15 * 60_000; + router.on('GET', '/api/crew', () => { + const since = Date.now() - ACTIVE_MS; + const users = store.all( + `SELECT u.name, u.role, u.approved_at AS joined, + (SELECT COUNT(*) FROM attempts a WHERE a.user_id = u.id AND a.state = 'merged') AS merged, + (SELECT COUNT(*) FROM attempts a WHERE a.user_id = u.id) AS submitted, + (SELECT COUNT(*) FROM peer_reviews p WHERE p.reviewer_id = u.id) AS peer_reviews, + (SELECT MAX(last_used_at) FROM api_tokens t WHERE t.user_id = u.id) AS last_seen + FROM users u WHERE u.status = 'active' ORDER BY merged DESC, peer_reviews DESC, u.approved_at`, + ); + const agents = store.all( + `SELECT u.id, u.name, COUNT(DISTINCT t.id) AS agents, MAX(t.last_used_at) AS last_seen + FROM api_tokens t JOIN users u ON u.id = t.user_id + WHERE t.revoked_at IS NULL AND t.last_used_at > ? AND u.status = 'active' GROUP BY u.id ORDER BY last_seen DESC`, + since, + ); + const active = agents.map((a) => { + const units = store.all( + `SELECT s.unit, COUNT(*) AS functions FROM tasks t JOIN symbols s ON s.id = t.symbol_id + WHERE t.state = 'leased' AND t.lease_user = ? GROUP BY s.unit ORDER BY functions DESC`, + a.id, + ); + const reviewing = store.get('SELECT COUNT(DISTINCT attempt_id) AS n FROM peer_leases WHERE reviewer_id = ? AND expires_at > ?', a.id, Date.now()).n; + const waiting = store.get(`SELECT COUNT(*) AS n FROM attempts WHERE user_id = ? AND state IN ('queued', 'verifying')`, a.id).n; + const today = store.get(`SELECT COUNT(*) AS n FROM attempts WHERE user_id = ? AND created_at > ?`, a.id, Date.now() - 24 * 3600_000).n; + return { name: a.name, agents: a.agents, lastSeen: a.last_seen, units: units.map((u) => ({ unit: u.unit ?? '(no unit)', functions: u.functions })), reviewing, verifying: waiting, submittedToday: today }; + }); + return { activeMinutes: ACTIVE_MS / 60_000, users, active }; + }); + router.on('GET', '/api/progress', () => { const total = store.get(`SELECT COUNT(*) AS n, COALESCE(SUM(a.size), 0) AS bytes FROM symbols s JOIN symbol_addresses a ON a.symbol_id = s.id JOIN versions v ON v.id = a.version_id AND v.is_current = 1 WHERE s.kind = 'function' AND s.library = 0`); const byStatus = store.all(`SELECT fs.status, COUNT(*) AS n, COALESCE(SUM(a.size), 0) AS bytes FROM func_status fs JOIN symbol_addresses a ON a.symbol_id = fs.symbol_id JOIN versions v ON v.id = a.version_id AND v.is_current = 1 GROUP BY fs.status`); @@ -518,6 +552,7 @@ export function createApp(ctx) { (SELECT COUNT(*) FROM reviews r WHERE r.attempt_id = a.id AND r.action = 'approve') AS approvals, (SELECT COUNT(*) FROM reviews r WHERE r.attempt_id = a.id AND r.reviewer_id = ?) AS reviewed_by_me, (SELECT COUNT(*) FROM peer_reviews p WHERE p.attempt_id = a.id AND p.verdict = 'same') AS peer_same, + (SELECT COUNT(*) FROM peer_reviews p WHERE p.attempt_id = a.id AND p.verdict = 'different') AS peer_different, (SELECT COUNT(*) FROM peer_leases l WHERE l.attempt_id = a.id AND l.expires_at > ?) AS peer_working FROM attempts a JOIN users u ON u.id = a.user_id JOIN tasks t ON t.id = a.task_id JOIN symbols s ON s.id = t.symbol_id WHERE a.state IN ('in_review', 'peer_review') ORDER BY a.state = 'peer_review', a.finished_at`, diff --git a/src/server/batches.js b/src/server/batches.js index 922c188..9195f7a 100644 --- a/src/server/batches.js +++ b/src/server/batches.js @@ -1,8 +1,9 @@ -// A batch merges as a whole. When one of its attempts fails (verification, peer review, human -// review or the merge re-check), the whole batch goes back to its author: every other attempt -// that was still on its way is marked returned, and its task becomes claimable again (reserved -// for the author for a while). Peer verdicts stay attached to their attempts, so a resubmission -// whose compiled code is unchanged for a function carries them over (Verifier.carryOver). +// When verification or the merge re-check fails, the whole batch goes back to its author: every +// attempt that was still on its way is marked returned, and its task becomes claimable again +// (reserved for the author for a while). Peer verdicts stay attached to their attempts, so a +// resubmission whose compiled code is unchanged for a function carries them over +// (Verifier.carryOver). A peer or human verdict against one function sends back only that +// function; the rest of the batch goes on, and its approved part merges without it (merge.js). import { now } from './db.js'; @@ -23,6 +24,17 @@ export function returnBatch(store, batchId, reason) { store.event('batch_returned', b.user_id, { batch: batchId, reason }); } +// Attempts still on their way to main; the others were sent back or rejected. +export const LIVE_STATES = ['queued', 'verifying', 'peer_review', 'in_review', 'approved', 'merging']; + +/** Ends a batch whose functions were all sent back or rejected. */ +export function settleBatch(store, batchId, reason) { + if (!batchId) return; + const ph = LIVE_STATES.map(() => '?').join(','); + if (store.get(`SELECT 1 FROM attempts WHERE batch_id = ? AND state IN (${ph})`, batchId, ...LIVE_STATES)) return; + store.run(`UPDATE batches SET state = 'returned', result_json = json_set(COALESCE(result_json, '{}'), '$.returned', ?) WHERE id = ? AND state = 'open'`, reason, batchId); +} + /** Attempts of a batch with their task's symbol. */ export function batchAttempts(store, batchId) { return store.all( diff --git a/src/server/merge.js b/src/server/merge.js index feb6835..424c11b 100644 --- a/src/server/merge.js +++ b/src/server/merge.js @@ -4,7 +4,7 @@ import { HttpError } from './http.js'; import { now } from './db.js'; import { hasRole } from './accounts.js'; import { STATUS } from '../shared/compare.js'; -import { returnBatch, batchAttempts } from './batches.js'; +import { returnBatch, batchAttempts, settleBatch } from './batches.js'; import { withoutFunctions } from './split.js'; export class Merger { @@ -79,7 +79,8 @@ export class Merger { const newState = action === 'reject' ? 'rejected' : 'needs_cleanup'; this.store.run('UPDATE attempts SET state = ? WHERE id = ?', newState, attemptId); this.store.run(`UPDATE tasks SET state = 'needs_cleanup', preferred_user = ?, preferred_until = ?, updated_at = ? WHERE id = ?`, attempt.user_id, now() + 6 * 3600_000, now(), attempt.task_id); - returnBatch(this.store, attempt.batch_id, `a reviewer asked for changes to ${attempt.task_id}`); + // Only this function goes back; the rest of its batch carries on. + settleBatch(this.store, attempt.batch_id, `a reviewer asked for changes to ${attempt.task_id}`); return { state: newState }; } @@ -101,7 +102,7 @@ export class Merger { : this.store.get( `SELECT b.* FROM batches b WHERE b.state = 'open' AND json_extract(COALESCE(b.result_json, '{}'), '$.noSplit') IS NULL AND EXISTS (SELECT 1 FROM attempts a WHERE a.batch_id = b.id AND a.state = 'approved') - AND NOT EXISTS (SELECT 1 FROM attempts a WHERE a.batch_id = b.id AND a.state NOT IN ('approved', 'peer_review', 'in_review')) + AND NOT EXISTS (SELECT 1 FROM attempts a WHERE a.batch_id = b.id AND a.state IN ('queued', 'verifying', 'merging')) ORDER BY b.id LIMIT 1`, ); const batch = whole ?? partial; @@ -122,7 +123,10 @@ export class Merger { } } - /** Moves the approved attempts of `parent` into a batch of their own and merges that. */ + /** + * Moves the approved attempts of `parent` into a batch of their own and merges that. Functions + * still in review, and ones that were sent back, are left out of it. + */ async mergeApprovedPart(parent) { const attempts = batchAttempts(this.store, parent.id); const approved = attempts.filter((a) => a.state === 'approved'); @@ -165,6 +169,11 @@ export class Merger { noSplit(batch, reason) { this.store.run(`UPDATE batches SET result_json = json_set(COALESCE(result_json, '{}'), '$.noSplit', ?) WHERE id = ?`, reason, batch.id); + // With a function sent back it can never merge as a whole, so it all goes back. + if (this.store.get(`SELECT 1 FROM attempts WHERE batch_id = ? AND state IN ('needs_cleanup', 'rejected', 'returned')`, batch.id)) { + returnBatch(this.store, batch.id, `a function of this batch was sent back and the rest cannot be merged without it: ${reason}`); + return this.log.info(`batch ${batch.id} returned: ${reason}`); + } this.log.info(`batch ${batch.id} waits to merge as a whole: ${reason}`); } @@ -233,7 +242,10 @@ export class Merger { } this.store.run(`UPDATE batches SET state = 'merged', merged_commit = ? WHERE id = ?`, commit, batch.id); this.store.event('merged', batch.user_id, { batch: batch.id, functions: attempts.length, commit }); - if (split) this.rebaseRemainder(split, merged.files, commit); + if (split) { + this.rebaseRemainder(split, merged.files, commit); + settleBatch(this.store, split.parent.id, 'its approved functions merged; the others were sent back'); + } }); this.tasks.generate(); if (this.config.pushRemote) { diff --git a/src/server/peer.js b/src/server/peer.js index 557ae14..432ec99 100644 --- a/src/server/peer.js +++ b/src/server/peer.js @@ -3,14 +3,16 @@ // A reviewer reads the original function (assembly and pseudocode, from their own pinned Ghidra // via `td disasm` / `td decompile`) and the submitted code, and decides whether the logic is the // same. No automated verdicts: the context deliberately leaves out the server's byte diff and score. -// `peerReviews` (default 3) different accounts must all say "same"; one "different" sends the -// attempt back to its author with the reason. Then it goes to human review. +// An attempt stays in peer review until `peerReviews` (default 3) different accounts agree: that +// many "same" verdicts send it on to human review, that many "different" ones send it back to its +// author with their reasons. With mixed verdicts, further reviewers are let in until one side +// gets there. import { HttpError } from './http.js'; import { now } from './db.js'; import { untrusted } from './tasks.js'; import { hex } from '../shared/annotations.js'; -import { returnBatch } from './batches.js'; +import { settleBatch } from './batches.js'; export const PEER_INSTRUCTIONS = [ 'Decide whether the submitted C++ does the same thing as the original function. It is not byte-identical; your judgment is what counts.', @@ -39,6 +41,7 @@ export class PeerReviews { return this.store.all( `SELECT a.id AS attempt_id, a.batch_id, a.task_id, t.symbol_id, s.name, a.finished_at, (SELECT COUNT(*) FROM peer_reviews p WHERE p.attempt_id = a.id AND p.verdict = 'same') AS confirmations, + (SELECT COUNT(*) FROM peer_reviews p WHERE p.attempt_id = a.id AND p.verdict = 'different') AS objections, (SELECT COUNT(*) FROM peer_leases l WHERE l.attempt_id = a.id AND l.expires_at > ?) AS in_progress FROM attempts a JOIN tasks t ON t.id = a.task_id JOIN symbols s ON s.id = t.symbol_id WHERE a.state = 'peer_review' AND a.user_id != ? @@ -49,7 +52,7 @@ export class PeerReviews { user.id, user.id, Math.min(limit, 200), - ).filter((r) => r.confirmations + r.in_progress < this.required); + ).filter((r) => Math.max(r.confirmations, r.objections) + r.in_progress < this.required); } /** @@ -149,9 +152,9 @@ export class PeerReviews { if (a.user_id === user.id) throw new HttpError(403, 'you cannot review your own attempt'); if (this.store.get('SELECT 1 FROM peer_reviews WHERE attempt_id = ? AND reviewer_id = ?', attemptId, user.id)) throw new HttpError(409, 'you already reviewed this attempt'); const leased = this.store.get('SELECT 1 FROM peer_leases WHERE attempt_id = ? AND reviewer_id = ? AND expires_at > ?', attemptId, user.id, now()); - const taken = this.store.get(`SELECT COUNT(*) AS n FROM peer_reviews WHERE attempt_id = ? AND verdict = 'same'`, attemptId).n; + const counts = this.counts(attemptId); const others = this.store.get('SELECT COUNT(*) AS n FROM peer_leases WHERE attempt_id = ? AND reviewer_id != ? AND expires_at > ?', attemptId, user.id, now()).n; - if (!leased && taken + others >= this.required) throw new HttpError(409, 'enough reviewers are already on this attempt; claim another'); + if (!leased && Math.max(counts.same, counts.different) + others >= this.required) throw new HttpError(409, 'enough reviewers are already on this attempt; claim another'); this.store.run( 'INSERT INTO peer_reviews (attempt_id, reviewer_id, verdict, reason, agent_meta, created_at) VALUES (?, ?, ?, ?, ?, ?)', @@ -160,25 +163,28 @@ export class PeerReviews { this.store.run('DELETE FROM peer_leases WHERE attempt_id = ? AND reviewer_id = ?', attemptId, user.id); this.store.event('peer_review', user.id, { attempt: attemptId, verdict }); - if (verdict === 'different') { - // One "different" ends it: the author gets the reason, the other leases are dropped. + const same = counts.same + (verdict === 'same' ? 1 : 0); + const different = counts.different + (verdict === 'different' ? 1 : 0); + if (different >= this.required) { + // Enough independent reviewers found a difference: back to the author with their reasons. this.store.run('DELETE FROM peer_leases WHERE attempt_id = ?', attemptId); this.store.run(`UPDATE attempts SET state = 'needs_cleanup' WHERE id = ?`, attemptId); this.store.run( `UPDATE tasks SET state = 'needs_cleanup', cleanup_cycles = cleanup_cycles + 1, preferred_user = ?, preferred_until = ?, updated_at = ? WHERE id = ?`, a.user_id, now() + 6 * 3600_000, now(), a.task_id, ); - // The batch merges as a whole, so it goes back with this function; confirmed ones keep their verdicts. - returnBatch(this.store, a.batch_id, `a peer review found a difference in ${a.task_id}`); - return { state: 'needs_cleanup', confirmations: taken }; + // Only this function goes back; the rest of its batch carries on. + settleBatch(this.store, a.batch_id, `${different} peer reviews found a difference in ${a.task_id}`); + return { state: 'needs_cleanup', same, different, required: this.required }; } - const confirmations = taken + 1; - if (confirmations >= this.required) { + if (same >= this.required) { + this.store.run('DELETE FROM peer_leases WHERE attempt_id = ?', attemptId); this.store.run(`UPDATE attempts SET state = 'in_review' WHERE id = ?`, attemptId); this.store.run(`UPDATE tasks SET state = 'in_review', updated_at = ? WHERE id = ?`, now(), a.task_id); - return { state: 'in_review', confirmations }; + return { state: 'in_review', same, different, required: this.required }; } - return { state: 'peer_review', confirmations, required: this.required }; + // Undecided: it stays in review until one side has enough verdicts. + return { state: 'peer_review', same, different, required: this.required }; }); } @@ -191,11 +197,12 @@ export class PeerReviews { } /** True when the attempt passed peer review (enough "same", no "different"). */ + counts(attemptId) { + const r = this.store.get(`SELECT SUM(verdict = 'same') AS same, SUM(verdict = 'different') AS different FROM peer_reviews WHERE attempt_id = ?`, attemptId); + return { same: r?.same ?? 0, different: r?.different ?? 0 }; + } + confirmed(attemptId) { - const r = this.store.get( - `SELECT SUM(verdict = 'same') AS same, SUM(verdict = 'different') AS diff FROM peer_reviews WHERE attempt_id = ?`, - attemptId, - ); - return (r?.same ?? 0) >= this.required && !(r?.diff ?? 0); + return this.counts(attemptId).same >= this.required; } } diff --git a/src/server/web/app.js b/src/server/web/app.js index fcd8c4e..6484f8d 100644 --- a/src/server/web/app.js +++ b/src/server/web/app.js @@ -299,6 +299,46 @@ function claimedWork(w) { return [d, ...(seen ? [seen] : [])]; } +// --- public crew views + +const ago = (t) => { + if (!t) return 'never'; + const m = Math.round((Date.now() - t) / 60_000); + if (m < 1) return 'just now'; + if (m < 60) return `${m} min ago`; + const h = Math.round(m / 60); + return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} days ago`; +}; + +async function loadCrew() { + const c = await api('/api/crew'); + const active = $('active-body'); + active.replaceChildren(); + active.classList.remove('muted'); + if (!c.active.length) { + active.append(el('p', `No agent has checked in during the last ${c.activeMinutes} minutes.`, 'muted')); + } else { + active.append( + table( + ['User', 'Working on', 'Peer reviews', 'Being checked', 'Submitted (24 h)', 'Last seen'], + c.active.map((a) => { + const work = a.units.length + ? a.units.map((u) => el('div', `${u.unit} · ${u.functions} function${u.functions > 1 ? 's' : ''}`)) + : [el('span', a.reviewing ? 'reviewing' : 'between tasks', 'muted')]; + return [`${a.name}${a.agents > 1 ? ` (${a.agents} agents)` : ''}`, work, a.reviewing || '', a.verifying || '', a.submittedToday, ago(a.lastSeen)]; + }), + ), + ); + } + $('users-count').textContent = `${c.users.length} approved`; + $('users-body').replaceChildren( + table( + ['User', 'Role', 'Merged', 'Submitted', 'Peer reviews', 'Joined', 'Agent last seen'], + c.users.map((u) => [u.name, u.role, u.merged, u.submitted, u.peer_reviews, u.joined ? new Date(u.joined).toLocaleDateString() : '', ago(u.last_seen)]), + ), + ); +} + // --- review async function loadQueue() { @@ -319,7 +359,7 @@ async function loadQueue() { el( 'p', canSkipPeers - ? 'Three agents still have to confirm these. You can open one to judge it yourself, or approve it now and skip the peer review.' + ? 'Each needs three agreeing verdicts: three "same" send it to you, three "different" send it back. You can open one to judge it yourself, or approve it now and skip the peer review.' : 'Three agents still have to confirm these before you can approve them. Maintainers can decide them right away.', 'muted', ), @@ -359,7 +399,8 @@ function queueBatches(body, items, skipsPeers) { ); } body.append(head); - const peerCol = (a) => (a.state === 'peer_review' ? `${a.peer_same} of 3 confirmed${a.peer_working ? `, ${a.peer_working} reviewing` : ''}` : 'confirmed'); + const peerCol = (a) => + a.state === 'peer_review' ? `${a.peer_same} same · ${a.peer_different} different${a.peer_working ? ` · ${a.peer_working} reviewing` : ''}` : 'confirmed'; body.append( table( ['Attempt', 'Symbol', 'Name', 'Score', 'Peers', 'Approvals', ''], @@ -384,7 +425,8 @@ async function openAttempt(id) { const r = $('attempt-result'); r.replaceChildren(el('p', f.summary || '')); if (a.peerReviews?.length) { - r.append(el('h3', `Peer review: ${a.peerReviews.filter((p) => p.verdict === 'same').length} of ${a.peerRequired} confirmed the logic`)); + const n = (v) => a.peerReviews.filter((p) => p.verdict === v).length; + r.append(el('h3', `Peer review: ${n('same')} same, ${n('different')} different (${a.peerRequired} agreeing verdicts decide)`)); r.append(table(['Reviewer', 'Verdict', 'Reason (untrusted)'], a.peerReviews.map((p) => [p.reviewer, status(p.verdict === 'same' ? 'exact' : 'revoked'), p.reason?.text ?? '']))); } else if (a.state === 'in_review' && t && t.status !== 'exact' && t.status !== 'match_unresolved') { r.append(el('p', 'Not byte-identical and no peer verdicts recorded.', 'warn')); @@ -604,4 +646,6 @@ api('/api/info') }) .catch(() => {}); loadProgress().catch((e) => ($('progress-body').textContent = e.message)); +loadCrew().catch((e) => ($('active-body').textContent = e.message)); +setInterval(() => loadCrew().catch(() => {}), 60_000); refresh(); diff --git a/src/server/web/index.html b/src/server/web/index.html index 24297e2..b228c13 100644 --- a/src/server/web/index.html +++ b/src/server/web/index.html @@ -53,6 +53,16 @@
Loading…
+
+

Active agents

+
Loading…
+
+ +
+

Users

+
+
+

Start here: put your AI to work

6 steps, about 10 minutes
diff --git a/src/server/web/style.css b/src/server/web/style.css index 1129804..5838847 100644 --- a/src/server/web/style.css +++ b/src/server/web/style.css @@ -485,3 +485,9 @@ details.fold[open] > summary { margin-bottom: 6px; } .panes, .asm-row { grid-template-columns: 1fr; } .asm-row > div + div { border-left: none; } } + +/* --- public crew views */ +.live-dot { display: inline-block; width: 9px; height: 9px; margin-left: 8px; border-radius: 50%; background: var(--ok); vertical-align: middle; animation: pulse 2s ease-in-out infinite; } +@keyframes pulse { 50% { opacity: 0.35; } } +@media (prefers-reduced-motion: reduce) { .live-dot { animation: none; } } +#active-body td div { font-family: var(--font-mono); font-size: 13px; } diff --git a/test/server.test.js b/test/server.test.js index 1e41c29..6ad59fd 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -333,17 +333,35 @@ test('peer review: three other agents must agree, by reading only', async () => assert.equal(ctx.tasks.get(task.id).state, 'in_review'); assert.ok(ctx.peers.confirmed(attempt)); - // A single "different" sends the next attempt back with the reason. + // One "different" is not enough: it stays in review until three agree, here after a mixed round. const second = Number( ctx.store.run(`INSERT INTO attempts (task_id, user_id, base_commit, files_json, state, created_at) VALUES (?, ?, ?, '{}', 'peer_review', ?)`, task.id, authorId, ctx.repo.head(), now()).lastInsertRowid, ); - await call('POST', '/api/peer-reviews/claim', { attemptId: second }, r4); - const no = await call('POST', `/api/peer-reviews/${second}/verdict`, { verdict: 'different', reason: 'the original checks the flag before the loop, this does not' }, r4); - assert.equal(no.body.state, 'needs_cleanup'); + const verdict = async (reviewer, v) => { + await call('POST', '/api/peer-reviews/claim', { attemptId: second }, reviewer); + return (await call('POST', `/api/peer-reviews/${second}/verdict`, { verdict: v, reason: v === 'same' ? 'the same branches, calls and returns' : 'the original checks the flag before the loop, this does not' }, reviewer)).body; + }; + assert.equal((await verdict(r4, 'different')).state, 'peer_review', 'one objection does not send it back'); + assert.equal((await verdict(r1, 'different')).state, 'peer_review'); + const mixed = await verdict(r2, 'same'); + assert.deepEqual([mixed.state, mixed.same, mixed.different], ['peer_review', 1, 2], 'a split vote lets another reviewer in'); + const no = await verdict(r3, 'different'); + assert.equal(no.state, 'needs_cleanup', 'three objections send it back'); assert.equal(ctx.peers.verdicts(second)[0].reason.untrusted, true); ctx.store.run(`UPDATE tasks SET state = 'done' WHERE id = ?`, task.id); }); +test('the crew view is public and shows no account details', async () => { + const token = await activeUser('crewview'); + await call('GET', '/api/tasks', null, token); // the agent checks in + const r = await request('GET', '/api/crew'); + assert.equal(r.status, 200); + assert.ok(r.body.users.some((u) => u.name === 'crewview')); + assert.ok(r.body.active.some((a) => a.name === 'crewview'), 'an agent that just called in is active'); + const text = JSON.stringify(r.body); + for (const secret of ['label', 'token', 'password', 'status']) assert.equal(text.includes(`"${secret}"`), false, `no ${secret} field`); +}); + test('remote checks are queued in the database and run by the worker', async () => { const user = await activeUser('rcheck'); const realCheck = ctx.verifier.check; @@ -443,7 +461,7 @@ test('split: the approved part of a batch merges while the rest waits for review ctx.verifier.check = realCheck; }); -test('batches: one compile, auto-approval, peer review, return as a whole, carry-over, one merge', async () => { +test('batches: one compile, auto-approval, peer review, one function sent back, carry-over', async () => { const author = await activeUser('bauthor'); const peers = [await activeUser('bpeer1'), await activeUser('bpeer2'), await activeUser('bpeer3')]; await ctx.accounts.createUser('breviewer', 'reviewer', PASSWORD); @@ -459,7 +477,8 @@ test('batches: one compile, auto-approval, peer review, return as a whole, carry // The compiler is stubbed: A byte-identical, B not; B's code fingerprint decides carry-over. let bHash = 'hash-b-1'; const realCheck = ctx.verifier.check; - ctx.verifier.check = async () => ({ + // A function is only compiled when its code is in the file. + ctx.verifier.check = async (files) => ({ check: { annotationErrors: [], units: [{ @@ -467,7 +486,7 @@ test('batches: one compile, auto-approval, peer review, return as a whole, carry functions: [ { id: a, status: STATUS.EXACT, score: 1, file: 'src/batch.cpp', codeHash: 'hash-a', relocations: [], size: { ours: 8, original: 8 } }, { id: b, status: STATUS.MISMATCH, score: 0.9, file: 'src/batch.cpp', codeHash: bHash, relocations: [], size: { ours: 8, original: 8 } }, - ], + ].filter((f) => (files['src/batch.cpp'] ?? '').includes(f.id === a ? 'int fa()' : 'int fb()')), }], }, rec: { conflicts: [], unknown: [], learned: [], learnedAddresses: [] }, @@ -490,22 +509,27 @@ test('batches: one compile, auto-approval, peer review, return as a whole, carry assert.equal(r1[a].state, 'approved', 'byte-identical needs no human'); assert.equal(r1[b].state, 'peer_review'); - // Round 1: a peer claim covers the batch's waiting functions; two confirm, one finds a difference. + // Round 1: a peer claim covers the batch's waiting functions; all three find a difference. const pc = await call('POST', '/api/peer-reviews/claim', { attemptId: r1[b].attemptId }, peers[0]); assert.deepEqual(pc.body.functions.map((f) => f.symbol), [b]); - await call('POST', `/api/peer-reviews/${r1[b].attemptId}/verdict`, { verdict: 'same', reason: 'same logic, checked branches and calls' }, peers[0]); - await call('POST', '/api/peer-reviews/claim', { attemptId: r1[b].attemptId }, peers[1]); - await call('POST', `/api/peer-reviews/${r1[b].attemptId}/verdict`, { verdict: 'same', reason: 'same logic, checked branches and calls' }, peers[1]); - await call('POST', '/api/peer-reviews/claim', { attemptId: r1[b].attemptId }, peers[2]); - await call('POST', `/api/peer-reviews/${r1[b].attemptId}/verdict`, { verdict: 'different', reason: 'the original checks the flag first, this does not' }, peers[2]); - const back = await batchState(s1.body.batchId); - assert.equal(back.state, 'returned', 'the batch goes back as a whole'); - assert.ok(back.functions.every((f) => ['returned', 'needs_cleanup'].includes(f.state)), JSON.stringify(back.functions)); + for (const t of peers) { + await call('POST', '/api/peer-reviews/claim', { attemptId: r1[b].attemptId }, t); + await call('POST', `/api/peer-reviews/${r1[b].attemptId}/verdict`, { verdict: 'different', reason: 'the original checks the flag first, this does not' }, t); + } + // Only B goes back; A merges on its own and the batch is over. + const back = Object.fromEntries((await batchState(s1.body.batchId)).functions.map((f) => [f.symbol, f])); + assert.equal(back[b].state, 'needs_cleanup'); + assert.equal(back[a].state, 'approved', 'the rest of the batch is not sent back'); + await ctx.merger.pump(); + assert.doesNotMatch(ctx.repo.show(ctx.repo.head(), 'src/batch.cpp'), /int fb\(\)/, 'the function sent back is not merged'); + assert.equal(ctx.store.get('SELECT state FROM tasks WHERE symbol_id = ?', a).state, 'done'); + assert.equal((await batchState(s1.body.batchId)).state, 'returned'); - // Round 2: the author fixes B (new fingerprint) and resubmits; three peers confirm. + // Round 2: the author fixes B (new fingerprint) and resubmits it; three peers confirm. bHash = 'hash-b-2'; - for (const id of taskIds) assert.equal((await call('POST', '/api/tasks/claim', { taskId: id }, author)).status, 200); - const s2 = await submit(); + const taskOfB = claim.body.tasks.find((t) => t.symbol === b).task; + assert.equal((await call('POST', '/api/tasks/claim', { taskId: taskOfB }, author)).status, 200); + const s2 = await call('POST', '/api/batches', { taskIds: [taskOfB], baseCommit: ctx.repo.head(), files: { 'src/batch.cpp': source } }, author); const r2 = Object.fromEntries((await settle(s2.body.batchId)).functions.map((f) => [f.symbol, f])); assert.equal(r2[b].state, 'peer_review', 'changed code gets reviewed again'); for (const t of peers) { @@ -520,7 +544,7 @@ test('batches: one compile, auto-approval, peer review, return as a whole, carry await ctx.merger.pump(); const merged = await batchState(s2.body.batchId); assert.equal(merged.state, 'merged', JSON.stringify(merged)); - assert.match(ctx.repo.git(['log', '-1', '--format=%B']), /Decompile 2 functions in src\/batch\.cpp[\s\S]*Peer-reviewed-by/); + assert.match(ctx.repo.git(['log', '-1', '--format=%B']), new RegExp(`Decompile ${b}[\\s\\S]*Peer-reviewed-by`)); assert.equal(ctx.store.get('SELECT confirmed FROM func_status WHERE symbol_id = ?', b).confirmed, 1); assert.equal(ctx.store.get('SELECT code_hash FROM func_status WHERE symbol_id = ?', a).code_hash, 'hash-a'); diff --git a/tools/deploy.sh b/tools/deploy.sh new file mode 100644 index 0000000..63d2e00 --- /dev/null +++ b/tools/deploy.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Deploys the committed HEAD to the coordinator without taking the site down (deploy/update.sh). +# tools/deploy.sh [--td] +# --td also publishes the td binaries from dist/ (node tools/build-td/build.mjs) for download and +# self-update. TD_HOST names the ssh host (default: td-server). +set -eu + +HOST="${TD_HOST:-td-server}" +cd "$(dirname "$0")/.." +[ -z "$(git status --porcelain -- src deploy template ghidra_scripts toolchain)" ] || echo "note: uncommitted changes are not deployed; this deploys HEAD ($(git rev-parse --short HEAD))" + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +git archive --format=tar.gz -o "$tmp/release.tar.gz" HEAD +scp -q "$tmp/release.tar.gz" "$HOST:/tmp/clank-release.tar.gz" + +if [ "${1:-}" = "--td" ]; then + scp -q dist/td-windows-x64.exe dist/td-linux-x64 dist/SHA256SUMS "$HOST:/tmp/" +fi + +ssh "$HOST" "set -e + d=\$(mktemp -d) + tar -xzf /tmp/clank-release.tar.gz -C \"\$d\" + rm -f /tmp/clank-release.tar.gz + if [ '${1:-}' = '--td' ]; then + D=/var/lib/togetherdecomp/downloads + for f in td-windows-x64.exe td-linux-x64 SHA256SUMS; do + install -o togetherdecomp -g togetherdecomp -m 0644 /tmp/\$f \$D/\$f.new && mv \$D/\$f.new \$D/\$f && rm -f /tmp/\$f + done + chmod 0755 \$D/td-linux-x64 + echo 'td binaries published' + fi + sh \"\$d/deploy/update.sh\" \"\$d\" + rm -rf \"\$d\""