The owner handles a new request from the Console: pick the mode (new autonomous, new guided or an existing project), answer the project manager's questions and confirm an attachment, with no copy-to-terminal step. Bus: 3p->3q append-only migration widens the request_events kinds. The PM asks one question with request.question. The owner-only request.answer.console, request.attach.console, request.mode.console and request.cancel.console join the two console reads in CONSOLE_VERBS. An answer is an attributed human.input linked to its question and request, recorded once per op and never a PRD approval. An attach pick makes the PM propose that project as a confirmation decision; nothing is mapped on the pick. A mode change takes the expected revisions and leaves the bound plan as evidence. Cancel keeps a created project. Console: request-http.mjs serves GET /api/requests/:request, /projects and /draft and POST /answer, /attach, /mode and /cancel with the signed-in owner, same-origin and bounded-body checks of the decision routes. requests.js draws the panel under the owner's chat message, answers the mode decision through the existing confirmation endpoints, and after a lost answer response reads the request first and resends the same op only on the owner's click.
118 lines
6.6 KiB
JavaScript
118 lines
6.6 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, mkdirSync, readFileSync, copyFileSync, chmodSync, rmSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import { createHash } from 'node:crypto';
|
|
import { Store, schemaDigest } from '../src/store.mjs';
|
|
|
|
function legacy(t) {
|
|
const root = mkdtempSync(join(tmpdir(), 'comment-migration-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const source = join(root, 'schema-current.sqlite');
|
|
const db = new DatabaseSync(source);
|
|
const ddl = readFileSync(new URL('./fixtures/schema-3b.sql', import.meta.url), 'utf8');
|
|
assert.equal(createHash('sha256').update(ddl).digest('hex'),
|
|
'179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65');
|
|
db.exec(ddl);
|
|
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_version', '3b');
|
|
db.prepare('INSERT INTO meta VALUES (?,?)').run('schema_digest', schemaDigest(db));
|
|
db.prepare('INSERT INTO events(id,at,business,kind,body) VALUES(?,?,?,?,?)')
|
|
.run('preserved', '2026-10-15T00:00:00.000Z', 'demo', 'action.allowed', '{"fixture":true}');
|
|
db.close();
|
|
mkdirSync(join(root, 'bus'), { mode: 0o700 });
|
|
const path = join(root, 'bus/bus.sqlite');
|
|
copyFileSync(source, path);
|
|
chmodSync(path, 0o600);
|
|
return { root, source, path };
|
|
}
|
|
|
|
test('explicit 3b copy migration preserves evidence and opens as 3g; normal startup never migrates', t => {
|
|
const { root, source } = legacy(t);
|
|
const original = readFileSync(source);
|
|
assert.throws(() => new Store(root), /schema-mismatch/);
|
|
const store = new Store(root, { migrateSchema: true });
|
|
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3r');
|
|
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
|
|
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
|
|
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
|
|
assert.equal(new Date(epoch).toISOString(), epoch);
|
|
store.close();
|
|
assert.deepEqual(readFileSync(source), original);
|
|
const again = new Store(root, { migrateSchema: true });
|
|
assert.equal(again.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value, epoch);
|
|
again.close();
|
|
new Store(root).close();
|
|
});
|
|
|
|
test('migration refuses active writers and drifted legacy schemas, with no partial upgrade', t => {
|
|
const { root, path } = legacy(t);
|
|
const db = new DatabaseSync(path);
|
|
db.exec('DROP TRIGGER events_no_delete');
|
|
db.close();
|
|
assert.throws(() => new Store(root, { migrateSchema: true }), /schema-mismatch/);
|
|
const check = new DatabaseSync(path);
|
|
assert.equal(check.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value, '3b');
|
|
assert.equal(check.prepare("SELECT count(*) n FROM sqlite_master WHERE name='task_comment_cursors'").get().n, 0);
|
|
check.close();
|
|
const other = legacy(t);
|
|
const current = new Store(other.root, { migrateSchema: true });
|
|
t.after(() => current.close());
|
|
assert.throws(() => new Store(other.root, { migrateSchema: true }), /writer-locked/);
|
|
});
|
|
|
|
test('named migration registry chains 3b through 3g to a fixture-only future version in order', async t => {
|
|
const { root } = legacy(t);
|
|
const { writeFileSync } = await import('node:fs');
|
|
const { pathToFileURL } = await import('node:url');
|
|
const moduleRoot = join(root, 'future-bus');
|
|
mkdirSync(join(moduleRoot, 'src'), { recursive: true });
|
|
mkdirSync(join(moduleRoot, 'src/migrations'));
|
|
const futureSQL = 'CREATE TABLE future_fixture (value TEXT) STRICT;\n';
|
|
const currentDDL = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
|
|
const futureDDL = currentDDL + futureSQL;
|
|
const sha = createHash('sha256').update(futureDDL).digest('hex');
|
|
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
|
|
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
|
|
.replace("export const SCHEMA_VERSION = '3r';", "export const SCHEMA_VERSION = '3s';")
|
|
.replace('}];\nconst timedTables', `}, { from: '3r', to: '3s', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
|
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
|
|
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
|
|
for (const name 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'])
|
|
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
|
|
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
|
|
const future = new FutureStore(root, { migrateSchema: true });
|
|
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3s');
|
|
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
|
|
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
|
|
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
|
|
future.close();
|
|
new FutureStore(root).close();
|
|
});
|
|
|
|
test('migration refuses mismatched source metadata without changing schema or evidence', t => {
|
|
const { root, path } = legacy(t);
|
|
const db = new DatabaseSync(path);
|
|
db.exec("DROP TRIGGER meta_no_update; UPDATE meta SET value='untrusted' WHERE key='schema_digest'; CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END");
|
|
db.close();
|
|
assert.throws(() => new Store(root, { migrateSchema: true }), /schema-mismatch/);
|
|
const check = new DatabaseSync(path);
|
|
assert.equal(check.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value, '3b');
|
|
assert.equal(check.prepare('SELECT id FROM events').get().id, 'preserved');
|
|
check.close();
|
|
});
|
|
|
|
test('offline command migrates only the supplied fixture root and reports usage errors', async t => {
|
|
const { spawnSync } = await import('node:child_process');
|
|
const { root } = legacy(t);
|
|
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
|
|
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
|
|
assert.equal(migrated.status, 0, migrated.stderr);
|
|
assert.match(migrated.stdout, /bus schema 3r ready/);
|
|
new Store(root).close();
|
|
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
|
|
assert.equal(usage.status, 2);
|
|
assert.match(usage.stderr, /usage:/);
|
|
});
|