283 lines
19 KiB
JavaScript
283 lines
19 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createHash } from 'node:crypto';
|
|
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import { Store, schemaDigest } from '../src/store.mjs';
|
|
import { Broker, TESTER_VERBS, USER_VERBS, VIEWER_VERBS } from '../src/broker.mjs';
|
|
|
|
// #797 MVP3 (owner report 2026-11-06): the owner's reversible project archive. The verbs are the
|
|
// human's alone (tester, user and agent refused), an archived project hides from project.list
|
|
// until includeArchived asks for it, refuses new threads and requests, keeps every record
|
|
// readable, and unarchive restores everything. Nothing is ever deleted.
|
|
|
|
const shipped = (name) => JSON.parse(readFileSync(new URL(`../../../roles/${name}.json`, import.meta.url), 'utf8'));
|
|
const AT = '2026-11-05T00:00:00.000Z';
|
|
const business = {
|
|
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' },
|
|
roles: {
|
|
continuity: { definition: 'continuity', authority: { withinRole: ['message.send'], crossRole: [] } },
|
|
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
|
},
|
|
definitions: { continuity: { messaging: shipped('continuity').messaging }, 'chief-of-staff': {} },
|
|
projects: { stack: { root: '/nonexistent' } }, vars: { 'bus.tester': true },
|
|
};
|
|
|
|
function setup(t, { grants = null } = {}) {
|
|
const root = mkdtempSync(join(tmpdir(), 'bus-project-archive-'));
|
|
const store = new Store(root);
|
|
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
|
const access = { doc: { accessVersion: 1, revision: 1, users: [], grants: grants ?? [] } };
|
|
const b = new Broker({ store, businesses: { demo: business }, access: () => access.doc }); const caps = {
|
|
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
|
|
tester: b.bindTester({ business: 'demo' }),
|
|
};
|
|
const call = (verb, args = {}, who = 'human') => b.request(caps[who], { verb, args });
|
|
const code = (verb, args = {}, who = 'human') => {
|
|
try { call(verb, args, who); } catch (e) { return e.code; }
|
|
return null;
|
|
};
|
|
caps.agent = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
|
call('role.claim', {}, 'agent');
|
|
if (grants) caps.user = b.bindUser({ business: 'demo', user: 'alice' });
|
|
return { b, caps, store, call, code, root, access };
|
|
}
|
|
|
|
const project = (f, name = 'demo-project') => f.call('project.create', { name }).id;
|
|
|
|
test('project.archive and project.unarchive are the human\'s alone', (t) => {
|
|
const f = setup(t), id = project(f);
|
|
for (const list of [TESTER_VERBS, USER_VERBS, VIEWER_VERBS])
|
|
assert.ok(!list.includes('project.archive') && !list.includes('project.unarchive'));
|
|
assert.equal(f.code('project.archive', { project: id }, 'tester'), 'tester-refused');
|
|
assert.equal(f.code('project.archive', { project: id }, 'agent'), 'human-required');
|
|
assert.throws(() => f.b.bindHuman({ business: 'demo', human: 'not-owner', via: 'cli', outsideAgent: true }), { code: 'human-required' });
|
|
assert.throws(() => f.b.bindConsole({ business: 'demo', human: 'not-owner', subject: { provider: 'test', iss: 'test', sub: 'other' } }), { code: 'human-required' });
|
|
f.caps.console = f.b.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'test', iss: 'test', sub: 'owner' } });
|
|
assert.equal(f.code('project.archive', { project: id }, 'console'), 'human-required');
|
|
const u = setup(t, { grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['continuity'], level: 'collaborator' }] });
|
|
assert.equal(u.code('project.archive', { project: id }, 'user'), 'access-refused');
|
|
assert.equal(u.code('project.unarchive', { project: id }, 'user'), 'access-refused');
|
|
assert.deepEqual(f.call('project.archive', { project: id }), { project: id, archived: true });
|
|
assert.deepEqual(f.call('project.unarchive', { project: id }), { project: id, archived: false });
|
|
});
|
|
|
|
test('archive verbs validate arguments and unknown projects', (t) => {
|
|
const f = setup(t), id = project(f);
|
|
assert.equal(f.code('project.archive', {}), 'invalid-request');
|
|
assert.equal(f.code('project.archive', { project: id, extra: 1 }), 'invalid-request');
|
|
// identifier() takes any id-shaped string, so a non-uuid name simply finds no project.
|
|
assert.equal(f.code('project.archive', { project: 'not-a-uuid' }), 'project-not-found');
|
|
assert.equal(f.code('project.archive', { project: '00000000-0000-4000-8000-000000000009' }), 'project-not-found');
|
|
assert.equal(f.code('project.unarchive', { project: id }), 'project-not-archived');
|
|
f.call('project.archive', { project: id });
|
|
assert.equal(f.code('project.archive', { project: id }), 'project-already-archived');
|
|
// A repeat op never writes a second row.
|
|
assert.equal(f.store.get('SELECT count(*) n FROM project_archives').n, 1);
|
|
});
|
|
|
|
test('project.list hides archived projects until includeArchived, which flags them', (t) => {
|
|
const f = setup(t);
|
|
const live = project(f, 'live'), gone = project(f, 'gone');
|
|
f.call('project.archive', { project: gone });
|
|
assert.deepEqual(f.call('project.list').map((p) => p.id), [live]);
|
|
assert.ok(f.call('project.list').every((p) => !('archived' in p)));
|
|
const all = f.call('project.list', { includeArchived: true });
|
|
assert.deepEqual(all.map((p) => p.id), [live, gone]);
|
|
assert.deepEqual(all.map((p) => p.archived), [undefined, true]);
|
|
assert.equal(f.code('project.list', { includeArchived: 'yes' }), 'invalid-request');
|
|
assert.equal(f.code('project.list', { includeArchived: false }), 'invalid-request');
|
|
// FED R3: a user still lists granted projects only, archived or not. Grants name project ids.
|
|
const u = setup(t);
|
|
const uGone = project(u, 'gone');
|
|
u.access.doc.grants.push({ id: 'g-1', user: 'alice', business: 'demo', projects: [uGone], instances: ['continuity'], level: 'collaborator' });
|
|
u.caps.user = u.b.bindUser({ business: 'demo', user: 'alice' });
|
|
assert.deepEqual(u.call('project.list', {}, 'user').map((p) => p.id), [uGone]);
|
|
assert.deepEqual(u.call('project.list', { includeArchived: true }, 'user').map((p) => p.id), [uGone]);
|
|
u.call('project.archive', { project: uGone });
|
|
assert.deepEqual(u.call('project.list', {}, 'user').map((p) => p.id), []);
|
|
assert.deepEqual(u.call('project.list', { includeArchived: true }, 'user').map((p) => [p.id, p.archived]), [[uGone, true]]);
|
|
});
|
|
|
|
test('an archived project refuses new threads and requests, keeps records readable, and unarchive restores everything', (t) => {
|
|
const f = setup(t), id = project(f);
|
|
const thread = f.call('thread.create', { project: id, name: 'continuity', instance: 'continuity' }).id;
|
|
assert.ok(f.call('thread.send', { thread, body: 'before archive' }).id);
|
|
const rows = () => ({
|
|
threads: f.store.get('SELECT count(*) n FROM threads').n,
|
|
messages: f.store.get('SELECT count(*) n FROM messages').n,
|
|
events: f.store.get("SELECT count(*) n FROM events WHERE kind='human.input'").n,
|
|
});
|
|
f.call('room.open', { project: id });
|
|
const before = rows();
|
|
f.call('project.archive', { project: id });
|
|
assert.equal(f.code('thread.create', { project: id, name: 'another', instance: 'continuity' }), 'project-archived');
|
|
assert.equal(f.code('thread.send', { thread, body: 'while archived' }), 'project-archived');
|
|
assert.equal(f.code('room.open', { project: id }), 'project-archived');
|
|
assert.equal(f.code('room.send', { project: id, body: 'while archived' }), 'project-archived');
|
|
assert.deepEqual(rows(), before, 'an archived project writes no thread, message or event');
|
|
// Existing records stay readable.
|
|
assert.deepEqual(f.call('thread.list', { project: id }).map((r) => r.id), [thread]);
|
|
assert.equal(f.call('thread.messages', { thread }).length, 1);
|
|
assert.ok(Array.isArray(f.call('room.read', { project: id })));
|
|
// Unarchive restores everything.
|
|
f.call('project.unarchive', { project: id });
|
|
assert.ok(f.call('project.list').some((p) => p.id === id));
|
|
assert.ok(f.call('thread.create', { project: id, name: 'another', instance: 'continuity' }).id);
|
|
assert.ok(f.call('thread.send', { thread, body: 'after unarchive' }).id);
|
|
assert.ok(f.call('room.send', { project: id, body: 'after unarchive' }).ids.length > 0);
|
|
// The audit trail: one row per change, append-only, naming who and when.
|
|
assert.deepEqual(f.store.all('SELECT business,project,op,by FROM project_archives').map((r) => ({ ...r })), [
|
|
{ business: 'demo', project: id, op: 'archive', by: 'jason' },
|
|
{ business: 'demo', project: id, op: 'unarchive', by: 'jason' },
|
|
]);
|
|
assert.match(f.store.get('SELECT at FROM project_archives').at, /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/);
|
|
});
|
|
|
|
// --- The 3s→3t migration: project_archives is new and empty, and everything else stays. ---
|
|
|
|
const here = (p) => new URL(p, import.meta.url);
|
|
const migrationSQL = readFileSync(here('../src/migrations/3s-3t.sql'), 'utf8');
|
|
const currentDDL = readFileSync(here('../schema.sql'), 'utf8');
|
|
assert.equal(currentDDL.split(migrationSQL).length, 2, 'schema.sql holds the 3s→3t step exactly once');
|
|
// 3u-3v (#1873, U4a) adds store_tasks after them; a 3s store has neither.
|
|
const ddl3s = currentDDL.replace(migrationSQL, '').replace(readFileSync(here('../src/migrations/3t-3u.sql'), 'utf8'), '').replace(readFileSync(here('../src/migrations/3u-3v.sql'), 'utf8'), '');
|
|
|
|
function source3s(t) {
|
|
const root = mkdtempSync(join(tmpdir(), 'project-archive-3s-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
mkdirSync(join(root, 'bus'), { mode: 0o700 });
|
|
const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path);
|
|
db.exec(ddl3s);
|
|
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_version', '3s');
|
|
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_digest', schemaDigest(db));
|
|
db.prepare('INSERT INTO meta VALUES (?,?)').run('comment_cursor_epoch', AT);
|
|
db.prepare("INSERT INTO projects(id,at,business,name,repo,created_by) VALUES('p1',?,'demo','kept',NULL,'jason')").run(AT);
|
|
db.prepare("INSERT INTO events(id,at,business,kind,body) VALUES('preserved',?,'demo','action.allowed','{}')").run(AT);
|
|
db.close(); chmodSync(path, 0o600);
|
|
return { root, path };
|
|
}
|
|
const master = (path) => {
|
|
const db = new DatabaseSync(path, { readOnly: true });
|
|
try { return db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map((r) => ({ ...r })); }
|
|
finally { db.close(); }
|
|
};
|
|
const version = (path) => {
|
|
const db = new DatabaseSync(path, { readOnly: true });
|
|
try { return db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value; }
|
|
finally { db.close(); }
|
|
};
|
|
|
|
test('a 3s store migrates to 3t: project_archives is new and empty, every row stays, and the schema is the fresh one', (t) => {
|
|
const { root, path } = source3s(t);
|
|
const was = master(path);
|
|
assert.throws(() => new Store(root), /schema-mismatch/);
|
|
assert.equal(version(path), '3s');
|
|
assert.deepEqual(master(path), was, 'the refused open changed nothing');
|
|
const store = new Store(root, { migrateSchema: true });
|
|
t.after(() => store.close());
|
|
assert.equal(version(path), '3v');
|
|
assert.equal(store.get("SELECT id FROM events").id, 'preserved');
|
|
assert.equal(store.get("SELECT name FROM projects").name, 'kept');
|
|
assert.deepEqual(store.all('SELECT seq,at,business,project,op,by FROM project_archives'), []);
|
|
const freshRoot = mkdtempSync(join(tmpdir(), 'project-archive-fresh-'));
|
|
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
|
|
new Store(freshRoot).close();
|
|
assert.deepEqual(master(path), master(join(freshRoot, 'bus/bus.sqlite')));
|
|
// The guards: append-only, state changes only, and a row belongs to its project's business.
|
|
const next = (op, p = 'p1') => store.run('INSERT INTO project_archives(at,business,project,op,by) VALUES(?,?,?,?,?)', AT, 'demo', p, op, 'jason');
|
|
next('archive');
|
|
assert.throws(() => next('archive'), /a project archive row changes state/);
|
|
next('unarchive');
|
|
for (const [sql, ...args] of [["UPDATE project_archives SET op='unarchive'"], ['DELETE FROM project_archives'],
|
|
["INSERT OR REPLACE INTO project_archives(seq,at,business,project,op,by) VALUES(1,?,'demo','p1','unarchive','jason')", AT]])
|
|
// The replace also trips the state guard when its op repeats the latest row's.
|
|
assert.throws(() => store.run(sql, ...args), /project_archives is append-only|a project archive row changes state/, sql);
|
|
// A row naming a project that does not exist is refused (the business guard fires first).
|
|
assert.throws(() => next('archive', '00000000-0000-4000-8000-000000000009'), /FOREIGN KEY|an archive row belongs to its project's business/);
|
|
assert.throws(() => store.run("INSERT INTO project_archives(at,business,project,op,by) VALUES(?,'other','p1','archive','jason')", AT), /an archive row belongs to its project's business/);
|
|
assert.throws(() => store.run("INSERT INTO project_archives(at,business,project,op,by) VALUES(?,'demo','p1','grant','jason')", AT), /CHECK/);
|
|
assert.equal(store.get('SELECT count(*) n FROM project_archives').n, 2);
|
|
});
|
|
|
|
test('the 3s engine refuses a 3t store, migrating or not, and leaves it as it was', async (t) => {
|
|
const { root, path } = source3s(t);
|
|
new Store(root, { migrateSchema: true }).close();
|
|
const was = master(path);
|
|
const moduleRoot = join(root, 'engine-3s');
|
|
mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
|
|
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
|
const edited = source
|
|
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3s).digest('hex')}';`)
|
|
.replace("export const SCHEMA_VERSION = '3v';", "export const SCHEMA_VERSION = '3s';")
|
|
.replace(/\}, \{\n {2}\/\/ #797 MVP3[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
|
assert.ok(edited.includes("export const SCHEMA_VERSION = '3s';") && edited.includes('3r-3s') && !edited.includes('3s-3t'), 'the 3s engine has no 3s→3t step');
|
|
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
|
writeFileSync(join(moduleRoot, 'schema.sql'), ddl3s);
|
|
for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql', '3m-3n.sql', '3n-3o.sql', '3o-3p.sql', '3p-3q.sql', '3q-3r.sql', '3r-3s.sql'])
|
|
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
|
|
const Store3s = (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
|
assert.throws(() => new Store3s(root), /schema-mismatch/);
|
|
assert.throws(() => new Store3s(root, { migrateSchema: true }), /schema-mismatch/);
|
|
assert.equal(version(path), '3v');
|
|
assert.deepEqual(master(path), was);
|
|
// The same engine opens a fresh 3s store, so the refusal is the version's and not a broken copy.
|
|
const { root: old } = source3s(t);
|
|
new Store3s(old).close();
|
|
});
|
|
|
|
test('archived threads refuse open, clear and fresh claims, including metadata-only sessions', t => {
|
|
const f = setup(t), p = project(f);
|
|
const thread = f.call('thread.create', { project: p, name: 'archive-admission', instance: 'continuity' }).id;
|
|
const session = f.call('session.list', { thread }).find(s => s.default);
|
|
f.call('thread.open', { thread }); // queued before archive
|
|
const cap = f.b.bindLaunch({ business: 'demo', role: 'continuity', run: 'queued', harness: 'pi', thread, session: session.id });
|
|
f.call('project.archive', { project: p });
|
|
const named = f.call('session.create', { thread, name: 'metadata-only', op: 'metadata-only' }).id;
|
|
for (const se of [session.id, named]) {
|
|
assert.equal(f.code('thread.open', { thread, session: se }), 'project-archived');
|
|
assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'continuity', run: 'new-' + se, harness: 'pi', thread, session: se }), { code: 'project-archived' });
|
|
}
|
|
assert.equal(f.code('session.clear', { thread, session: session.id, revision: session.revision, confirm: true, op: 'archived-clear' }), 'project-archived');
|
|
assert.throws(() => f.b.request(cap, { verb: 'role.claim' }), { code: 'project-archived' });
|
|
assert.equal(f.store.get('SELECT count(*) n FROM session_ops').n, 0);
|
|
assert.equal(f.store.get('SELECT count(*) n FROM thread_claims').n, 0);
|
|
f.call('project.unarchive', { project: p });
|
|
assert.deepEqual(f.call('thread.open', { thread, session: named }), { opened: thread });
|
|
assert.ok(f.b.request(cap, { verb: 'role.claim' }));
|
|
});
|
|
|
|
test('a queued clear can retire and cancel after archive but cannot start its successor', t => {
|
|
const f = setup(t), p = project(f);
|
|
const thread = f.call('thread.create', { project: p, name: 'queued-clear', instance: 'continuity' }).id;
|
|
const se = f.call('session.list', { thread }).find(s => s.default);
|
|
f.call('session.clear', { thread, session: se.id, revision: se.revision, confirm: true, op: 'queued-clear' });
|
|
f.call('project.archive', { project: p });
|
|
assert.equal(f.code('session.clear', { thread, session: se.id, revision: se.revision, confirm: true, op: 'queued-clear' }), 'project-archived');
|
|
const where = { business: 'demo', thread, op: 'queued-clear' };
|
|
f.b.sessionClearExited({ ...where, run: null });
|
|
f.b.sessionClearRetire(where);
|
|
const pending = f.b.sessionClears('demo').find(s => s.op === where.op);
|
|
assert.ok(pending.successor);
|
|
assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'continuity', run: 'successor', harness: 'pi', thread, session: pending.successor }), { code: 'project-archived' });
|
|
assert.ok(f.call('session.cancel', { thread, op: where.op, confirm: true }));
|
|
assert.ok(f.call('session.list', { thread }).length);
|
|
});
|
|
|
|
test('archive preserves an accepted run result, release/end and business-wide launch admission', t => {
|
|
const f = setup(t), p = project(f);
|
|
const thread = f.call('thread.create', { project: p, name: 'accepted', instance: 'continuity' }).id;
|
|
const session = f.call('session.list', { thread }).find(s => s.default).id;
|
|
f.caps.running = f.b.bindLaunch({ business: 'demo', role: 'continuity', run: 'accepted', harness: 'pi', thread, session });
|
|
f.call('role.claim', {}, 'running');
|
|
f.call('project.archive', { project: p });
|
|
assert.ok(f.call('message.send', { to: 'human', body: 'Accepted work is complete.' }, 'running').id);
|
|
assert.equal(f.call('thread.messages', { thread }).at(-1).body, 'Accepted work is complete.');
|
|
assert.ok(f.call('role.release', {}, 'running'));
|
|
assert.ok(f.b.endLaunch({ business: 'demo', run: 'accepted', reason: 'done' }));
|
|
assert.ok(f.b.bindLaunch({ business: 'demo', role: 'chief', run: 'business-live', harness: 'pi' }));
|
|
});
|