diff --git a/src/client/cli.js b/src/client/cli.js index 26378a1..19db240 100644 --- a/src/client/cli.js +++ b/src/client/cli.js @@ -47,6 +47,11 @@ Work (a region: a source file or class, done as a whole) td symbol new <0xaddr> [--name N] get a stable ID for a new global/vtable/string/function td decompile | td disasm 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 [--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) td peer list attempts waiting for review td peer claim [attemptId] claim one, prints instructions, original and submission @@ -114,6 +119,8 @@ async function main() { range: { type: 'string' }, files: { type: 'string' }, reason: { type: 'string' }, + size: { type: 'string' }, + retire: { type: 'boolean', default: false }, json: { type: 'boolean', default: false }, free: { 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)) : []; 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 [--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': { const r = await client.get(`/api/attempts/${args[0]}`); delete r.files; diff --git a/src/client/ops.js b/src/client/ops.js index 78417a3..cae4843 100644 --- a/src/client/ops.js +++ b/src/client/ops.js @@ -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 } : {}) }); } +/** + * 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. */ function rewrittenFunctions(ws, allowed, files) { const out = new Set(); diff --git a/src/server/app.js b/src/server/app.js index ce6be00..4bbb0b7 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -11,6 +11,7 @@ import { Repo, serveGit } from './git.js'; import { Tasks } from './tasks.js'; import { Verifier } from './verify.js'; import { Merger } from './merge.js'; +import { Maintenance } from './maintenance.js'; import { PeerReviews } from './peer.js'; import { Originals } from './original.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 ghidra = new ServerGhidra({ config, store, originals, log }); 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 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) { @@ -529,6 +532,18 @@ export function createApp(ctx) { 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 }) => { 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)); @@ -540,8 +555,10 @@ export function createApp(ctx) { ); return guided({ id: b.id, + kind: b.kind, author: b.author, state: b.state, + ...(b.kind === 'maintenance' && { result: result }), baseCommit: b.base_commit, mergedCommit: b.merged_commit, 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}` }; }); + // 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. // Derived from the exe, so approved accounts only, like the exe itself. router.on('GET', '/api/original/:symbol', async ({ req, params }) => { diff --git a/src/server/db.js b/src/server/db.js index 5a5d3a1..305ca17 100644 --- a/src/server/db.js +++ b/src/server/db.js @@ -321,6 +321,8 @@ export class Store { 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. 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 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 diff --git a/src/server/maintenance.js b/src/server/maintenance.js new file mode 100644 index 0000000..7ceca41 --- /dev/null +++ b/src/server/maintenance.js @@ -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; + } +} diff --git a/src/server/merge.js b/src/server/merge.js index 1d40bd9..ce8f655 100644 --- a/src/server/merge.js +++ b/src/server/merge.js @@ -404,6 +404,12 @@ export class Merger { 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(); if (this.config.pushRemote) { try { @@ -413,7 +419,6 @@ export class Merger { } } this.onMerged?.(commit); - this.log.info(`merged batch ${batch.id} (${attempts.length} functions) as ${commit.slice(0, 10)}`); } /** diff --git a/src/server/verify.js b/src/server/verify.js index bc6c790..c1f7568 100644 --- a/src/server/verify.js +++ b/src/server/verify.js @@ -90,6 +90,7 @@ export class Verifier { `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`, ); + if (row && row.kind === 'maintenance' && !this.maintenance) return null; if (row) { 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); @@ -98,7 +99,9 @@ export class Verifier { }); if (!b) return; 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) => { this.log.error(`batch ${b.id}: verifier crashed: ${e.stack}`); for (const a of batchAttempts(this.store, b.id)) { diff --git a/test/server.test.js b/test/server.test.js index b3c0afa..d717e6d 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -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); }); +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 '); + 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 '); + 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 '); + 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 () => { const token = await activeUser('rtuser'); const open = ctx.store.get(`SELECT id FROM tasks WHERE state = 'open' LIMIT 1`);