Add maintenance batches and symbol corrections for maintainers

A maintenance batch carries structural source changes that belong to no function task (moving a global to its owner, fixing data annotations, adding a header or unit). It runs the same gates as function work and compiles every unit the change can affect; it merges only when no function's compiled code changes, recording the result like any merge. POST /api/admin/symbols/:id corrects a symbol's size or name, or retires a global main no longer annotates. td maintenance submit|symbol drive both.
This commit is contained in:
ragnarok-clanker 2026-10-26 18:46:45 +01:00
commit 689a8bc85b
8 changed files with 310 additions and 3 deletions

View file

@ -47,6 +47,11 @@ Work (a region: a source file or class, done as a whole)
td symbol new <kind> <0xaddr> [--name N] get a stable ID for a new global/vtable/string/function td symbol new <kind> <0xaddr> [--name N] get a stable ID for a new global/vtable/string/function
td decompile <symbol> | td disasm <symbol> query the local Ghidra project td decompile <symbol> | td disasm <symbol> query the local Ghidra project
Maintenance (maintainers: structural changes with no function tasks)
td maintenance submit --files a.cpp,b.h --notes T verified like a batch; merges only when no
function's compiled code changes
td maintenance symbol <id> [--size N] [--name N] [--retire] --reason T correct the symbol database
Peer review (attempts that are not byte-identical; judge by reading, not by tools) Peer review (attempts that are not byte-identical; judge by reading, not by tools)
td peer list attempts waiting for review td peer list attempts waiting for review
td peer claim [attemptId] claim one, prints instructions, original and submission td peer claim [attemptId] claim one, prints instructions, original and submission
@ -114,6 +119,8 @@ async function main() {
range: { type: 'string' }, range: { type: 'string' },
files: { type: 'string' }, files: { type: 'string' },
reason: { type: 'string' }, reason: { type: 'string' },
size: { type: 'string' },
retire: { type: 'boolean', default: false },
json: { type: 'boolean', default: false }, json: { type: 'boolean', default: false },
free: { type: 'boolean', default: false }, free: { type: 'boolean', default: false },
includers: { type: 'boolean', default: false }, includers: { type: 'boolean', default: false },
@ -203,6 +210,22 @@ connect your AI with: td connect --workspace "${ws.dir}"`);
const files = values.files ? values.files.split(',').map((f) => ops.relPath(ws, f)) : []; const files = values.files ? values.files.split(',').map((f) => ops.relPath(ws, f)) : [];
return print(await ops.submit(ws, ids, { files, notes: values.notes, agentMeta: { client: 'td-cli' } })); return print(await ops.submit(ws, ids, { files, notes: values.notes, agentMeta: { client: 'td-cli' } }));
} }
case 'maintenance': {
const [sub, id] = args;
if (sub === 'submit') {
if (!values.files) throw new Error('usage: td maintenance submit --files a.cpp,b.h --notes "what and why"');
const files = values.files.split(',').map((f) => ops.relPath(ws, f));
return print(await ops.submitMaintenance(ws, files, { notes: values.notes }));
}
if (sub === 'symbol') {
if (!id || !values.reason) throw new Error('usage: td maintenance symbol <id> [--size N] [--name N] [--retire] --reason "evidence"');
const body = { reason: values.reason, ...(values.size && { size: Number(values.size) }), ...(values.name && { name: values.name }), ...(values.retire && { retire: true }) };
const r = await client.post(`/api/admin/symbols/${id}`, body);
await ops.refreshSymbols(ws);
return print(r);
}
throw new Error('usage: td maintenance submit|symbol ...');
}
case 'result': { case 'result': {
const r = await client.get(`/api/attempts/${args[0]}`); const r = await client.get(`/api/attempts/${args[0]}`);
delete r.files; delete r.files;

View file

@ -302,6 +302,23 @@ export async function submit(ws, taskIds = [], { files, notes, agentMeta, confir
return api(ws).post('/api/batches', { taskIds: ids, baseCommit: ws.head(), files: contents, notes, agentMeta, envDigest: ws.config.envDigest, ...(confirm.length ? { confirm } : {}) }); return api(ws).post('/api/batches', { taskIds: ids, baseCommit: ws.head(), files: contents, notes, agentMeta, envDigest: ws.config.envDigest, ...(confirm.length ? { confirm } : {}) });
} }
/**
* A maintenance batch (maintainers): the named files, with no function tasks. The server checks it
* like any batch and merges it only when no function's compiled code changes.
*/
export async function submitMaintenance(ws, files, { notes } = {}) {
if (!files.length) throw new Error('name the files of the change (--files a.cpp,b.h)');
const contents = {};
for (const f of files) contents[f.replace(/\\/g, '/')] = fs.readFileSync(path.join(ws.dir, f), 'utf8');
const errors = lintSubmission(contents, { allowedPaths: [`${ws.project.sourceDirs[0]}/**`, ...ws.project.headerGlobs], limits: ws.project.lintLimits, allowedPragmas: ws.project.allowedPragmas, baseSize: (f) => ws.size(f) });
if (errors.length) {
const e = new Error('submission fails the static gate:\n' + errors.map((x) => ` ${x.file ?? ''}${x.line ? ':' + x.line : ''} ${x.message}`).join('\n'));
e.details = errors;
throw e;
}
return api(ws).post('/api/maintenance', { baseCommit: ws.head(), files: contents, notes });
}
/** Annotated functions whose definition differs from HEAD in the changed (or named) files under allowed paths. */ /** Annotated functions whose definition differs from HEAD in the changed (or named) files under allowed paths. */
function rewrittenFunctions(ws, allowed, files) { function rewrittenFunctions(ws, allowed, files) {
const out = new Set(); const out = new Set();

View file

@ -11,6 +11,7 @@ import { Repo, serveGit } from './git.js';
import { Tasks } from './tasks.js'; import { Tasks } from './tasks.js';
import { Verifier } from './verify.js'; import { Verifier } from './verify.js';
import { Merger } from './merge.js'; import { Merger } from './merge.js';
import { Maintenance } from './maintenance.js';
import { PeerReviews } from './peer.js'; import { PeerReviews } from './peer.js';
import { Originals } from './original.js'; import { Originals } from './original.js';
import { downloadsDir, installPs1, installSh, publishedBuilds } from './install.js'; import { downloadsDir, installPs1, installSh, publishedBuilds } from './install.js';
@ -98,9 +99,11 @@ export function openServer(dataDir, { log = console } = {}) {
const originals = new Originals({ store }); const originals = new Originals({ store });
const ghidra = new ServerGhidra({ config, store, originals, log }); const ghidra = new ServerGhidra({ config, store, originals, log });
merger.onMerged = (commit) => ghidra.scheduleRefresh(commit); merger.onMerged = (commit) => ghidra.scheduleRefresh(commit);
const maintenance = new Maintenance({ store, repo, project, verifier, merger, log });
verifier.maintenance = maintenance;
const checks = new CheckQueue({ store, repo, verifier, config, log }); const checks = new CheckQueue({ store, repo, verifier, config, log });
const credits = new Credits({ store }); const credits = new Credits({ store });
return { config, store, repo, project, tasks, regions, quality, trust, credits, verifier, merger, getPe, accounts, peers, originals, ghidra, checks }; return { config, store, repo, project, tasks, regions, quality, trust, credits, verifier, merger, maintenance, getPe, accounts, peers, originals, ghidra, checks };
} }
export function createApp(ctx) { export function createApp(ctx) {
@ -529,6 +532,18 @@ export function createApp(ctx) {
return guided({ ...r, state: 'queued' }); return guided({ ...r, state: 'queued' });
}); });
// Structural source changes with no function tasks (maintenance.js): maintainers only. Verified
// like any batch; merged only when no function's compiled code changes.
router.on('POST', '/api/maintenance', async ({ req }) => {
const user = auth(req, { role: 'maintainer' });
const body = await readJson(req, 8 * 1024 * 1024);
if (typeof body.baseCommit !== 'string' || !/^[0-9a-f]{40}$/.test(body.baseCommit)) throw new HttpError(400, 'baseCommit must be a full commit hash');
if (!body.files || typeof body.files !== 'object' || Array.isArray(body.files)) throw new HttpError(400, 'files must be an object of path -> content');
const r = ctx.maintenance.submit(user, { baseCommit: body.baseCommit, files: lf(body.files), notes: body.notes ? String(body.notes).slice(0, DEFAULT_LIMITS.maxNotesBytes) : null });
if (runsWorker) ctx.verifier.pump();
return guided(r);
});
router.on('GET', '/api/batches/:id', ({ req, params }) => { router.on('GET', '/api/batches/:id', ({ req, params }) => {
auth(req); auth(req);
const b = store.get('SELECT b.*, u.name AS author FROM batches b JOIN users u ON u.id = b.user_id WHERE b.id = ?', intParam(params.id)); const b = store.get('SELECT b.*, u.name AS author FROM batches b JOIN users u ON u.id = b.user_id WHERE b.id = ?', intParam(params.id));
@ -540,8 +555,10 @@ export function createApp(ctx) {
); );
return guided({ return guided({
id: b.id, id: b.id,
kind: b.kind,
author: b.author, author: b.author,
state: b.state, state: b.state,
...(b.kind === 'maintenance' && { result: result }),
baseCommit: b.base_commit, baseCommit: b.base_commit,
mergedCommit: b.merged_commit, mergedCommit: b.merged_commit,
returned: result.returned ?? null, returned: result.returned ?? null,
@ -777,6 +794,48 @@ export function createApp(ctx) {
return { id: r.symbol.id, kind: r.symbol.kind, created: r.created, annotation: `// ${{ function: 'FUNCTION', global: 'GLOBAL', vtable: 'VTABLE', string: 'STRING' }[kind]}: ${r.symbol.id}` }; return { id: r.symbol.id, kind: r.symbol.kind, created: r.created, annotation: `// ${{ function: 'FUNCTION', global: 'GLOBAL', vtable: 'VTABLE', string: 'STRING' }[kind]}: ${r.symbol.id}` };
}); });
// Corrections to the symbol database a maintainer makes from evidence in the original: a size,
// a name, or a global that is not one (an address inside another object, registered by mistake).
// Retiring removes its address in the current version, so nothing resolves to it any more; its
// ID stays reserved. Only symbols no file on main annotates may be retired: a maintenance batch
// removes those annotations first.
router.on('POST', '/api/admin/symbols/:id', async ({ req, params }) => {
const user = auth(req, { role: 'maintainer' });
const body = await readJson(req, 4096);
const sym = store.get('SELECT * FROM symbols WHERE id = ?', String(params.id));
if (!sym) throw new HttpError(404, 'no such symbol');
const v = store.currentVersion();
const reason = String(body.reason ?? '').trim().slice(0, 500);
if (!reason) throw new HttpError(400, 'reason is required: the evidence for the correction');
const changes = {};
store.tx(() => {
if (body.size !== undefined) {
const size = Number(body.size);
if (!Number.isSafeInteger(size) || size <= 0) throw new HttpError(400, 'size must be a positive integer');
store.run('UPDATE symbol_addresses SET size = ? WHERE symbol_id = ? AND version_id = ?', size, sym.id, v.id);
changes.size = size;
}
if (body.name !== undefined) {
const name = body.name === null ? null : String(body.name).slice(0, 200);
store.run('UPDATE symbols SET name = ? WHERE id = ?', name, sym.id);
changes.name = name;
}
if (body.retire === true) {
if (sym.kind !== 'global') throw new HttpError(400, 'only globals are retired this way');
let annotated = '';
try {
annotated = repo.git(['grep', '-l', '-w', '-e', `GLOBAL: ${sym.id}`, 'HEAD', '--', '.']).trim();
} catch {}
if (annotated) throw new HttpError(409, `${sym.id} is still annotated on main (${annotated.split('\n').map((l) => l.replace(/^HEAD:/, '')).join(', ')}); remove those annotations with a maintenance batch first`);
store.run('DELETE FROM symbol_addresses WHERE symbol_id = ? AND version_id = ?', sym.id, v.id);
changes.retired = true;
}
if (!Object.keys(changes).length) throw new HttpError(400, 'nothing to change: give size, name or retire: true');
store.event('symbol_corrected', user.id, { id: sym.id, ...changes, reason });
});
return { id: sym.id, ...changes };
});
// --- the original function (assembly, pseudocode) for contributors without a local Ghidra. // --- the original function (assembly, pseudocode) for contributors without a local Ghidra.
// Derived from the exe, so approved accounts only, like the exe itself. // Derived from the exe, so approved accounts only, like the exe itself.
router.on('GET', '/api/original/:symbol', async ({ req, params }) => { router.on('GET', '/api/original/:symbol', async ({ req, params }) => {

View file

@ -321,6 +321,8 @@ export class Store {
this.addColumn('attempts', 'batch_id', 'INTEGER REFERENCES batches(id)'); this.addColumn('attempts', 'batch_id', 'INTEGER REFERENCES batches(id)');
// A candidate for a function another agent holds (region claims): it does not move the task. // A candidate for a function another agent holds (region claims): it does not move the task.
this.addColumn('attempts', 'competing', 'INTEGER NOT NULL DEFAULT 0'); this.addColumn('attempts', 'competing', 'INTEGER NOT NULL DEFAULT 0');
// A maintenance batch (maintenance.js): structural changes with no function tasks.
this.addColumn('batches', 'kind', "TEXT NOT NULL DEFAULT 'code'");
this.db.exec('CREATE INDEX IF NOT EXISTS events_type_at ON events(type, at)'); // merge rates, feed this.db.exec('CREATE INDEX IF NOT EXISTS events_type_at ON events(type, at)'); // merge rates, feed
this.db.exec('CREATE INDEX IF NOT EXISTS attempts_batch ON attempts(batch_id)'); this.db.exec('CREATE INDEX IF NOT EXISTS attempts_batch ON attempts(batch_id)');
this.db.exec('CREATE INDEX IF NOT EXISTS peer_reviews_reviewer ON peer_reviews(reviewer_id, created_at)'); // reviewer trust this.db.exec('CREATE INDEX IF NOT EXISTS peer_reviews_reviewer ON peer_reviews(reviewer_id, created_at)'); // reviewer trust

127
src/server/maintenance.js Normal file
View file

@ -0,0 +1,127 @@
// Maintenance batches: structural changes to the source that belong to no function task (moving
// a global to its owner, fixing annotations or provenance on data, adding a header). Only
// maintainers submit them. They go through the same gates as function work (static gate, free
// text, dropped annotations, undefined declarations, a compile of every unit the change can
// affect) and merge only when no function's compiled code changes: a change to compiled code is
// function work and needs its task. The merge records what it compiled, like any other merge.
import { HttpError } from './http.js';
import { now } from './db.js';
import { hasRole } from './accounts.js';
import { lintSubmission } from '../shared/lint.js';
import { isUnit, addUnitsToCompileCommands } from '../shared/project.js';
import { bestResults } from './verify.js';
export class Maintenance {
constructor({ store, repo, project, verifier, merger, log = console }) {
Object.assign(this, { store, repo, project, verifier, merger, log });
}
/** Queues a maintenance batch; it is verified and merged by run(). */
submit(user, { baseCommit, files, notes = null }) {
if (!hasRole(user, 'maintainer')) throw new HttpError(403, 'maintenance batches require the maintainer role');
if (!Object.keys(files).length) throw new HttpError(400, 'files must not be empty');
const r = this.store.run(
`INSERT INTO batches (user_id, token_id, base_commit, files_json, notes, state, kind, created_at) VALUES (?, ?, ?, ?, ?, 'queued', 'maintenance', ?)`,
user.id, user.tokenId ?? null, baseCommit, JSON.stringify(files), notes, now(),
);
const batchId = Number(r.lastInsertRowid);
this.store.event('maintenance_submit', user.id, { batch: batchId, files: Object.keys(files).length });
return { batchId, state: 'queued' };
}
/** Verifies and, when nothing compiled changes, merges one maintenance batch the pump took. */
async run(batchId) {
const batch = this.store.get(`SELECT * FROM batches WHERE id = ? AND kind = 'maintenance'`, batchId);
if (!batch || batch.state !== 'verifying') return;
try {
const outcome = await this.verifyAndMerge(batch);
if (outcome.state === 'queued') {
this.store.run(`UPDATE batches SET state = 'queued' WHERE id = ?`, batch.id);
return outcome;
}
this.store.run(`UPDATE batches SET state = ?, finished_at = ?, result_json = ?, merged_commit = ? WHERE id = ?`, outcome.state, now(), JSON.stringify(outcome.result), outcome.commit ?? null, batch.id);
return outcome;
} catch (e) {
this.log.error(`maintenance batch ${batch.id}: ${e.stack}`);
this.store.run(`UPDATE batches SET state = 'returned', finished_at = ?, result_json = ? WHERE id = ?`, now(), JSON.stringify({ returned: 'internal error; a maintainer has been notified' }), batch.id);
return { state: 'returned' };
}
}
async verifyAndMerge(batch) {
const files = JSON.parse(batch.files_json);
const back = (returned, details = {}) => ({ state: 'returned', result: { returned, ...details } });
const sourceDir = this.project.sourceDirs[0];
const lintErrors = lintSubmission(files, {
allowedPaths: [`${sourceDir}/**`, ...this.project.headerGlobs],
limits: this.project.lintLimits,
allowedPragmas: this.project.allowedPragmas,
baseSize: (f) => this.repo.size('HEAD', f),
});
if (lintErrors.length) return back('rejected by the static gate', { lintErrors });
if (!this.repo.isAncestor(batch.base_commit)) return back(`base commit ${batch.base_commit} is not part of main; sync and resubmit`);
const textErrors = this.verifier.freeTextGate(files, batch.base_commit);
if (textErrors.length) return back('the submission adds comments or strings that are not allowed', { lintErrors: textErrors });
const dropped = this.verifier.droppedAnnotations(files, batch.base_commit, [], batch.user_id);
if (dropped.length) return back(`removes the annotation of ${dropped.join(', ')}: a maintenance batch keeps every function annotated`, { droppedAnnotations: dropped });
const undefinedNames = this.verifier.undefinedGate(files, batch.base_commit);
if (undefinedNames.length) return back(`declares functions that are defined nowhere: ${undefinedNames.join(', ')}`, { undefinedFunctions: undefinedNames });
const gateErrors = this.verifier.globalsGate?.(files, batch.base_commit) ?? [];
if (gateErrors.length) return back('global variable rules (see globalErrors)', { globalErrors: gateErrors });
const startHead = this.repo.head();
const merged = this.repo.mergeOntoHead(batch.base_commit, files);
if (merged.conflicts.length) return back('conflicts with newer work on main; sync, merge and resubmit', { conflicts: merged.conflicts });
// Every unit the change can affect: the submitted ones, and every includer of a changed header.
const { check, rec, changedHeaders } = await this.verifier.check(merged.files, `maintenance-${batch.id}`, { dependents: true });
const failed = check.units.filter((u) => !u.compiled);
if (failed.length) return back('compilation failed', { compileErrors: failed.map((u) => ({ file: u.file, log: u.log })) });
const fileSet = new Set(Object.keys(merged.files));
const annErrors = check.annotationErrors.filter((e) => fileSet.has(e.file) && e.severity !== 'warning');
if (annErrors.length) return back('annotation errors', { annotationErrors: annErrors });
const lint = check.units.flatMap((u) => u.lintErrors);
if (lint.length) return back('banned constructs found after preprocessing', { lintErrors: lint });
await this.verifier.refreshBaselines(check, merged.files, `maintenance-${batch.id}`);
const regressions = this.verifier.regressions(check, merged.files);
if (regressions.length) return back(`breaks ${regressions.length} function(s) that matched before`, { regressions });
const changed = this.changedFunctions(check);
if (changed.length) {
return back(`changes the compiled code of ${changed.length} function(s): that is function work; submit those functions with their tasks`, { changedFunctions: changed.slice(0, 200) });
}
// Another merge committed while this one compiled: check again on the new main.
if (this.repo.head() !== startHead) return { state: 'queued', result: { note: 'main moved while verifying; checking again' } };
// Commit like any merge; new units join the build list in the same commit.
const author = this.store.get('SELECT name FROM users WHERE id = ?', batch.user_id).name;
const listFile = this.project.compileCommands ?? null;
const grown = listFile ? addUnitsToCompileCommands(this.project, this.repo.show(startHead, listFile), Object.keys(merged.files).filter(isUnit)) : null;
const filesToCommit = grown ? { ...merged.files, [listFile]: grown.text } : merged.files;
const summary = String(batch.notes ?? '').split('\n')[0].slice(0, 120) || `${Object.keys(files).length} file(s)`;
const message =
`Maintenance: ${summary}\n\nBatch #${batch.id} by ${author}: no function's compiled code changes; checked with ${check.units.length} compiled unit(s)` +
`${changedHeaders.length ? `, including every includer of ${changedHeaders.join(', ')}` : ''}.\n${grown ? `Adds to the build: ${grown.added.join(', ')}\n` : ''}`;
const commit = this.repo.commitFiles(filesToCommit, message, `${author} <${author.replace(/[^A-Za-z0-9_.-]/g, '_')}@users.clank-together.invalid>`);
if (grown) this.project.compile.units = grown.units;
this.store.tx(() => this.merger.applyCheckToDatabase(check, rec, commit, batch.user_id));
this.merger.afterCommit(commit);
this.log.info(`merged maintenance batch ${batch.id} as ${commit.slice(0, 10)}`);
return { state: 'merged', commit, result: { units: check.units.length, changedHeaders } };
}
/** Functions whose compiled code differs from what main recorded for them. */
changedFunctions(check) {
const out = [];
for (const [id, { f, unit }] of bestResults(check)) {
const main = this.store.get('SELECT code_hash, status, score FROM func_status WHERE symbol_id = ?', id);
if (!main) {
if (f.codeHash) out.push({ id, file: unit, why: 'not compiled on main before' });
continue;
}
if (main.code_hash && f.codeHash && main.code_hash !== f.codeHash) out.push({ id, file: unit, why: 'compiled code changed' });
}
return out;
}
}

View file

@ -404,6 +404,12 @@ export class Merger {
settleBatch(this.store, split.parent.id, 'its approved functions merged; the others were sent back'); settleBatch(this.store, split.parent.id, 'its approved functions merged; the others were sent back');
} }
}); });
this.afterCommit(commit);
this.log.info(`merged batch ${batch.id} (${attempts.length} functions) as ${commit.slice(0, 10)}`);
}
/** What follows every commit to main: new tasks, the push to the mirror, listeners. */
afterCommit(commit) {
this.tasks.generate(); this.tasks.generate();
if (this.config.pushRemote) { if (this.config.pushRemote) {
try { try {
@ -413,7 +419,6 @@ export class Merger {
} }
} }
this.onMerged?.(commit); this.onMerged?.(commit);
this.log.info(`merged batch ${batch.id} (${attempts.length} functions) as ${commit.slice(0, 10)}`);
} }
/** /**

View file

@ -90,6 +90,7 @@ export class Verifier {
`SELECT b.* FROM batches b WHERE b.state = 'queued' `SELECT b.* FROM batches b WHERE b.state = 'queued'
ORDER BY (SELECT COUNT(*) FROM batches v WHERE v.user_id = b.user_id AND v.state = 'verifying'), b.id LIMIT 1`, ORDER BY (SELECT COUNT(*) FROM batches v WHERE v.user_id = b.user_id AND v.state = 'verifying'), b.id LIMIT 1`,
); );
if (row && row.kind === 'maintenance' && !this.maintenance) return null;
if (row) { if (row) {
this.store.run(`UPDATE batches SET state = 'verifying' WHERE id = ?`, row.id); this.store.run(`UPDATE batches SET state = 'verifying' WHERE id = ?`, row.id);
this.store.run(`UPDATE attempts SET state = 'verifying' WHERE batch_id = ? AND state = 'queued'`, row.id); this.store.run(`UPDATE attempts SET state = 'verifying' WHERE batch_id = ? AND state = 'queued'`, row.id);
@ -98,7 +99,9 @@ export class Verifier {
}); });
if (!b) return; if (!b) return;
this.running++; this.running++;
this.verifyBatch(b) // A maintenance batch verifies and merges in one step (maintenance.js).
const job = b.kind === 'maintenance' ? this.maintenance.run(b.id) : this.verifyBatch(b);
job
.catch((e) => { .catch((e) => {
this.log.error(`batch ${b.id}: verifier crashed: ${e.stack}`); this.log.error(`batch ${b.id}: verifier crashed: ${e.stack}`);
for (const a of batchAttempts(this.store, b.id)) { for (const a of batchAttempts(this.store, b.id)) {

View file

@ -1339,6 +1339,77 @@ test('admin: a done task is reopened only with force, and then as open', async (
ctx.store.run(`UPDATE tasks SET state = 'done' WHERE id = ?`, done.id); ctx.store.run(`UPDATE tasks SET state = 'done' WHERE id = ?`, done.id);
}); });
test('maintenance batches: maintainers only; merge when no compiled code changes, back otherwise', async () => {
const contributor = await activeUser('mtcontrib');
const maintainer = await activeUser('mtmaint', 'maintainer');
const unit = 'src/maint.cpp';
const v = ctx.store.currentVersion();
const fid = ctx.store.ensureSymbol({ kind: 'function', versionId: v.id, addr: 0x402900, size: 8, unit }).symbol.id;
ctx.tasks.generate();
const onMain = `// FUNCTION: ${fid}\nint mf() { return 1; }\n`;
ctx.repo.commitFiles({ [unit]: onMain }, 'seed maint.cpp', 'x <x@y.invalid>');
ctx.store.run(`INSERT INTO func_status (symbol_id, file, status, score, commit_hash, updated_at, confirmed, code_hash) VALUES (?, ?, 'mismatch', 0.7, 'x', ?, 1, 'mf-main')`, fid, unit, now());
const realCheck = ctx.verifier.check;
ctx.verifier.check = async (files) => {
const text = files[unit] ?? '';
const body = /int mf\(\) \{ ([^}]*) \}/.exec(text)?.[1] ?? '';
const functions = [{ id: fid, status: STATUS.MISMATCH, score: 0.7, file: unit, codeHash: body === 'return 1;' ? 'mf-main' : `mf-${body}`, relocations: [], size: { ours: 8, original: 8 } }];
const units = Object.keys(files).filter((f) => f.endsWith('.cpp')).map((file) => ({ file, compiled: true, lintErrors: [], unannotated: [], bindings: {}, inferred: [], includes: [], errors: [], functions: file === unit ? functions : [] }));
return { check: { annotationErrors: [], units }, rec: { conflicts: [], unknown: [], learned: [], learnedAddresses: [] }, changedHeaders: [], units: units.map((u) => u.file) };
};
const submit = (token, files, notes = 'move a global') => call('POST', '/api/maintenance', { baseCommit: ctx.repo.head(), files, notes }, token);
const settle = (id, token) => waitFor(async () => { const st = (await call('GET', `/api/batches/${id}`, null, token)).body; return !['queued', 'verifying'].includes(st.state) && st; });
try {
assert.equal((await submit(contributor, { [unit]: onMain })).status, 403);
// A data-only change (a new unit holding a global) merges, and joins the build in that commit.
const added = { 'src/owner.cpp': 'int g_owned;\n' };
const ok = await submit(maintainer, added);
assert.equal(ok.status, 200, JSON.stringify(ok.body));
const done = await settle(ok.body.batchId, maintainer);
assert.equal(done.state, 'merged', JSON.stringify(done));
assert.equal(done.kind, 'maintenance');
assert.equal(ctx.repo.show('HEAD', 'src/owner.cpp'), added['src/owner.cpp']);
// Changing a function's compiled code is function work: it goes back, main stays.
const changed = await submit(maintainer, { [unit]: onMain.replace('return 1;', 'int x = 1; return x;') });
const back = await settle(changed.body.batchId, maintainer);
assert.equal(back.state, 'returned');
assert.match(back.result.returned, /changes the compiled code of 1 function/);
assert.equal(ctx.repo.show('HEAD', unit), onMain);
// Removing a function's annotation is refused before compiling.
const dropped = await submit(maintainer, { [unit]: 'int mf() { return 1; }\n' });
assert.match((await settle(dropped.body.batchId, maintainer)).result.returned, new RegExp(`removes the annotation of ${fid}`));
} finally {
ctx.verifier.check = realCheck;
}
});
test('admin symbol corrections: size and name; a global is retired only once main no longer annotates it', async () => {
const maintainer = await activeUser('symmaint', 'maintainer');
const contributor = await activeUser('symcontrib');
const v = ctx.store.currentVersion();
const gid = ctx.store.ensureSymbol({ kind: 'global', versionId: v.id, addr: 0x409100 }).symbol.id;
const fix = (id, body, token = maintainer) => call('POST', `/api/admin/symbols/${id}`, body, token);
assert.equal((await fix(gid, { size: 64, reason: 'r' }, contributor)).status, 403);
assert.equal((await fix(gid, { size: 64 })).status, 400, 'a reason is required');
assert.equal((await fix(gid, { size: 64, name: 'g_table', reason: 'constructor writes 64 bytes' })).status, 200);
const row = ctx.store.get('SELECT a.size, s.name FROM symbols s JOIN symbol_addresses a ON a.symbol_id = s.id WHERE s.id = ?', gid);
assert.deepEqual([row.size, row.name], [64, 'g_table']);
ctx.repo.commitFiles({ 'src/annotated.cpp': `// GLOBAL: ${gid}\nint g_table;\n` }, 'annotate', 'x <x@y.invalid>');
const blocked = await fix(gid, { retire: true, reason: 'member of another object' });
assert.equal(blocked.status, 409);
assert.match(blocked.body.error, /still annotated on main \(src\/annotated\.cpp\)/);
ctx.repo.commitFiles({ 'src/annotated.cpp': 'int g_table;\n' }, 'unannotate', 'x <x@y.invalid>');
assert.equal((await fix(gid, { retire: true, reason: 'member of another object' })).status, 200);
assert.equal(ctx.store.get('SELECT 1 AS x FROM symbol_addresses WHERE symbol_id = ?', gid), undefined);
});
test('new work is claimed by region; a single function only to fix one that came back', async () => { test('new work is claimed by region; a single function only to fix one that came back', async () => {
const token = await activeUser('rtuser'); const token = await activeUser('rtuser');
const open = ctx.store.get(`SELECT id FROM tasks WHERE state = 'open' LIMIT 1`); const open = ctx.store.get(`SELECT id FROM tasks WHERE state = 'open' LIMIT 1`);