diff --git a/src/server/accounts.js b/src/server/accounts.js index adbeeb2..043ca4e 100644 --- a/src/server/accounts.js +++ b/src/server/accounts.js @@ -1,6 +1,6 @@ // Accounts, passwords, browser sessions and API tokens. // -// register -> pending --admin approves--> active --admin revokes--> revoked +// register -> pending (or active with auto-approval) --admin approves--> active --admin revokes--> revoked // // Only active users can do anything. Every request looks its credential up in // the database, so revoking a user, a token or a session takes effect at once. @@ -66,6 +66,19 @@ export class Accounts { this.store = store; } + registrationSettings() { + return { autoApprove: this.store.getMeta('auto_approve_accounts') === 'true' }; + } + + setRegistrationSettings(autoApprove, adminId) { + if (typeof autoApprove !== 'boolean') throw new AccountError(400, 'autoApprove must be a boolean'); + return this.store.tx(() => { + this.store.setMeta('auto_approve_accounts', autoApprove); + this.store.event('registration_settings', adminId, { autoApprove }); + return this.registrationSettings(); + }); + } + // --- self service async register(name, password) { @@ -73,9 +86,15 @@ export class Accounts { checkPassword(password); if (this.byName(name)) throw new AccountError(409, 'username is taken'); const hash = await hashPassword(password); - this.store.run(`INSERT INTO users (name, password_hash, role, status, created_at) VALUES (?, ?, 'contributor', 'pending', ?)`, name, hash, now()); - this.store.event('register', null, { name }); - return { name, status: 'pending' }; + return this.store.tx(() => { + const { autoApprove } = this.registrationSettings(); + const status = autoApprove ? 'active' : 'pending'; + const createdAt = now(); + this.store.run(`INSERT INTO users (name, password_hash, role, status, created_at, approved_at) VALUES (?, ?, 'contributor', ?, ?, ?)`, name, hash, status, createdAt, autoApprove ? createdAt : null); + this.store.event('register', null, { name }); + if (autoApprove) this.store.event('user_approved', null, { name, automatic: true }); + return { name, status }; + }); } async login(name, password) { diff --git a/src/server/app.js b/src/server/app.js index 6da5d83..8afea5d 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -393,7 +393,7 @@ export function createApp(ctx) { // several times in a row. const per = config.reviewsPerClaim ?? 3; const since = Date.now() - 3600_000; - const claims = store.get(`SELECT COUNT(*) AS n FROM events WHERE user_id = ? AND type IN ('claim', 'claim_batch') AND at > ?`, user.id, since).n; + const claims = store.get(`SELECT COUNT(*) AS n FROM events WHERE user_id = ? AND type IN ('claim', 'claim_batch', 'region_claim') AND at > ?`, user.id, since).n; const done = store.get('SELECT COUNT(*) AS n FROM peer_reviews WHERE reviewer_id = ? AND created_at > ?', user.id, since).n; const need = Math.min(per, (claims + 1) * per - done); if (need <= 0) return; @@ -982,6 +982,15 @@ export function createApp(ctx) { }); return { authors, limits: LIMITS }; }); + router.on('GET', '/api/admin/registration', ({ req }) => { + auth(req, { role: 'admin', session: true }); + return accounts.registrationSettings(); + }); + router.on('POST', '/api/admin/registration', async ({ req }) => { + const admin = auth(req, { role: 'admin', session: true }); + const body = await readJson(req, 1024); + return accountCall(() => accounts.setRegistrationSettings(body.autoApprove, admin.id)); + }); router.on('GET', '/api/admin/users', ({ req, query }) => { auth(req, { role: 'admin', session: true }); return { roles: ROLES, users: accounts.listUsers(query.get('status') ?? undefined), work: claimedWork(), reviewers: reviewerStats(store) }; diff --git a/src/server/db.js b/src/server/db.js index c064755..5a5d3a1 100644 --- a/src/server/db.js +++ b/src/server/db.js @@ -4,9 +4,9 @@ import { DatabaseSync } from 'node:sqlite'; import crypto from 'node:crypto'; import { formatSymbolId } from '../shared/annotations.js'; -// Accounts. People register with a password and stay 'pending' until an admin -// approves them. Approved users create API tokens for their agents; the browser -// uses short-lived sessions. Both are stored as SHA-256 hashes only. +// Accounts. Registrations stay 'pending' until approved unless auto-approval is on. +// Approved users create API tokens for their agents; the browser uses short-lived +// sessions. Both are stored as SHA-256 hashes only. const USERS_TABLES = ` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, diff --git a/src/server/guidance.js b/src/server/guidance.js index 16cd30d..48199a1 100644 --- a/src/server/guidance.js +++ b/src/server/guidance.js @@ -11,7 +11,7 @@ export const DEFAULT_GUIDANCE = [ 'Before submitting: the region compiles, every function is annotated, and after a header change td_check with includers: true shows every unit that includes it still compiles.', 'Port, do not polish. Write faithful C++ for each function: the same logic, calls, conditions, loops and data accesses, with sensible names. Clear compile errors and structural differences (a missing branch, a wrong call, a wrong field), then move on even when it is not byte-identical.', 'Do not spend more than two or three checks per function chasing exact bytes: five other agents confirm the logic in peer review, which is the normal path. Byte-identical code merges right away.', - 'Peer reviews come in groups (up to 40 functions of one batch and file): read the original and the submission (it is known to compile; do not compile or diff), and send all verdicts in one td_submit_peer_review call. A function merges once "same" leads "different" by three (3:0, 4:1, 5:2); it goes back once at least three "different" lead by two (3:1, 4:2).', + 'Peer reviews come in groups (up to 40 functions of one batch and file): read the original and the submission (it is known to compile; do not compile or diff), and send all verdicts in one td_submit_peer_review call. A function merges once "same" leads "different" by three and holds 70% of the votes (3:0, 4:1, 5:2, 7:3); it goes back once at least three "different" lead by two (3:1, 4:2), or after seven reviewers without a decision.', 'Quality counts: an account whose functions peers keep sending back, or whose submissions keep breaking, is limited and then paused for a while automatically (the quality field of td_get_batch / /api/me says why). Fewer, checked submissions beat many careless ones.', 'Trust pays: an agent whose close matches keep passing peer review gets functions scoring 95% or more accepted without review, and a reviewer whose verdicts keep agreeing with the others (including on the differences they find) counts twice or three times. Each agent (token) earns it on its own; td_quality shows where it stands.', 'If your code (or someone else\'s) breaks a neighbouring function so a region does not compile, claim that function too (td_claim_region covers it when it is in your region) and fix it in the same submission.', diff --git a/src/server/merge.js b/src/server/merge.js index d1ed023..1d40bd9 100644 --- a/src/server/merge.js +++ b/src/server/merge.js @@ -77,7 +77,7 @@ export class Merger { const skipsPeers = attempt.state === 'peer_review' && skipPeerReview; if (skipsPeers && !hasRole(reviewer, 'maintainer')) throw new HttpError(403, 'only maintainers can decide before peer review is done'); if (attempt.state !== 'in_review' && !skipsPeers) throw new HttpError(409, `attempt is ${attempt.state}`); - if (attempt.user_id === reviewer.id) throw new HttpError(403, 'you cannot review your own attempt'); + if (attempt.user_id === reviewer.id && !hasRole(reviewer, 'admin')) throw new HttpError(403, 'you cannot review your own attempt'); if (this.store.get('SELECT 1 FROM reviews WHERE attempt_id = ? AND reviewer_id = ?', attemptId, reviewer.id)) throw new HttpError(409, 'you already reviewed this attempt'); this.store.run('INSERT INTO reviews (attempt_id, reviewer_id, action, comment, created_at) VALUES (?, ?, ?, ?, ?)', attemptId, reviewer.id, action, comment?.slice(0, 4000) ?? null, now()); @@ -127,9 +127,29 @@ export class Merger { this.log.info(`${done.length} function(s) accepted as needing no decompilation`); } + /** + * A batch that only merges as a whole (noSplit) can't once one of its functions has dropped out + * after that was decided (sent back, withdrawn, superseded): it all goes back to its author + * instead of holding its approved functions forever. + */ + returnUnmergeable() { + const stuck = this.store.all( + `SELECT b.id, json_extract(b.result_json, '$.noSplit') AS reason FROM batches b WHERE b.state = 'open' AND json_extract(COALESCE(b.result_json, '{}'), '$.noSplit') IS NOT NULL + AND EXISTS (SELECT 1 FROM attempts a WHERE a.batch_id = b.id AND a.state IN ('needs_cleanup', 'rejected', 'returned', 'withdrawn', 'superseded'))`, + ).filter((b) => !this.active.has(b.id)); + if (!stuck.length) return; + this.store.tx(() => { + for (const b of stuck) returnBatch(this.store, b.id, `a function of this batch dropped out and the rest cannot be merged without it: ${b.reason}`); + }); + this.log.info(`${stuck.length} batch(es) returned: a function dropped out and the rest cannot merge without it`); + } + /** Starts merges while a lane is free; resolves when the merges it started are done. */ async pump() { - if (!this.stopped) this.finishSkips(); + if (!this.stopped) { + this.finishSkips(); + this.returnUnmergeable(); + } const started = []; while (!this.stopped && this.running < MERGE_SLOTS) { const pick = this.next(this.runningBig > 0); @@ -257,7 +277,7 @@ export class Merger { } 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', 'withdrawn')`, batch.id)) { + if (this.store.get(`SELECT 1 FROM attempts WHERE batch_id = ? AND state IN ('needs_cleanup', 'rejected', 'returned', 'withdrawn', 'superseded')`, 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}`); } @@ -353,7 +373,7 @@ export class Merger { (peers.length ? `\nPeer-reviewed-by: ${peers.join(', ')}` : '') + (reviewers.length ? `\nReviewed-by: ${reviewers.join(', ')}` : '') + (!reviewers.length && !confirmedIds.size ? '\nMerged automatically: every function is byte-identical to the original.' : '') + - (!reviewers.length && confirmedIds.size ? `\nAccepted without human review: peers confirmed the logic ("same" ahead by at least ${this.peers?.margin ?? 3}).` : '') + + (!reviewers.length && confirmedIds.size ? `\nAccepted without human review: peers confirmed the logic ("same" ahead by at least ${this.peers?.margin ?? 3}, with ${Math.round((this.peers?.acceptShare ?? 0.7) * 100)}% of the votes).` : '') + '\n'; // New source files join the build list in the same commit (with the flags of their folder). const listFile = this.project.compileCommands ?? null; diff --git a/src/server/peer.js b/src/server/peer.js index 236c7de..cee0928 100644 --- a/src/server/peer.js +++ b/src/server/peer.js @@ -14,6 +14,9 @@ // (Attempts from before this rule may wait in in_review; they are accepted the same way.) // Verdicts are weighted: a reviewer whose verdicts keep agreeing with the others' counts twice or // three times (trust.js). Either decision still takes at least two reviewers. +// Accepting also takes a clear majority: "same" at least `peerAcceptShare` (default 0.7) of the +// weight, so a contested function is not worn down until "same" happens to lead (10:7). One that +// `peerMaxReviewers` (default 7) reviewed without a decision goes back to its author. import { HttpError } from './http.js'; import { now } from './db.js'; @@ -28,7 +31,7 @@ const REVIEW_GROUP = 40; export const PEER_INSTRUCTIONS = [ 'You hold every function listed under functions. Review them all, then send all verdicts in one call (td_submit_peer_review with a verdicts list).', 'A function with a claim field is not ported code but a claim that it needs no decompilation (and why). Read the original: "same" if the claim is right, "different" if it is ordinary code that should be ported.', - 'Decide whether the submitted C++ does the same thing as the original function. It compiles (the server proved that when it was submitted) but is not byte-identical; your judgment is what counts. A function merges once "same" leads "different" by three (3:0, 4:1, 5:2); it goes back once at least three "different" lead by two (3:1, 4:2); verdicts of reviewers who keep agreeing with the others count twice or three times, so careful reviews pay off.', + 'Decide whether the submitted C++ does the same thing as the original function. It compiles (the server proved that when it was submitted) but is not byte-identical; your judgment is what counts. A function merges once "same" leads "different" by three and holds 70% of the votes (3:0, 4:1, 5:2, 7:3); it goes back once at least three "different" lead by two (3:1, 4:2), or when seven reviewers could not decide it; verdicts of reviewers who keep agreeing with the others count twice or three times, so careful reviews pay off.', 'Read only: the original assembly (td_disassemble), the original pseudocode (td_decompile) and the submitted code below. Do not compile, run td_check, diff bytes or use other automated comparison.', 'Check: every branch and condition, loop bounds, arithmetic and signedness, the functions called and their arguments, fields and globals read and written, return values, and error paths.', 'Answer "same" only if a caller could not tell the two apart. Otherwise answer "different" and name the concrete difference (where, and what the original does instead).', @@ -56,6 +59,16 @@ export class PeerReviews { return this.project.matchPolicy.peerRejectMargin ?? 2; } + // The share of the weight "same" needs as well as its lead: 7:3, not 10:7. + get acceptShare() { + return this.project.matchPolicy.peerAcceptShare ?? 0.7; + } + + // Reviewers after which an undecided function goes back to its author. + get maxReviewers() { + return this.project.matchPolicy.peerMaxReviewers ?? 7; + } + // The fewest "same" verdicts that accept a function (nobody objected). get acceptClean() { return this.margin; @@ -63,7 +76,8 @@ export class PeerReviews { /** "same" verdicts (by weight) this attempt needs, given the "different" ones. */ needed(different) { - return different + this.margin; + const share = this.acceptShare; + return Math.max(different + this.margin, share < 1 ? Math.ceil((share * different) / (1 - share) - 1e-9) : Infinity); } get leaseMs() { @@ -80,25 +94,24 @@ export class PeerReviews { // Room for this reviewer is part of the query, so the limit counts functions they can take // (filtering after the limit returned an empty list while the first rows were fully leased). const rows = this.store.all( - `SELECT * FROM ( - SELECT a.id AS attempt_id, a.batch_id, a.task_id, a.state, t.symbol_id, s.name, s.unit, a.finished_at, - (SELECT COALESCE(SUM(p.weight), 0) FROM peer_reviews p WHERE p.attempt_id = a.id AND p.verdict = 'same') AS confirmations, - (SELECT COALESCE(SUM(p.weight), 0) 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 IN ('peer_review', 'in_review') AND a.user_id != ? - AND NOT EXISTS (SELECT 1 FROM users b WHERE b.id = ? AND b.peer_review_blocked = 1) - AND NOT EXISTS (SELECT 1 FROM peer_reviews p WHERE p.attempt_id = a.id AND p.reviewer_id = ?) - ) - WHERE NOT (objections >= ? AND objections - confirmations >= ?) AND confirmations + in_progress < objections + ?`, + `SELECT a.id AS attempt_id, a.batch_id, a.task_id, a.state, t.symbol_id, s.name, s.unit, a.finished_at, + (SELECT COALESCE(SUM(p.weight), 0) FROM peer_reviews p WHERE p.attempt_id = a.id AND p.verdict = 'same') AS confirmations, + (SELECT COALESCE(SUM(p.weight), 0) FROM peer_reviews p WHERE p.attempt_id = a.id AND p.verdict = 'different') AS objections, + (SELECT COUNT(*) FROM peer_reviews p WHERE p.attempt_id = a.id AND p.verdict = 'same') AS same_reviewers, + (SELECT COUNT(*) FROM peer_reviews p WHERE p.attempt_id = a.id AND p.verdict = 'different') AS different_reviewers, + (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 IN ('peer_review', 'in_review') AND a.user_id != ? + AND NOT EXISTS (SELECT 1 FROM users b WHERE b.id = ? AND b.peer_review_blocked = 1) + AND NOT EXISTS (SELECT 1 FROM peer_reviews p WHERE p.attempt_id = a.id AND p.reviewer_id = ?)`, now(), user.id, user.id, user.id, - this.required, - this.rejectMargin, - this.margin, - ); + ).filter((r) => { + r.reviewers = r.same_reviewers + r.different_reviewers; + return !this.full({ same: r.confirmations, different: r.objections, sameReviewers: r.same_reviewers, differentReviewers: r.different_reviewers }, r.in_progress); + }); // Random by batch, so a batch stays together. const key = new Map(); for (const r of rows) { @@ -215,8 +228,7 @@ export class PeerReviews { const leased = this.store.get('SELECT 1 FROM peer_leases WHERE attempt_id = ? AND reviewer_id = ? AND expires_at > ?', attemptId, user.id, now()); 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; - const full = counts.same + others >= this.needed(counts.different); - if (!leased && full) throw new HttpError(409, 'enough reviewers are already on this attempt; claim another'); + if (!leased && this.full(counts, others)) throw new HttpError(409, 'enough reviewers are already on this attempt; claim another'); const weight = this.trust?.reviewer(user.id, user.tokenId ?? null).weight ?? 1; this.store.run( @@ -243,18 +255,25 @@ export class PeerReviews { this.store.event('auto_accepted', null, { attempt: a.id, same, different }); return { state: 'approved', same, different, required: this.needed(different) }; } - if (this.rejects(c)) { - // Enough independent reviewers found a difference: back to the author with their reasons. + const reviewers = c.sameReviewers + c.differentReviewers; + const undecided = !this.rejects(c) && reviewers >= this.maxReviewers; + if (this.rejects(c) || undecided) { + // Enough independent reviewers found a difference, or so many could not agree that more + // would not settle it: back to the author with their reasons. + const why = undecided + ? `${reviewers} peer reviewers could not agree (${same} "same" to ${different} "different" by weight); look at the "different" reasons, change the code and resubmit` + : `${different} peer reviews found a difference`; this.store.run('DELETE FROM peer_leases WHERE attempt_id = ?', a.id); - this.store.run(`UPDATE attempts SET state = 'needs_cleanup' WHERE id = ?`, a.id); + this.store.run(`UPDATE attempts SET state = 'needs_cleanup', result_json = json_set(COALESCE(result_json, '{}'), '$.peerOutcome', ?) WHERE id = ?`, why, a.id); + if (undecided) this.store.event('peer_undecided', null, { attempt: a.id, same, different, reviewers }); this.store.run( `UPDATE tasks SET state = 'needs_cleanup', cleanup_cycles = cleanup_cycles + 1, preferred_user = ?, preferred_until = ?, updated_at = ? WHERE id = ? ${notOthers} AND NOT EXISTS (SELECT 1 FROM attempts o WHERE o.task_id = tasks.id AND o.id != ? AND o.state IN ('queued', 'verifying', 'peer_review', 'in_review', 'approved', 'merging'))`, a.user_id, now() + 6 * 3600_000, now(), a.task_id, a.id, ); // 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 }; + settleBatch(this.store, a.batch_id, `${why} (${a.task_id})`); + return { state: 'needs_cleanup', same, different, required: this.required, ...(undecided && { undecided: true }) }; } // Undecided: it stays in review until one side leads by enough. return { state: a.state, same, different, required: this.needed(different) }; @@ -322,17 +341,26 @@ export class PeerReviews { return { same: r?.same ?? 0, different: r?.different ?? 0, sameReviewers: r?.sameReviewers ?? 0, differentReviewers: r?.differentReviewers ?? 0 }; } - // The side that leads decides: "same" ahead by `margin` (3:0, 4:1, 5:2) accepts; at least - // `required` "different" ahead by `rejectMargin` (3:1, 4:2) send it back. Close votes wait for - // more verdicts. However much a verdict weighs, one reviewer never decides. + // The side that leads decides: "same" ahead by `margin` with `acceptShare` of the weight (3:0, + // 4:1, 5:2, 7:3) accepts; at least `required` "different" ahead by `rejectMargin` (3:1, 4:2) send + // it back. Close votes wait for more verdicts, up to `maxReviewers` (decide). However much a + // verdict weighs, one reviewer never decides. accepts(c) { - return c.same - c.different >= this.margin && c.sameReviewers >= 2; + return c.same - c.different >= this.margin && c.same >= this.acceptShare * (c.same + c.different) && c.sameReviewers >= 2; } rejects(c) { return c.different >= this.required && c.different - c.same >= this.rejectMargin && c.differentReviewers >= 2; } + // No room for another reviewer: decided, or decided once the `inProgress` leased reviews come + // back "same", or at `maxReviewers`. The same rules as decide(), reviewer counts included, so a + // function one heavy verdict leads is still offered until a second reviewer settles it. + full(c, inProgress = 0) { + const hoped = { ...c, same: c.same + inProgress, sameReviewers: c.sameReviewers + inProgress }; + return this.rejects(c) || this.accepts(hoped) || c.sameReviewers + c.differentReviewers + inProgress >= this.maxReviewers; + } + /** Passed peer review, or a trusted author's function that did not need it (trust.js). */ confirmed(attemptId) { if (this.store.get('SELECT 1 FROM attempts WHERE id = ? AND peer_skipped = 1', attemptId)) return true; diff --git a/src/server/trust.js b/src/server/trust.js index a4e689d..312b1b4 100644 --- a/src/server/trust.js +++ b/src/server/trust.js @@ -53,12 +53,16 @@ export class Trust { return { ...TRUST_DEFAULTS, ...p, weights: p.weights ?? TRUST_DEFAULTS.weights }; } - // The peer review rule (peer.js): "same" ahead by margin accepts, "different" ahead by - // rejectMargin (at least required of them) sends back. + // The peer review rule (peer.js): "same" ahead by margin with acceptShare of the weight accepts, + // "different" ahead by rejectMargin (at least required of them) sends back. get margin() { return this.project.matchPolicy.peerMargin ?? 3; } + get acceptShare() { + return this.project.matchPolicy.peerAcceptShare ?? 0.7; + } + get rejectMargin() { return this.project.matchPolicy.peerRejectMargin ?? 2; } @@ -92,11 +96,11 @@ export class Trust { const s = this.scope('a', tokenId); const w = (verdict) => `(SELECT COALESCE(SUM(r.weight), 0) FROM peer_reviews r WHERE r.attempt_id = a.id AND r.verdict = '${verdict}')`; const r = this.store.get( - `SELECT SUM(same - different >= ?) AS accepted, SUM(different >= ? AND different - same >= ?) AS rejected FROM ( + `SELECT SUM(same - different >= ? AND same >= ? * (same + different)) AS accepted, SUM(different >= ? AND different - same >= ?) AS rejected FROM ( SELECT ${w('same')} AS same, ${w('different')} AS different FROM attempts a WHERE a.user_id = ? AND ${s.sql} AND a.created_at > ? AND a.kind = 'code' AND a.peer_skipped = 0 AND a.score >= ?)`, - this.margin, this.required, this.rejectMargin, userId, ...s.args, now() - p.authorDays * DAY, p.bandAt, + this.margin, this.acceptShare, this.required, this.rejectMargin, userId, ...s.args, now() - p.authorDays * DAY, p.bandAt, ); const accepted = r?.accepted ?? 0; const rejected = r?.rejected ?? 0; diff --git a/src/server/verify.js b/src/server/verify.js index 065e358..fa2dd78 100644 --- a/src/server/verify.js +++ b/src/server/verify.js @@ -7,7 +7,7 @@ import { lintSubmission } from '../shared/lint.js'; import { freeTextErrors } from '../shared/freetext.js'; import { checkUnits } from '../shared/check.js'; import { reconcile } from '../shared/reconcile.js'; -import { STATUS } from '../shared/compare.js'; +import { STATUS, sameOriginal } from '../shared/compare.js'; import { listSources, isUnit } from '../shared/project.js'; import { changedNames, unitsMentioning } from './affected.js'; import { undefinedDeclarations } from './undefined.js'; @@ -444,6 +444,29 @@ export class Verifier { ); } + /** + * Another function annotated on the same compiled body as `target`, accepted on main (byte- + * identical or confirmed by peers) with this very compiled code, whose original is the same code + * as the target's (sameOriginal). Its ID, or null. + */ + acceptedTwin(check, target) { + if (!target?.symbol || !target.addr || !target.codeHash) return null; + const size = target.size?.original; + const pe = this.getPe(); + // The target's file is its unit (evaluate); a decorated name stands for one body in a unit. + for (const u of check.units.filter((x) => x.file === target.file)) { + for (const f of u.functions) { + if (f.id === target.id || f.symbol !== target.symbol || !f.addr || f.codeHash !== target.codeHash) continue; + if (f.size?.original !== size) continue; + const main = this.store.get('SELECT status, confirmed, code_hash FROM func_status WHERE symbol_id = ?', f.id); + if (!main || main.code_hash !== f.codeHash) continue; + if (!(main.status === STATUS.EXACT || main.status === STATUS.UNRESOLVED || main.confirmed)) continue; + if (sameOriginal(pe, target.addr, f.addr, size)) return f.id; + } + } + return null; + } + /** * A function that three peers already confirmed keeps that confirmation when its compiled code is * unchanged in a resubmission (the batch came back because of another function). @@ -539,8 +562,10 @@ export class Verifier { ev.feedback.warnings.push('your local toolchain differs from the pinned one; local results may not match the server'); } if (ev.state === 'peer_review' && this.carryOver(a, ev.feedback.target)) { - ev.state = 'in_review'; - ev.feedback.summary += '; the compiled code is unchanged since three peers confirmed it, so their verdicts carry over'; + // Their verdicts already accept it, so it goes on as approved (in_review would wait for a + // human review, and the peer queue skips functions its rule has decided). + ev.state = 'approved'; + ev.feedback.summary += '; the compiled code is unchanged since peers confirmed it, so their verdicts carry over and it is accepted'; } // A trusted author's function that is close to the original: accepted without peer review // (but for a random sample that keeps the author's record measured; trust.js). @@ -550,6 +575,15 @@ export class Verifier { ev.feedback.trusted = true; this.store.run('UPDATE attempts SET peer_skipped = 1 WHERE id = ?', a.id); } + // One body annotated for several functions: when the original of this one is the same code + // as another's that main already accepts from that body, the verdict on it holds for this one. + const twin = ev.state === 'peer_review' && a.kind !== 'skip' ? this.acceptedTwin(check, ev.feedback.target) : null; + if (twin) { + ev.state = 'approved'; + ev.feedback.summary = `not byte-identical (score ${(ev.score * 100).toFixed(1)}%), accepted without peer review: the original is the same code as ${twin}, which main already accepts from this body`; + ev.feedback.sameAs = twin; + this.store.run('UPDATE attempts SET peer_skipped = 1 WHERE id = ?', a.id); + } if (ev.state === 'needs_cleanup' || ev.state === 'rejected') failed++; this.finish(a, ev.state, ev.score, ev.feedback); } @@ -659,7 +693,7 @@ export class Verifier { state = auto ? 'approved' : 'in_review'; } else if (target.status === STATUS.MISMATCH || target.status === STATUS.UNRESOLVED) { // Not byte-identical, but it compiles cleanly: other agents judge whether the logic is the same. - feedback.summary = `not byte-identical (score ${(score * 100).toFixed(1)}%), sent to peer review: other agents compare it with the original (it merges once "same" leads "different" by ${policy.peerMargin ?? 3}; at least ${policy.peerReviews ?? 3} "different" leading by ${policy.peerRejectMargin ?? 2} send it back)`; + feedback.summary = `not byte-identical (score ${(score * 100).toFixed(1)}%), sent to peer review: other agents compare it with the original (it merges once "same" leads "different" by ${policy.peerMargin ?? 3} with ${Math.round((policy.peerAcceptShare ?? 0.7) * 100)}% of the votes; at least ${policy.peerReviews ?? 3} "different" leading by ${policy.peerRejectMargin ?? 2}, or ${policy.peerMaxReviewers ?? 7} reviewers without a decision, send it back)`; state = 'peer_review'; } else { feedback.summary = `the annotated function was not compiled or has no address in the current version (${target.status})`; diff --git a/src/server/web/app.js b/src/server/web/app.js index f417584..d1da6c6 100644 --- a/src/server/web/app.js +++ b/src/server/web/app.js @@ -226,9 +226,11 @@ $('login-form').addEventListener('submit', async (e) => { $('register-form').addEventListener('submit', async (e) => { e.preventDefault(); try { - await post('/api/register', formData(e.target)); + const registration = await post('/api/register', formData(e.target)); e.target.reset(); - $('register-msg').textContent = 'Registered. You can log in once an admin approves your account.'; + $('register-msg').textContent = registration.status === 'active' + ? 'Registered. Your account is approved; you can log in now.' + : 'Registered. You can log in once an admin approves your account.'; } catch (err) { $('register-msg').textContent = err.message; } @@ -859,11 +861,32 @@ async function adminAction(name, action, body) { return null; } } +$('auto-approve').addEventListener('change', async (e) => { + const toggle = e.target; + const previous = !toggle.checked; + toggle.disabled = true; + $('registration-msg').textContent = ''; + try { + const settings = await post('/api/admin/registration', { autoApprove: toggle.checked }); + toggle.checked = settings.autoApprove; + $('registration-msg').textContent = settings.autoApprove + ? 'Auto approval is on. New accounts can log in immediately.' + : 'Auto approval is off. New accounts need manual approval.'; + } catch (err) { + toggle.checked = previous; + $('registration-msg').textContent = err.message; + } finally { + toggle.disabled = false; + } +}); + let adminData = null; async function loadAdmin() { - const [users, authors] = await Promise.all([api('/api/admin/users'), api('/api/admin/authors').catch(() => ({ authors: [] }))]); + const [users, authors, registration] = await Promise.all([api('/api/admin/users'), api('/api/admin/authors').catch(() => ({ authors: [] })), api('/api/admin/registration')]); adminData = users; + $('auto-approve').checked = registration.autoApprove; + $('auto-approve').disabled = false; renderAdmin(); renderAuthors(authors.authors); } diff --git a/src/server/web/index.html b/src/server/web/index.html index 5445011..373431b 100644 --- a/src/server/web/index.html +++ b/src/server/web/index.html @@ -6,7 +6,7 @@
An admin checks every new account. Log in to see your status: once the Account page lets you create tokens, you're approved.
When auto approval is on, you can log in immediately after registering. Otherwise, wait for an admin to approve your account. Once the Account page lets you create tokens, you're approved.
On your Account page, give the token a label (for example "laptop") and press Create token. The page then shows your personal install line with the token already filled in. Copy it; it's shown only once.
When on, new accounts can log in immediately. Existing pending accounts still need manual approval.
+ @@ -383,6 +389,6 @@Clank Together · source is open: git clone https://clank.rodecomp.online/git/clank/game-decomp.git