diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 3a717e1c2..8785c10f6 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -2064,3 +2064,4 @@ are never rewritten or removed; corrections are new entries. 2026-11-05 | codex-ux-skill-author | Vikunja #818 UX improvement skill author handoff | Candidate-02 three-file package personally authored; package/copy/patch/arithmetic/integrity checks passed; 19 synthetic behavioral samples and all 186 raw artifacts retained with candidate-01 issue/correction and limits. Exact manifest b93d7836ed7c70db62386192d6e1db5c4f867f841aaa7645c3c861def5a9b140; evidence /mnt/storage/scratch/mosaic-lead/skill-ux-improvement/author/run-20261105T022125Z/HANDOFF.md. Independent source review pending; no commit, app suites, forge/queue writes, installation, activation or publication. 2026-11-04 | Mos, skill integrator | #818 / Gitea #1845 | Integrated exact independently approved ms-ux-improvement candidate02 into source;3hashes/validator/whitespace pass and author histories preserved. Separate commit from MVP; its activation target staysbd8120318. No install, activation, publication or live action. +2026-11-06 | mvp3-archive-child (#797 lane) | feat/mvp3-project-archive from 05fdfef9e: owner-only project.archive/unarchive (schema 3s→3t project_archives journal), project.list includeArchived, archived-project refusals on thread/room sends, Console archive UI (new project-archive files), CLI project archive/unarchive/list --archived, restore-guard lists | bus 1601/1601 (Node 24; Node 26 pre-existing socket stall), webui 582/582, plugins 132/132, cli 1191/1194 with two pre-existing load flakes (row253 C4 timing, land_work; both pass isolated, base full-suite fails four of the same family); committed locally, not pushed; report /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r1-report.md diff --git a/packages/bus/README.md b/packages/bus/README.md index 47de1caea..8417adb7b 100644 --- a/packages/bus/README.md +++ b/packages/bus/README.md @@ -241,7 +241,9 @@ a receipt with empty `changes`. A refusal is `action.refused` with its code alon | `state` | No args; role, human and reader sessions (row 109, #1603). `{launch:{state,at,event},tasks:{open:[{task_ref,title}],total,cap}}` for the capability's business. `launch` is the latest `launch.revoked` or `launch.restored` event: `allowed` with `at` and `event` null when none exists. `open` is the tracker snapshot's open tasks in `task_ref` order, at most `cap` (the `tasks` page size, 50); `total` counts every open task. The harness runner reads it before each turn. Writes nothing. | | `replies` | `{id}`: a message UUID; human and reader sessions only. The messages whose `in_reply_to` is that id, oldest first, at most 50. Writes nothing. | | `project.create` | `{name,repo?}`; human sessions only (row 121, #1615). `repo` must name one of the business's `projects` (`unknown-repo`); a second project with the same name refuses `project-exists`. Returns `{id}`. | -| `project.list` | No args; human sessions only. The business's projects, oldest first. | +| `project.list` | No args, or `{includeArchived:true}`; human, tester and user sessions. The business's projects, oldest first, with archived projects hidden unless `includeArchived` asks for them; each returned archived project carries `archived:true`. A user still lists granted projects only. | +| `project.archive` | `{project}`; human only (#797 MVP3). Appends one `project_archives` row (the audit trail: who, when); nothing is deleted. A repeat of the current state refuses `project-already-archived`. Returns `{project,archived:true}`. While archived the project hides from the plain list and refuses new threads and requests (`project-archived` from `thread.create`, `thread.send`, `room.open`, `room.send`); every existing record stays readable. | +| `project.unarchive` | `{project}`; human only (#797 MVP3). Restores the project completely; a project that is not archived refuses `project-not-archived`. Returns `{project,archived:false}`. | | `room.open` | `{project}`; human only. Creates/reuses the caller's canonical member threads, named by sorted `THREAD_DEFINITIONS` instance keys. A taken name/owner/instance refuses `room-thread-name-taken` atomically. Returns `{project,threads:[{id,instance,name}]}`. | | `room.send` | `{project,body}`; human only, room must be fully open (`room-not-open`). Leading whitespace and `@name` tokens (including a final EOF tag) address current display names or member keys; no tags broadcasts, repeats send once. Unknown/ambiguous members refuse `unknown-addressee`/`addressee-ambiguous`; tags-only refuses `invalid-request`. Stores the original body, one normal message/route/human.input per target in one transaction, with one shared message timestamp. Returns `{ids,to}` in member-key order. | | `room.read` | `{project,after?,limit?}`; human only, complete owned room. Oldest first across all sessions of member threads. Returns entries with first-message fields plus `thread`, `session`, `instance`, `ids` and `to`. Only consecutive human rows with equal at/body/class and distinct instances merge; repeated instances split identical sends. `limit` counts merged entries (default 50, 1–200). Continue after the last entry's last `ids` element; an `after` outside the room refuses `message-not-found`. No tester access or schema change. | diff --git a/packages/bus/schema.sql b/packages/bus/schema.sql index 1e4caabdd..0c8fda64c 100644 --- a/packages/bus/schema.sql +++ b/packages/bus/schema.sql @@ -364,6 +364,27 @@ CREATE TRIGGER delivery_same_session BEFORE INSERT ON deliveries CREATE TRIGGER projects_no_update BEFORE UPDATE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END; CREATE TRIGGER projects_no_delete BEFORE DELETE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END; CREATE TRIGGER projects_no_replace BEFORE INSERT ON projects WHEN EXISTS (SELECT 1 FROM projects WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END; +-- #797 MVP3: the owner's reversible project archive. A project's state is the op of its latest +-- row, and the rows are the audit trail (who archived or restored it, and when). The projects +-- table itself stays as written; an archived project keeps every record and is never deleted. +CREATE TABLE project_archives ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + business TEXT NOT NULL, + project TEXT NOT NULL REFERENCES projects(id), + op TEXT NOT NULL CHECK (op IN ('archive','unarchive')), + by TEXT NOT NULL +) STRICT; +CREATE INDEX project_archives_project ON project_archives (business, project, seq); +CREATE TRIGGER project_archives_no_update BEFORE UPDATE ON project_archives BEGIN SELECT RAISE(ABORT, 'project_archives is append-only'); END; +CREATE TRIGGER project_archives_no_delete BEFORE DELETE ON project_archives BEGIN SELECT RAISE(ABORT, 'project_archives is append-only'); END; +CREATE TRIGGER project_archives_no_replace BEFORE INSERT ON project_archives WHEN EXISTS (SELECT 1 FROM project_archives WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'project_archives is append-only'); END; +CREATE TRIGGER project_archives_business BEFORE INSERT ON project_archives + WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business + BEGIN SELECT RAISE(ABORT, 'an archive row belongs to its project''s business'); END; +CREATE TRIGGER project_archives_no_repeat BEFORE INSERT ON project_archives + WHEN (SELECT op FROM project_archives WHERE business = NEW.business AND project = NEW.project ORDER BY seq DESC LIMIT 1) = NEW.op + BEGIN SELECT RAISE(ABORT, 'a project archive row changes state'); END; CREATE TRIGGER threads_no_update BEFORE UPDATE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END; CREATE TRIGGER threads_no_delete BEFORE DELETE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END; CREATE TRIGGER threads_no_replace BEFORE INSERT ON threads WHEN EXISTS (SELECT 1 FROM threads WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END; diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index a98048708..8d4d95a73 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -1408,6 +1408,15 @@ export class Broker { #owner(s) { return s.tester ? 'tester' : s.user ? 'user:' + s.user : s.human; } + // #797 MVP3: a project's archive state, the op of its latest project_archives row. Archived + // projects keep every record and stay readable; they take no new threads or requests. + #archived(business, project) { + return this.#store.get( + 'SELECT op FROM project_archives WHERE business=? AND project=? ORDER BY seq DESC LIMIT 1', + business, + project, + )?.op === 'archive'; + } // FED R3 (design 3.5): the user's live grants in the session's business, from one read of the // access file. Any failure to read leaves none. Row 275: a grant at any level counts here; each // verb's own check asks the level it needs (#granted). @@ -7372,11 +7381,43 @@ export class Broker { } case 'project.list': { if (!s.tester && !s.user) this.#human(s); - keys(a, []); - // FED R3 (design 3.5): a user lists granted projects only. + keys(a, ['includeArchived']); + if (a.includeArchived !== undefined && a.includeArchived !== true) fail('invalid-request'); + // #797 MVP3: archived projects are hidden unless the caller asks for them, and then each + // carries `archived: true`. FED R3 (design 3.5) still trims a user to granted projects. return this.#store - .all('SELECT id,at,name,repo FROM projects WHERE business=? ORDER BY seq', s.business) - .filter((p) => !s.user || this.#granted(s, 'project.list', p.id, null)); + .all( + `SELECT p.id,p.at,p.name,p.repo, + (SELECT pa.op FROM project_archives pa WHERE pa.business=p.business AND pa.project=p.id ORDER BY pa.seq DESC LIMIT 1) AS state + FROM projects p WHERE p.business=? ORDER BY p.seq`, + s.business, + ) + .filter((p) => a.includeArchived === true || p.state !== 'archive') + .filter((p) => !s.user || this.#granted(s, 'project.list', p.id, null)) + .map((p) => (p.state === 'archive' ? { id: p.id, at: p.at, name: p.name, repo: p.repo, archived: true } : { id: p.id, at: p.at, name: p.name, repo: p.repo })); + } + // #797 MVP3 (owner report 2026-11-06): the owner's reversible retirement of a project. The + // human gate is project.create's minus the tester carve-out: tester, user and agent sessions + // are refused here. Each verb appends one project_archives row (the audit trail) and deletes + // nothing; a repeat of the state the project already holds refuses. + case 'project.archive': + case 'project.unarchive': { + this.#human(s); + keys(a, ['project'], ['project']); + const project = identifier(a.project); + const row = this.#store.get('SELECT name FROM projects WHERE id=? AND business=?', project, s.business); + if (!row) fail('project-not-found'); + const archive = verb === 'project.archive'; + if (this.#archived(s.business, project) === archive) fail(archive ? 'project-already-archived' : 'project-not-archived'); + this.#store.run( + 'INSERT INTO project_archives(at,business,project,op,by) VALUES(?,?,?,?,?)', + this.#now(), + s.business, + project, + archive ? 'archive' : 'unarchive', + this.#owner(s), + ); + return { project, archived: archive }; } // C2-09b: canonical per-instance threads form one human-owned project room. case 'room.open': @@ -7389,6 +7430,9 @@ export class Broker { sending ? ['project', 'body'] : ['project']); const project = identifier(a.project); if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', project, s.business)) fail('project-not-found'); + // #797 MVP3: an archived project takes no new room threads and no new requests; reading it + // (room.read) and every existing record stay open. + if ((opening || sending) && this.#archived(s.business, project)) fail('project-archived'); const members = Object.keys(b.roles).filter((instance) => this.#threaded(s.business, instance)).sort(); if (!members.length) fail('thread-instance-missing'); const owner = this.#owner(s); @@ -7489,6 +7533,8 @@ export class Broker { } if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', identifier(a.project), s.business)) fail('project-not-found'); + // #797 MVP3: an archived project refuses new threads (and the room's requests, above). + if (this.#archived(s.business, identifier(a.project))) fail('project-archived'); // The human selects an instance; omission is compatible only with one candidate. if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing'); if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous'); @@ -7547,6 +7593,9 @@ export class Broker { if (!s.tester && !s.user) this.#human(s); keys(a, ['thread', 'session', 'body', 'class'], ['thread', 'body']); const t = this.#grantedThread(s, 'thread.send', this.#thread(s, a.thread)); + // #797 MVP3: an archived project refuses new requests; the thread and its messages stay + // readable (thread.list, thread.messages). + if (this.#archived(s.business, t.project)) fail('project-archived'); const session = this.#threadSession(t, a.session); // Row 271 (264d, D7 step 1): nor sent to, by name or as the default. this.#sessionFence(session); diff --git a/packages/bus/src/migrations/3s-3t.sql b/packages/bus/src/migrations/3s-3t.sql new file mode 100644 index 000000000..3d32ff387 --- /dev/null +++ b/packages/bus/src/migrations/3s-3t.sql @@ -0,0 +1,21 @@ +-- #797 MVP3: the owner's reversible project archive. A project's state is the op of its latest +-- row, and the rows are the audit trail (who archived or restored it, and when). The projects +-- table itself stays as written; an archived project keeps every record and is never deleted. +CREATE TABLE project_archives ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + business TEXT NOT NULL, + project TEXT NOT NULL REFERENCES projects(id), + op TEXT NOT NULL CHECK (op IN ('archive','unarchive')), + by TEXT NOT NULL +) STRICT; +CREATE INDEX project_archives_project ON project_archives (business, project, seq); +CREATE TRIGGER project_archives_no_update BEFORE UPDATE ON project_archives BEGIN SELECT RAISE(ABORT, 'project_archives is append-only'); END; +CREATE TRIGGER project_archives_no_delete BEFORE DELETE ON project_archives BEGIN SELECT RAISE(ABORT, 'project_archives is append-only'); END; +CREATE TRIGGER project_archives_no_replace BEFORE INSERT ON project_archives WHEN EXISTS (SELECT 1 FROM project_archives WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'project_archives is append-only'); END; +CREATE TRIGGER project_archives_business BEFORE INSERT ON project_archives + WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business + BEGIN SELECT RAISE(ABORT, 'an archive row belongs to its project''s business'); END; +CREATE TRIGGER project_archives_no_repeat BEFORE INSERT ON project_archives + WHEN (SELECT op FROM project_archives WHERE business = NEW.business AND project = NEW.project ORDER BY seq DESC LIMIT 1) = NEW.op + BEGIN SELECT RAISE(ABORT, 'a project archive row changes state'); END; diff --git a/packages/bus/src/store.mjs b/packages/bus/src/store.mjs index 099f5c5ab..79fbf999d 100644 --- a/packages/bus/src/store.mjs +++ b/packages/bus/src/store.mjs @@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path'; import { createHash } from 'node:crypto'; const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8'); -export const SCHEMA_SHA256 = 'fde7a46505a964d0e3802aaee96a1e2d9fb8c7163fbafc2f86aca914ecc50f03'; -export const SCHEMA_VERSION = '3s'; +export const SCHEMA_SHA256 = '1cec88b0b697c648bcab18b45f86d85526aa1701a7619046367e16c38476aee8'; +export const SCHEMA_VERSION = '3t'; // Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned. const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8'); const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65'; @@ -107,6 +107,11 @@ const migrations = [{ // Task 6b: checked advancement after Task 3's complete fifteen-kind union. from: '3r', to: '3s', sql: readFileSync(new URL('./migrations/3r-3s.sql', import.meta.url), 'utf8'), +}, { + // #797 MVP3: the owner's reversible project archive. Only a new append-only table, its index + // and its guards; no existing row changes and nothing is deleted. + from: '3s', to: '3t', + sql: readFileSync(new URL('./migrations/3s-3t.sql', import.meta.url), 'utf8'), }]; const timedTables = [ 'events', @@ -132,6 +137,7 @@ const timedTables = [ 'request_snapshots', 'request_snapshot_events', 'request_project_events', + 'project_archives', ]; const hex = (value) => createHash('sha256').update(value).digest('hex'); if (hex(ddl) !== SCHEMA_SHA256) throw Error('schema-source-mismatch'); diff --git a/packages/bus/tests/account-migration.test.mjs b/packages/bus/tests/account-migration.test.mjs index 79725d73c..36e06c7e0 100644 --- a/packages/bus/tests/account-migration.test.mjs +++ b/packages/bus/tests/account-migration.test.mjs @@ -19,7 +19,7 @@ const here = (p) => new URL(p, import.meta.url); const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens']; // Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it. // Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them. -const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name); +const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name); function source3h(t) { const root = mkdtempSync(join(tmpdir(), 'account-migrate-')); t.after(() => rmSync(root, { recursive: true, force: true })); @@ -93,6 +93,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) { copyFileSync(here('../src/migrations/3p-3q.sql'), join(moduleRoot, 'src/migrations/3p-3q.sql')); copyFileSync(here('../src/migrations/3q-3r.sql'), join(moduleRoot, 'src/migrations/3q-3r.sql')); copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql')); + copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql')); return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store; } @@ -100,7 +101,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence const { root, path } = source3h(t), before = snapshot(path), was = master(path); assert.throws(() => new Store(root), /schema-mismatch/); new Store(root, { migrateSchema: true }).close(); - assert.equal(version(path), '3s'); + assert.equal(version(path), '3t'); // Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50. assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows); assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]); @@ -150,7 +151,7 @@ test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with assert.deepEqual(snapshot(path, before.columns).rows, before.rows); // Still a whole 3h store: the real step migrates it. new Store(root, { migrateSchema: true }).close(); - assert.equal(version(path), '3s'); + assert.equal(version(path), '3t'); assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows); }); @@ -162,14 +163,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and schema: ddl3h, store: (source) => source .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`) - .replace("export const SCHEMA_VERSION = '3s';", "export const SCHEMA_VERSION = '3h';") + .replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3h';") .replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'), }); const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8'); assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step'); assert.throws(() => new Store3h(root), /schema-mismatch/); assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/); - assert.equal(version(path), '3s'); + assert.equal(version(path), '3t'); assert.deepEqual(master(path), was); assert.deepEqual(snapshot(path, after.columns).rows, after.rows); }); diff --git a/packages/bus/tests/chief-of-staff.test.mjs b/packages/bus/tests/chief-of-staff.test.mjs index f3ddf513a..725fa13d5 100644 --- a/packages/bus/tests/chief-of-staff.test.mjs +++ b/packages/bus/tests/chief-of-staff.test.mjs @@ -614,7 +614,7 @@ async function database3c(t) { let source = readFileSync(here('../src/store.mjs'), 'utf8'); const pinned = source .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`) - .replace("export const SCHEMA_VERSION = '3s';", "export const SCHEMA_VERSION = '3c';") + .replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3c';") .replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'); assert.notEqual(pinned, source); assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step'); @@ -638,7 +638,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a assert.throws(() => new Store(root), /schema-mismatch/); const store = new Store(root, { migrateSchema: true }); t.after(() => store.close()); - assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3s'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t'); assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}'); assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run'); const b = new Broker({ store, businesses: demo(), organization: null }); diff --git a/packages/bus/tests/comment-migration.test.mjs b/packages/bus/tests/comment-migration.test.mjs index 6445e50df..50db455ef 100644 --- a/packages/bus/tests/comment-migration.test.mjs +++ b/packages/bus/tests/comment-migration.test.mjs @@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star 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, '3s'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t'); 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; @@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver 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 = '3s';", "export const SCHEMA_VERSION = '3t';") - .replace('}];\nconst timedTables', `}, { from: '3s', to: '3t', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`); + .replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3u';") + .replace('}];\nconst timedTables', `}, { from: '3t', to: '3u', 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', '3r-3s.sql']) + 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', '3r-3s.sql', '3s-3t.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, '3t'); + assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3u'); 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'); @@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage 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 3s ready/); + assert.match(migrated.stdout, /bus schema 3t ready/); new Store(root).close(); const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' }); assert.equal(usage.status, 2); diff --git a/packages/bus/tests/gitea-migration.test.mjs b/packages/bus/tests/gitea-migration.test.mjs index 3b5af2235..bcb2f2f2a 100644 --- a/packages/bus/tests/gitea-migration.test.mjs +++ b/packages/bus/tests/gitea-migration.test.mjs @@ -36,11 +36,11 @@ function source3j(t) { const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens']; // Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it. // Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them. -const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name); +const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name); function rows(path) { const db = new DatabaseSync(path, { readOnly: true }); try { - return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events')").all() + return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives')").all() .map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all() .filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))])); } finally { db.close(); } @@ -83,6 +83,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) { copyFileSync(here('../src/migrations/3p-3q.sql'), join(moduleRoot, 'src/migrations/3p-3q.sql')); copyFileSync(here('../src/migrations/3q-3r.sql'), join(moduleRoot, 'src/migrations/3q-3r.sql')); copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql')); + copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql')); return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store; } function fresh(t) { @@ -104,7 +105,7 @@ test('3j copy migrates to 3k: every row kept, and sqlite_master gains only the t const { root, path } = source3j(t), before = rows(path), was = master(path); assert.throws(() => new Store(root), /schema-mismatch/); new Store(root, { migrateSchema: true }).close(); - assert.equal(version(path), '3s'); + assert.equal(version(path), '3t'); assert.deepEqual(migrated3l(rows(path)), before); const now = master(path); assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r))); @@ -130,7 +131,7 @@ test('a 3j→3k step that fails partway leaves the store at 3j with every row an assert.deepEqual(master(path), was); assert.deepEqual(rows(path), before); new Store(root, { migrateSchema: true }).close(); - assert.equal(version(path), '3s'); + assert.equal(version(path), '3t'); }); test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => { @@ -141,14 +142,14 @@ test('the 3j engine refuses a migrated store, migrating or not, and leaves it as schema: ddl3j, store: (source) => source .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`) - .replace("export const SCHEMA_VERSION = '3s';", "export const SCHEMA_VERSION = '3j';") + .replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3j';") .replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'), }); const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8'); assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step'); assert.throws(() => new Store3j(root), /schema-mismatch/); assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/); - assert.equal(version(path), '3s'); + assert.equal(version(path), '3t'); assert.deepEqual(master(path), was); assert.deepEqual(rows(path), after); }); diff --git a/packages/bus/tests/grant-migration.test.mjs b/packages/bus/tests/grant-migration.test.mjs index 1764fcd79..fd75e9bf2 100644 --- a/packages/bus/tests/grant-migration.test.mjs +++ b/packages/bus/tests/grant-migration.test.mjs @@ -22,7 +22,7 @@ const LATER = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1', const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens']; // Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it. // Row 271 (#1776): and the 3m→3n step's session_ops and message_cancels. session-clear.test.mjs tests them. -const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name); +const later3l = (r) => [...SESSIONS_3L, 'thread_shares', 'session_ops', 'message_cancels', 'project_archives', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name); function source3i(t) { const root = mkdtempSync(join(tmpdir(), 'grant-migrate-')); t.after(() => rmSync(root, { recursive: true, force: true })); @@ -42,7 +42,7 @@ function source3i(t) { function rows(path) { const db = new DatabaseSync(path, { readOnly: true }); try { - return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events')").all() + return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events','project_archives')").all() .map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all() .filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))])); } finally { db.close(); } @@ -86,6 +86,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) { copyFileSync(here('../src/migrations/3p-3q.sql'), join(moduleRoot, 'src/migrations/3p-3q.sql')); copyFileSync(here('../src/migrations/3q-3r.sql'), join(moduleRoot, 'src/migrations/3q-3r.sql')); copyFileSync(here('../src/migrations/3r-3s.sql'), join(moduleRoot, 'src/migrations/3r-3s.sql')); + copyFileSync(here('../src/migrations/3s-3t.sql'), join(moduleRoot, 'src/migrations/3s-3t.sql')); return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store; } function fresh(t) { @@ -109,7 +110,7 @@ test('3i copy migrates to 3j: every row kept, and sqlite_master gains only the t const { root, path } = source3i(t), before = rows(path), was = master(path); assert.throws(() => new Store(root), /schema-mismatch/); new Store(root, { migrateSchema: true }).close(); - assert.equal(version(path), '3s'); + assert.equal(version(path), '3t'); assert.deepEqual(migrated3l(rows(path)), before); const now = master(path).filter(r => !LATER.includes(r.name)); assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r))); @@ -134,7 +135,7 @@ test('a 3i→3j step that fails partway leaves the store at 3i with every row an assert.deepEqual(master(path), was); assert.deepEqual(rows(path), before); new Store(root, { migrateSchema: true }).close(); - assert.equal(version(path), '3s'); + assert.equal(version(path), '3t'); }); test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => { @@ -145,14 +146,14 @@ test('the 3i engine refuses a migrated store, migrating or not, and leaves it as schema: ddl3i, store: (source) => source .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3i).digest('hex')}';`) - .replace("export const SCHEMA_VERSION = '3s';", "export const SCHEMA_VERSION = '3i';") + .replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3i';") .replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'), }); const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8'); assert.ok(pinned.includes("export const SCHEMA_VERSION = '3i';") && !pinned.includes('3i-3j'), 'the 3i engine has no 3i→3j step'); assert.throws(() => new Store3i(root), /schema-mismatch/); assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/); - assert.equal(version(path), '3s'); + assert.equal(version(path), '3t'); assert.deepEqual(master(path), was); assert.deepEqual(rows(path), after); }); diff --git a/packages/bus/tests/name-migration.test.mjs b/packages/bus/tests/name-migration.test.mjs index 12deca6c5..7fb2f449f 100644 --- a/packages/bus/tests/name-migration.test.mjs +++ b/packages/bus/tests/name-migration.test.mjs @@ -65,7 +65,7 @@ test('3f copy migrates to 3g preserving every old column/row, with an empty inst assert.throws(() => new Store(root), /schema-mismatch/); const store = new Store(root, { migrateSchema: true }); try { - assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3s'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t'); assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null }); assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0); assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) }); diff --git a/packages/bus/tests/prd-activation.test.mjs b/packages/bus/tests/prd-activation.test.mjs index 2c3265f08..5064e7a07 100644 --- a/packages/bus/tests/prd-activation.test.mjs +++ b/packages/bus/tests/prd-activation.test.mjs @@ -196,6 +196,8 @@ test('3p through 3s migration preserves existing request rows and the append-onl const { Store, schemaDigest } = await import('../src/store.mjs'); const root = tmp(t, 'prd-migration-'); mkdirSync(join(root, 'bus'), { mode: 0o700 }); const path = join(root, 'bus/bus.sqlite'), ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8') + // 3s-3t (#797) adds project_archives; the 3p fixture predates it and the replayed chain recreates it. + .replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '') .replace(",'prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced'", ''); const db = new DatabaseSync(path); db.exec(ddl); const at = '2026-11-03T00:00:00.000Z'; @@ -211,7 +213,7 @@ test('3p through 3s migration preserves existing request rows and the append-onl assert.throws(() => new Store(root), /schema-mismatch/); const store = new Store(root, { migrateSchema: true }); t.after(() => store.close()); assert.deepEqual(store.all('SELECT * FROM request_events'), before); - assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3s'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t'); store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',2,'prd.submitted',?,'{}')", at); assert.throws(() => store.run('DELETE FROM request_events'), /append-only/); assert.throws(() => store.run("UPDATE request_events SET body='{}'"), /append-only/); diff --git a/packages/bus/tests/project-archive.test.mjs b/packages/bus/tests/project-archive.test.mjs new file mode 100644 index 000000000..e8ec11e80 --- /dev/null +++ b/packages/bus/tests/project-archive.test.mjs @@ -0,0 +1,225 @@ +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: 'continuity', delivery: 'continuity' }, + 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'); + 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'); +const ddl3s = currentDDL.replace(migrationSQL, ''); + +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), '3t'); + 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 = '3t';", "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), '3t'); + 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(); +}); diff --git a/packages/bus/tests/request-console.test.mjs b/packages/bus/tests/request-console.test.mjs index 75d998427..b4c133914 100644 --- a/packages/bus/tests/request-console.test.mjs +++ b/packages/bus/tests/request-console.test.mjs @@ -302,13 +302,16 @@ test('a cancel is refused when the owner-to-PM edge closed, with no withdrawn de }); test('3q through 3s widens the kind list to the union, keeps every 3q kind, row, seq and guard, and the chain matches the fresh digest', t => { - assert.equal(SCHEMA_VERSION, '3s'); + assert.equal(SCHEMA_VERSION, '3t'); const root = mkdtempSync(join(tmpdir(), 'request-migration-3r-')); t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 }); const path = join(root, 'bus/bus.sqlite'), AT = '2026-11-03T00:00:00.000Z'; const current = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8'); + // 3s-3t (#797) adds project_archives, outside request_events; the 3q fixture predates it and + // the replayed chain recreates it, so the step's text is removed here like the kinds are. + const step3t = readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'); // 3p-3q shipped the PRD kinds and mode.changed; 3q-3r adds the three Task 3 kinds after them. - const ddl3q = current.replace(",'advanced'", '').replace("'prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'prd.invalidated','mode.changed','cancelled'"); + const ddl3q = current.replace(step3t, '').replace(",'advanced'", '').replace("'prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'", "'prd.invalidated','mode.changed','cancelled'"); assert.notEqual(ddl3q, current, 'the fixture is the narrower schema'); const migration = readFileSync(new URL('../src/migrations/3q-3r.sql', import.meta.url), 'utf8'); assert.ok(migration.includes("'prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','cancelled'"), 'the step carries every 3q kind plus its own'); @@ -325,7 +328,7 @@ test('3q through 3s widens the kind list to the union, keeps every 3q kind, row, db.close(); chmodSync(path, 0o600); assert.throws(() => new Store(root), /schema-mismatch/); const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' }); - assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3s ready\n'); + assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3t ready\n'); const store = new Store(root); assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before, 'every row keeps its seq and bytes, including the 3q kinds'); const fresh = new DatabaseSync(':memory:'); fresh.exec(current); diff --git a/packages/bus/tests/request-continuation.test.mjs b/packages/bus/tests/request-continuation.test.mjs index 63563bdf7..dcef001d8 100644 --- a/packages/bus/tests/request-continuation.test.mjs +++ b/packages/bus/tests/request-continuation.test.mjs @@ -170,7 +170,10 @@ test('3r to 3s migration preserves journal and sequence and admits only the new const root = tmp(t, 'continuation-migration-'); mkdirSync(join(root, 'bus'), { mode: 0o700 }); const path = join(root, 'bus/bus.sqlite'); const db = new DatabaseSync(path); - db.exec(readFileSync(new URL('../schema.sql', import.meta.url), 'utf8').replace(",'advanced'", '')); + // 3s-3t (#797) adds project_archives; the 3r fixture predates it and the replayed chain recreates it. + db.exec(readFileSync(new URL('../schema.sql', import.meta.url), 'utf8') + .replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '') + .replace(",'advanced'", '')); db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3r'); db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest', schemaDigest(db)); db.prepare('INSERT INTO meta VALUES(?,?)').run('comment_cursor_epoch', '2026-11-04T00:00:00.000Z'); @@ -190,7 +193,7 @@ test('3r to 3s migration preserves journal and sequence and admits only the new assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before); assert.deepEqual(store.all("SELECT name,sql FROM sqlite_master WHERE type='trigger' AND tbl_name IN ('request_events','request_effects') ORDER BY name"), guards); assert.equal(store.get("SELECT name FROM sqlite_master WHERE type='index' AND name='request_events_request'").name, 'request_events_request'); - assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3s'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t'); store.run("INSERT INTO request_events(business,request,revision,kind,at,body) VALUES('demo','r',16,'advanced',?,'{}')", at); assert.equal(store.get("SELECT max(seq) n FROM request_events").n, 42); assert.throws(() => store.run("UPDATE request_events SET kind='opened'"), /append-only/); diff --git a/packages/bus/tests/request-intake.test.mjs b/packages/bus/tests/request-intake.test.mjs index 8a56a4ca7..9f739678d 100644 --- a/packages/bus/tests/request-intake.test.mjs +++ b/packages/bus/tests/request-intake.test.mjs @@ -274,7 +274,11 @@ test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_sch const root = mkdtempSync(join(tmpdir(), 'request-migration-')); t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 }); // Tasks 2, 3, 4 and 6b (3o→3p, 3p→3q, 3q→3r, 3r→3s) widen only request_events.kind, so the 3n schema is the current one with the old list. - const path = join(root, 'bus/bus.sqlite'), currentDDL = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8').replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled'", "'setup.wait','cancelled'"); + const path = join(root, 'bus/bus.sqlite'), currentDDL = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8') + // 3s-3t (#797) adds project_archives after the request tables; the pre-3t fixtures below + // predate it and the replayed chain recreates it, so the step's text comes out here too. + .replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '') + .replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled'", "'setup.wait','cancelled'"); const newDDL = readFileSync(new URL('../src/migrations/3n-3o.sql', import.meta.url), 'utf8'); assert.ok(currentDDL.endsWith(newDDL)); const oldDDL = currentDDL.slice(0, -newDDL.length); const { schemaDigest, SCHEMA_VERSION } = awaitImportStore; @@ -286,7 +290,7 @@ test('3n_to_3o_is_explicit_offline_preserves_rows_and_old_engine_refuses_new_sch db.close(); chmodSync(path, 0o600); const before = readFileSync(path); assert.throws(() => new Store(root), /schema-mismatch/); assert.deepEqual(readFileSync(path), before); const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' }); - assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3s ready\n'); assert.equal(SCHEMA_VERSION, '3s'); + assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3t ready\n'); assert.equal(SCHEMA_VERSION, '3t'); const store = new Store(root); assert.equal(store.get("SELECT id FROM events").id, 'preserved'); for (const table of ['request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events']) assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0); store.close(); assert.equal(spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root]).status, 0); @@ -307,7 +311,9 @@ test('3o_to_3p_widens_only_the_event_kinds_and_keeps_rows_seq_and_guards', t => t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 }); const path = join(root, 'bus/bus.sqlite'); // The 3o schema is the current one with the narrower kind list. - const ddl3o = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8').replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled'", "'setup.wait','cancelled'"); + const ddl3o = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8') + .replace(readFileSync(new URL('../src/migrations/3s-3t.sql', import.meta.url), 'utf8'), '') + .replace("'setup.wait','bootstrap.stale','bootstrap.completed','prd.submitted','prd.activated','prd.invalidated','question.asked','question.answered','attach.selected','mode.changed','advanced','cancelled'", "'setup.wait','cancelled'"); const { schemaDigest } = awaitImportStore, AT = '2026-11-03T00:00:00.000Z'; const db = new DatabaseSync(path); db.exec(ddl3o); db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3o'); @@ -323,7 +329,7 @@ test('3o_to_3p_widens_only_the_event_kinds_and_keeps_rows_seq_and_guards', t => db.close(); chmodSync(path, 0o600); assert.throws(() => new Store(root), /schema-mismatch/); const cli = spawnSync(process.execPath, [new URL('../src/migrate-schema.mjs', import.meta.url).pathname, root], { encoding: 'utf8' }); - assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3s ready\n'); + assert.equal(cli.status, 0, cli.stderr); assert.equal(cli.stdout, 'bus schema 3t ready\n'); const store = new Store(root); assert.deepEqual(store.all('SELECT * FROM request_events ORDER BY seq'), before, 'every row keeps its seq and bytes'); assert.deepEqual(store.all('SELECT * FROM request_effects ORDER BY seq'), effects); diff --git a/packages/bus/tests/schema.test.mjs b/packages/bus/tests/schema.test.mjs index 687bcfb66..cf20c95cc 100644 --- a/packages/bus/tests/schema.test.mjs +++ b/packages/bus/tests/schema.test.mjs @@ -525,8 +525,9 @@ test('v3b prototype refusals, views and append-only mutations', (t) => { // Row 206 (#1704) adds thread_shares and its 3 append-only triggers; thread-shares.test.mjs tests them. // Row 271 (#1776) adds session_ops, message_cancels and 7 triggers; session-clear.test.mjs tests them. // #797 / #1839 Task 1 adds five append-only lifecycle tables and eighteen guards. - // Task 4 (3p to 3q), Task 3 (3q to 3r) and Task 6b (3r to 3s) widen request_events.kind in place; all 30 tables and 114 guards remain. - assert.equal(count('table') - 1, 30); - assert.equal(count('trigger'), 114); + // Task 4 (3p to 3q), Task 3 (3q to 3r) and Task 6b (3r to 3s) widen request_events.kind in place. + // #797 MVP3 (3s to 3t) adds project_archives and its five guards; project-archive.test.mjs tests them. + assert.equal(count('table') - 1, 31); + assert.equal(count('trigger'), 119); assert.equal(count('view'), 5); }); diff --git a/packages/bus/tests/session-clear.test.mjs b/packages/bus/tests/session-clear.test.mjs index ffa109cd2..ceb68862c 100644 --- a/packages/bus/tests/session-clear.test.mjs +++ b/packages/bus/tests/session-clear.test.mjs @@ -530,9 +530,9 @@ test('T4 schema: a 3m store migrates to 3n, session_ops and message_cancels are const before = tables(path); assert.throws(() => new Store(root), /schema-mismatch/); new Store(root, { migrateSchema: true }).close(); - assert.equal(version(path), '3s'); + assert.equal(version(path), '3t'); const after = tables(path); - for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events']) { + for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives']) { assert.deepEqual(after[name], [], name); delete after[name]; } @@ -571,7 +571,7 @@ async function engine3m(root) { 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(ddl3m).digest('hex')}';`) - .replace("export const SCHEMA_VERSION = '3s';", "export const SCHEMA_VERSION = '3m';") + .replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3m';") .replace(/\}, \{\n {2}\/\/ Row 271 \(#1776\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'); assert.ok(edited.includes("export const SCHEMA_VERSION = '3m';") && edited.includes('3l-3m') && !edited.includes('3m-3n'), 'the 3m engine has no 3m→3n step'); writeFileSync(join(moduleRoot, 'src/store.mjs'), edited); @@ -588,7 +588,7 @@ test('T4 schema: the 3m engine refuses a 3n store, migrating or not, and leaves const Store3m = await engine3m(root); assert.throws(() => new Store3m(root), /schema-mismatch/); assert.throws(() => new Store3m(root, { migrateSchema: true }), /schema-mismatch/); - assert.equal(version(path), '3s'); + assert.equal(version(path), '3t'); assert.deepEqual(master(path), was); assert.deepEqual(tables(path), before); // The same engine opens a fresh 3m store, so the refusal is the version's and not a broken copy. diff --git a/packages/bus/tests/sessions.test.mjs b/packages/bus/tests/sessions.test.mjs index 8c6e8a0dc..2f652b65f 100644 --- a/packages/bus/tests/sessions.test.mjs +++ b/packages/bus/tests/sessions.test.mjs @@ -374,7 +374,7 @@ test('T7: a 3k store migrates to 3l, each thread\'s default session has the thre const { root, path } = source3k(t); assert.throws(() => new Store(root), /schema-mismatch/); new Store(root, { migrateSchema: true }).close(); - assert.equal(version(path), '3s'); + assert.equal(version(path), '3t'); // The migrated schema is the fresh one, object for object. const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-')); t.after(() => rmSync(freshRoot, { recursive: true, force: true })); @@ -430,7 +430,7 @@ async function engine3k(root) { 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(ddl3k).digest('hex')}';`) - .replace("export const SCHEMA_VERSION = '3s';", "export const SCHEMA_VERSION = '3k';") + .replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3k';") .replace(/\}, \{\n {2}\/\/ Row 264a \(#1769\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'); assert.ok(edited.includes("export const SCHEMA_VERSION = '3k';") && edited.includes('3j-3k') && !edited.includes('3k-3l'), 'the 3k engine has no 3k→3l step'); writeFileSync(join(moduleRoot, 'src/store.mjs'), edited); @@ -447,7 +447,7 @@ test('T7 D9: the 3k engine refuses a 3l store, migrating or not, and leaves it a const Store3k = await engine3k(root); assert.throws(() => new Store3k(root), /schema-mismatch/); assert.throws(() => new Store3k(root, { migrateSchema: true }), /schema-mismatch/); - assert.equal(version(path), '3s'); + assert.equal(version(path), '3t'); assert.deepEqual(master(path), was); assert.deepEqual(rows(path), before); // The same engine opens a fresh 3k store, so the refusal is the version's and not a broken copy. diff --git a/packages/bus/tests/spec-migration.test.mjs b/packages/bus/tests/spec-migration.test.mjs index 038995612..1711ce3d5 100644 --- a/packages/bus/tests/spec-migration.test.mjs +++ b/packages/bus/tests/spec-migration.test.mjs @@ -63,7 +63,7 @@ test('3e copy migrates through 3f to 3g preserving every old column/row and addi assert.throws(() => new Store(root), /schema-mismatch/); const store = new Store(root, { migrateSchema: true }); try { - assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3s'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t'); assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null }); assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok'); assert.deepEqual(store.all('PRAGMA foreign_key_check'), []); diff --git a/packages/bus/tests/thread-migration.test.mjs b/packages/bus/tests/thread-migration.test.mjs index cc76c439a..7890c4aa2 100644 --- a/packages/bus/tests/thread-migration.test.mjs +++ b/packages/bus/tests/thread-migration.test.mjs @@ -49,7 +49,7 @@ async function database3d(t) { const source = readFileSync(here('../src/store.mjs'), 'utf8'); const pinned = source .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`) - .replace("export const SCHEMA_VERSION = '3s';", "export const SCHEMA_VERSION = '3d';") + .replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3d';") .replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'); assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step'); writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned); @@ -101,7 +101,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l assert.throws(() => new Store(root), /schema-mismatch/); const store = new Store(root, { migrateSchema: true }); t.after(() => store.close()); - assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3s'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t'); for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens']) assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table); // Parked: the undelivered ones are counted per business and role, and none is changed or given a thread. @@ -161,5 +161,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th 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.equal(migrated.stdout, [...REPORT, 'bus schema 3s ready', ''].join('\n')); + assert.equal(migrated.stdout, [...REPORT, 'bus schema 3t ready', ''].join('\n')); }); diff --git a/packages/bus/tests/thread-shares.test.mjs b/packages/bus/tests/thread-shares.test.mjs index a7d9cf808..f72fa49fa 100644 --- a/packages/bus/tests/thread-shares.test.mjs +++ b/packages/bus/tests/thread-shares.test.mjs @@ -403,12 +403,12 @@ test('a 3l store migrates to 3m: thread_shares is new and empty, and every other const before = tables(path); assert.throws(() => new Store(root), /schema-mismatch/); new Store(root, { migrateSchema: true }).close(); - assert.equal(version(path), '3s'); + assert.equal(version(path), '3t'); const after = tables(path); assert.deepEqual(after.thread_shares, []); delete after.thread_shares; // Row 271 (#1776): the 3m→3n step's session_ops and message_cancels, new and empty too. - for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events']) { + for (const name of ['session_ops', 'message_cancels', 'request_events', 'request_effects', 'request_snapshots', 'request_snapshot_events', 'request_project_events', 'project_archives']) { assert.deepEqual(after[name], [], name); delete after[name]; } @@ -439,7 +439,7 @@ async function engine3l(root) { 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(ddl3l).digest('hex')}';`) - .replace("export const SCHEMA_VERSION = '3s';", "export const SCHEMA_VERSION = '3l';") + .replace("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3l';") .replace(/\}, \{\n {2}\/\/ Row 206 \(193b2[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'); assert.ok(edited.includes("export const SCHEMA_VERSION = '3l';") && edited.includes('3k-3l') && !edited.includes('3l-3m'), 'the 3l engine has no 3l→3m step'); writeFileSync(join(moduleRoot, 'src/store.mjs'), edited); @@ -456,7 +456,7 @@ test('the 3l engine refuses a 3m store, migrating or not, and leaves it as it wa const Store3l = await engine3l(root); assert.throws(() => new Store3l(root), /schema-mismatch/); assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/); - assert.equal(version(path), '3s'); + assert.equal(version(path), '3t'); assert.deepEqual(master(path), was); assert.deepEqual(tables(path), before); // The same engine opens a fresh 3l store, so the refusal is the version's and not a broken copy. diff --git a/packages/bus/tests/usage-migration.test.mjs b/packages/bus/tests/usage-migration.test.mjs index c9aa5cb05..17c289b97 100644 --- a/packages/bus/tests/usage-migration.test.mjs +++ b/packages/bus/tests/usage-migration.test.mjs @@ -68,7 +68,7 @@ test('REQ-LIMIT-4 3g copy migrates through 3h, 3i, 3j and 3k to 3l preserving ev assert.throws(() => new Store(root), /schema-mismatch/); const store = new Store(root, { migrateSchema: true }); try { - assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3s'); + assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3t'); assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null }); assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 0); assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder'); diff --git a/packages/cli/README.md b/packages/cli/README.md index bbe177f50..c2056c975 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -33,7 +33,9 @@ mosaic trail [--business ] [--json] mosaic talk [--wait | --follow] [--business ] mosaic follow [--business ] mosaic project create [--repo ] [--business ] [--tester] -mosaic project list [--business ] [--json] [--tester] +mosaic project list [--archived] [--business ] [--json] [--tester] +mosaic project archive [--business ] +mosaic project unarchive [--business ] mosaic threads [--project ] [--business ] [--json] [--tester] mosaic thread create [--business ] [--tester] mosaic thread open [--business ] [--tester] @@ -99,7 +101,11 @@ mosaic launches list [--json] the bus record; `project init` and `project check` (`docs/TOOLS.md`, "Local project records") work on the repository on disk and keep their arguments. `project list` prints name, repo (`-` for none) and the first 8 characters of - the id. `threads` prints `/`, the instance and the short id of + the id; `--archived` includes archived projects, each marked `archived`. + `project archive ` (#797) is the owner's reversible retirement of + a project: it hides the project, refuses its new threads and messages, deletes + nothing, and `project unarchive ` restores it (resolution reads + the archived list too, so an archived project can be named). `threads` prints `/`, the instance and the short id of the caller's threads, optionally for one project. `thread create` prints the id and the instance the broker chose; `thread open` writes the open row the launcher reads; `thread read` pages the thread's messages oldest first and diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index 7c3424b08..b5be070cb 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -639,7 +639,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin, if (argv.length === 0) return app(io, deps); const [verb, ...rest] = argv; if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps); - if (verb === "project") return ["create", "list"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps)); + if (verb === "project") return ["create", "list", "archive", "unarchive"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps)); if (verb === "threads") return threadsCommand(rest, io, (p) => channelCallFor(p, io, deps)); if (verb === "session") return sessionCommand(rest, io, (p) => channelCallFor(p, io, deps)); if (verb === "thread") return threadCommand(rest, io, (p) => channelCallFor(p, io, deps), formatMessage); diff --git a/packages/cli/src/project.mjs b/packages/cli/src/project.mjs index 76cb3e6c4..6c3293973 100644 --- a/packages/cli/src/project.mjs +++ b/packages/cli/src/project.mjs @@ -12,17 +12,23 @@ export const PROJECT_USAGE = 'mosaic project init [--tester // init --tester (row 175, #1673) binds the tester once before it writes. export const PROJECT_BUS_USAGE = [ 'mosaic project create [--repo ] [--business ] [--tester]', - 'mosaic project list [--business ] [--json] [--tester]', + 'mosaic project list [--archived] [--business ] [--json] [--tester]', + 'mosaic project archive [--business ]', + 'mosaic project unarchive [--business ]', 'A thread project named for a business repository must carry that repo: --repo .', + 'list --archived includes archived projects, each marked; archive is reversible and deletes nothing.', ].join('\n '); export async function projectBusCommand(args, io, callFor) { const [sub, ...rest] = args; const parsed = parseArgs(rest, PROJECT_BUS_USAGE, sub === 'create' ? { flags: ['--tester'], values: ['--repo', '--business'] } - : { flags: ['--json', '--tester'], values: ['--business'] }); - if (parsed.positional.length !== (sub === 'create' ? 1 : 0)) - throw new CliError(`project ${sub} takes ${sub === 'create' ? 'one name' : 'no arguments'}\n${PROJECT_BUS_USAGE}`, 4); + : sub === 'list' + ? { flags: ['--json', '--tester', '--archived'], values: ['--business'] } + : { flags: [], values: ['--business'] }); + const one = sub === 'create' || sub === 'archive' || sub === 'unarchive'; + if (parsed.positional.length !== (one ? 1 : 0)) + throw new CliError(`project ${sub} takes ${one ? (sub === 'create' ? 'one name' : 'one name or id') : 'no arguments'}\n${PROJECT_BUS_USAGE}`, 4); const call = callFor(parsed); if (sub === 'create') { const [name] = parsed.positional; @@ -36,9 +42,25 @@ export async function projectBusCommand(args, io, callFor) { } return io.stdout.write(`created project ${name} id ${made.id}\n`); } - const projects = await call('project.list'); + // #797 MVP3: the owner's reversible archive. Resolution reads the full list, archived included, + // so an archived project can be named for unarchive. + if (sub === 'archive' || sub === 'unarchive') { + const [ref] = parsed.positional; + const projects = await call('project.list', { includeArchived: true }); + const hit = projects.find(p => p.id === ref) ?? projects.find(p => p.name === ref); + if (!hit) throw new CliError(`no project is named or numbered ${JSON.stringify(ref)}; see mosaic project list`, 2); + try { + await call(`project.${sub}`, { project: hit.id }); + } catch (error) { + if (error.code === 'project-already-archived') throw new CliError(`project ${hit.name} is already archived`, 2); + if (error.code === 'project-not-archived') throw new CliError(`project ${hit.name} is not archived`, 2); + throw error; + } + return io.stdout.write(`${sub === 'archive' ? 'archived' : 'unarchived'} project ${hit.name} id ${shortId(hit.id)}\n`); + } + const projects = await call('project.list', parsed.flags.has('--archived') ? { includeArchived: true } : {}); if (parsed.flags.has('--json')) return io.stdout.write(`${JSON.stringify(projects, null, 2)}\n`); - io.stdout.write(projects.length === 0 ? 'no projects\n' : projects.map(p => `${p.name} ${p.repo ?? '-'} ${shortId(p.id)}\n`).join('')); + io.stdout.write(projects.length === 0 ? 'no projects\n' : projects.map(p => `${p.name}${p.archived ? ' archived' : ''} ${p.repo ?? '-'} ${shortId(p.id)}\n`).join('')); } // Row 175 (#1673, Q173, mos-0872 Q2): `init --tester` may run inside an agent. Before anything diff --git a/packages/cli/tests/project.test.mjs b/packages/cli/tests/project.test.mjs index 5e067247b..fe6f45064 100644 --- a/packages/cli/tests/project.test.mjs +++ b/packages/cli/tests/project.test.mjs @@ -60,3 +60,68 @@ test('project dispatcher reaches the human CLI', async t => { assert.equal(result.status, 4); assert.match(result.stderr, /mosaic project/); }); + +// #797 MVP3: archive and unarchive resolve a name-or-id against the full list (archived +// included, so an archived project can be named), then call their verb once. list --archived +// includes archived projects, each marked. +test('project archive and unarchive validate args, resolve name-or-id, and map refusals', async t => { + const root = tmp(t), f = fixture(root); + const A = '10000000-0000-4000-8000-000000000001', B = '20000000-0000-4000-8000-000000000002'; + const projects = [ + { id: A, at: '2026-11-06T00:00:00.000Z', name: 'live', repo: null }, + { id: B, at: '2026-11-06T00:00:00.000Z', name: 'shelved', repo: null, archived: true }, + ]; + const calls = []; + const transport = () => async (verb, args) => { + if (verb === 'plugins') return { business: 'acme', revision: 'a'.repeat(64), plugins: { channel: ['cli', 'webui'] } }; + calls.push([verb, args]); + if (verb === 'project.list') return projects; + if (verb === 'project.archive') return { project: args.project, archived: true }; + if (verb === 'project.unarchive') return { project: args.project, archived: false }; + throw Object.assign(new Error(verb), { code: 'bus-refused' }); + }; + for (const args of [['archive'], ['unarchive'], ['archive', 'a', 'b'], ['archive', 'x', '--repo', 'y'], ['archive', '--archived', 'x']]) + assert.equal(await run(['project', ...args, '--business', 'acme'], io(humanEnv(f.env)), { transport }), 4, args.join(' ')); + assert.deepEqual(calls, []); + const archived = io(humanEnv(f.env)); + assert.equal(await run(['project', 'archive', 'live', '--business', 'acme'], archived, { transport }), 0); + assert.deepEqual(calls.at(-1), ['project.archive', { project: A }]); + assert.equal(archived.out.text, `archived project live id ${A.slice(0, 8)}\n`); + const restored = io(humanEnv(f.env)); + assert.equal(await run(['project', 'unarchive', B, '--business', 'acme'], restored, { transport }), 0); + assert.deepEqual(calls.at(-1), ['project.unarchive', { project: B }]); + assert.equal(restored.out.text, `unarchived project shelved id ${B.slice(0, 8)}\n`); + for (const [args, code] of [[['archive', 'missing'], 2], [['unarchive', 'live'], 2], [['archive', 'shelved'], 2]]) { + const transport2 = () => async (verb, args2) => { + if (verb === 'plugins') return { business: 'acme', revision: 'a'.repeat(64), plugins: { channel: ['cli', 'webui'] } }; + if (verb === 'project.list') return projects; + throw Object.assign(new Error('refused'), { + code: args[0] === 'archive' && args[1] === 'shelved' ? 'project-already-archived' : args[1] === 'missing' ? undefined : 'project-not-archived', + }); + }; + const x = io(humanEnv(f.env)); + assert.equal(await run(['project', ...args, '--business', 'acme'], x, { transport: transport2 }), code, args.join(' ')); + assert.match(x.err.text, args[1] === 'missing' ? /no project is named/ : args[0] === 'archive' ? /already archived/ : /not archived/); + } +}); + +test('project list --archived includes and marks archived projects', async t => { + const root = tmp(t), f = fixture(root); + const A = '10000000-0000-4000-8000-000000000001', B = '20000000-0000-4000-8000-000000000002'; + const calls = []; + const transport = () => async (verb, args) => { + if (verb === 'plugins') return { business: 'acme', revision: 'a'.repeat(64), plugins: { channel: ['cli', 'webui'] } }; + calls.push([verb, args]); + return [ + { id: A, at: '2026-11-06T00:00:00.000Z', name: 'live', repo: null }, + { id: B, at: '2026-11-06T00:00:00.000Z', name: 'shelved', repo: null, archived: true }, + ]; + }; + const plain = io(humanEnv(f.env)); + assert.equal(await run(['project', 'list', '--business', 'acme'], plain, { transport }), 0); + assert.deepEqual(calls.at(-1), ['project.list', {}]); + const archived = io(humanEnv(f.env)); + assert.equal(await run(['project', 'list', '--archived', '--business', 'acme'], archived, { transport }), 0); + assert.deepEqual(calls.at(-1), ['project.list', { includeArchived: true }]); + assert.equal(archived.out.text, `live - ${A.slice(0, 8)}\nshelved archived - ${B.slice(0, 8)}\n`); +}); diff --git a/packages/cli/tests/stack-release.test.mjs b/packages/cli/tests/stack-release.test.mjs index 31ce58e5b..da8000050 100644 --- a/packages/cli/tests/stack-release.test.mjs +++ b/packages/cli/tests/stack-release.test.mjs @@ -306,7 +306,7 @@ test('#811 migration readers follow the engine order and refuse malformed pairs' assert.notEqual(engineSource,source); const engine=await import(`data:text/javascript;base64,${Buffer.from(engineSource).toString('base64')}`); const pairs=engine.migrations.map(({from,to})=>[from,to]); - assert.equal(pairs.length,17);assert.deepEqual(pairs.at(-1),['3r','3s']); + assert.equal(pairs.length,18);assert.deepEqual(pairs.at(-1),['3s','3t']); const release=readFileSync(join(ROOT,'scripts/stack-release.sh'),'utf8'); const fn=(name)=>{const m=release.match(new RegExp(`^${name}\\(\\) \{[\\s\\S]*?^\}`, 'm'));assert.ok(m,name);return m[0]}; const functions=['copy_schema','target_schema','plan_steps'].map(fn).join('\n'); @@ -885,7 +885,7 @@ const COUNTED=/^COUNTED="([^"]*)"$/m.exec(srText)[1].split(' '); const STORE_JS=/^STORE_JS='([\s\S]*?)'$/m.exec(srText)[1]; // sage-1163's list 2, written out so that a table dropped from the script's COUNTED fails T45 as // well as T46. -const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','threads','sessions','session_defaults','thread_opens','thread_shares','instance_names','usage_records','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events']; +const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','project_archives','threads','sessions','session_defaults','thread_opens','thread_shares','instance_names','usage_records','session_ops','message_cancels','request_events','request_effects','request_snapshots','request_snapshot_events','request_project_events']; const LATE='2999-01-01T00:00:00.000Z'; const addRow=(file,table,at=LATE)=>{const db=new DatabaseSync(file);db.exec(`CREATE TABLE IF NOT EXISTS ${table} (seq INTEGER PRIMARY KEY, at TEXT NOT NULL)`);db.prepare(`INSERT INTO ${table} (at) VALUES (?)`).run(at);db.close()}; diff --git a/packages/plugins/channel/cli/index.mjs b/packages/plugins/channel/cli/index.mjs index c3c0ae511..ce3b8a956 100644 --- a/packages/plugins/channel/cli/index.mjs +++ b/packages/plugins/channel/cli/index.mjs @@ -4,6 +4,8 @@ export const interfaceVersion = 1; const VERBS = new Set([ "project.list", "project.create", + "project.archive", + "project.unarchive", "thread.list", "thread.create", "thread.messages", diff --git a/packages/plugins/channel/webui/index.mjs b/packages/plugins/channel/webui/index.mjs index 8dd4d7749..89facaac4 100644 --- a/packages/plugins/channel/webui/index.mjs +++ b/packages/plugins/channel/webui/index.mjs @@ -7,6 +7,8 @@ const VERBS = new Set([ "room.send", "project.list", "project.create", + "project.archive", + "project.unarchive", "thread.list", "thread.create", "thread.messages", diff --git a/packages/webui/README.md b/packages/webui/README.md index dd190530f..8329f71fd 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -1062,6 +1062,11 @@ The fixed `/api/chat/projects`, `/api/chat/threads` and threads and read/send messages. `POST /api/chat/threads/UUID/open` is the explicit **Resume assistant** action. Reading a thread never opens it or acknowledges a message. Sending uses `thread.send` with class REQUEST, as the human CLI does. +The owner's archive controls (#797) live in `public/project-archive.js` and +`src/project-archive-http.mjs`: a **Show archived** toggle on the project list, +Archive (with a confirm) on a project, and Unarchive on archived ones, over +`GET /api/chat/projects/archived` and `POST /api/chat/projects/UUID/archive|unarchive`, +fenced exactly like the chat writes above. All four POST operations need `--sso --send`, the configured public Host, a valid OIDC session, the Console's HTTPS Origin, same-origin Fetch diff --git a/packages/webui/src/chat.mjs b/packages/webui/src/chat.mjs index 542b5fffb..e230e963f 100644 --- a/packages/webui/src/chat.mjs +++ b/packages/webui/src/chat.mjs @@ -25,8 +25,11 @@ export function createChat({ call, owner = null }) { roomOpen: ({ project }) => channel('room.open', { project }), roomRead: ({ project, after, limit }) => channel('room.read', { project, ...(after ? { after } : {}), ...(limit === undefined ? {} : { limit }) }), roomSend: ({ project, body }) => channel('room.send', { project, body }), - projects: () => channel('project.list', {}), + // #797 MVP3: includeArchived asks project.list for the hidden projects too, each flagged. + projects: ({ includeArchived } = {}) => channel('project.list', includeArchived === true ? { includeArchived: true } : {}), createProject: ({ name, repo }) => channel('project.create', { name, ...(repo === undefined ? {} : { repo }) }), + archiveProject: ({ project }) => channel('project.archive', { project }), + unarchiveProject: ({ project }) => channel('project.unarchive', { project }), threads: ({ project, shared } = {}) => channel('thread.list', { ...(project === undefined ? {} : { project }), ...(shared === true ? { shared: true } : {}) }), shares: ({ thread }) => channel('thread.shares', { thread }), share: ({ thread, to }) => channel('thread.share', { thread, to }), diff --git a/packages/webui/src/project-archive-http.mjs b/packages/webui/src/project-archive-http.mjs new file mode 100644 index 000000000..e4b909f94 --- /dev/null +++ b/packages/webui/src/project-archive-http.mjs @@ -0,0 +1,99 @@ +// #797 MVP3 (owner report 2026-11-06): the Console's project archive routes, kept out of +// chat-http.mjs and request-http.mjs on purpose. Three shapes only: the archived-projects read +// the "Show archived" toggle needs, and the owner's archive and unarchive writes. The write +// fence is chat-http's: same-origin, signed-in send mode, and the tester listener never has +// them (serve.mjs's TESTER_CHAT_WRITE fence stops a tester POST before this module runs). +import { BusReadError } from './bus.mjs'; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const ROUTE = /^\/api\/chat\/projects\/([0-9a-f-]{36})\/(archive|unarchive)$/; +const one = (req, key) => req.headersDistinct[key]?.length === 1 ? req.headersDistinct[key][0] : null; +const json = (res, status, value) => { + res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); + res.end(JSON.stringify(value)); +}; +const STATUS = { + 'invalid-request': 400, 'project-not-found': 404, + 'project-archived': 409, 'project-already-archived': 409, 'project-not-archived': 409, + 'project-exists': 409, 'human-required': 403, unauthenticated: 403, 'read-only': 403, + 'unknown-business': 403, 'tester-refused': 403, 'access-refused': 403, 'access-revoked': 401, + 'not-configured': 503, 'no-bus-host': 503, 'channel-disabled': 503, + 'channel-operation': 503, 'outcome-unknown': 503, 'unknown-verb': 503, + 'invalid-response': 502, +}; +const safeMessage = (code) => + code === 'project-not-found' ? 'Project not found.' : + code === 'project-archived' ? 'This project is archived. Unarchive it to use it again.' : + code === 'project-already-archived' ? 'This project is already archived.' : + code === 'project-not-archived' ? 'This project is not archived.' : + code === 'channel-disabled' + ? 'Run mosaic plugins to read the revision, then mosaic plugins set --file PATCH with plugins.channel set to ["cli","webui"].' + : `Project operation refused (${code}).`; + +async function payload(req) { + if ((req.headers['content-type'] || '').split(';')[0].trim().toLowerCase() !== 'application/json') throw Error(); + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > 16384) throw Error(); + chunks.push(chunk); + } + const value = JSON.parse(Buffer.concat(chunks).toString('utf8')); + if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length) throw Error(); + return value; +} + +export async function projectArchiveHTTP(req, res, { path, search, chat, named, send, preview, log }) { + const write = req.method === 'POST'; + const refuse = (status, code, sent = false) => + json(res, status, { error: code, message: safeMessage(code), ...(write ? { sent } : {}) }); + if (!['GET', 'POST'].includes(req.method)) return refuse(405, 'method-not-allowed'); + const match = ROUTE.exec(path); + const list = path === '/api/chat/projects/archived'; + if (!match && !list) return refuse(404, 'not-found'); + if (write !== !!match) return refuse(405, 'method-not-allowed'); + if (search) return refuse(400, 'invalid-request'); + try { + if (write) { + // Same fence as chat-http.mjs: the tester listener answers read-only, and every other + // listener needs send mode on its public name with a signed-in single-user preview. + if (preview?.tester) { + if (!chat) return refuse(403, 'read-only'); + if (one(req, 'origin') !== preview.origin) return refuse(403, 'cross-origin'); + } else { + if (!send || !named || !preview) return refuse(403, 'read-only'); + if (preview.sso !== true && one(req, preview.header) !== preview.user) return refuse(401, 'unauthenticated'); + if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin'); + } + if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site'); + await payload(req); + } + } catch { return refuse(400, 'invalid-request'); } + if (!chat) return refuse(503, 'not-configured'); + const action = match ? (match[2] === 'archive' ? 'archiveProject' : 'unarchiveProject') : 'projects'; + const line = outcome => log(`${new Date().toISOString()} project-archive action=${action} user=${JSON.stringify(preview?.user ?? null)} ${outcome}`); + try { + if (!write) { + const rows = await chat[action]({ includeArchived: true }); + if (!Array.isArray(rows) || rows.some(p => !UUID.test(p?.id) || typeof p.name !== 'string' + || p.name.length > 200 || /[\r\n\0]/.test(p.name) || p.archived !== true && p.archived !== undefined)) + return refuse(502, 'invalid-response'); + return json(res, 200, { rows, at: new Date().toISOString() }); + } + const value = await chat[action]({ project: match[1] }); + if (value?.project !== match[1] || value.archived !== (match[2] === 'archive')) + { line('error=outcome-unknown'); return refuse(503, 'outcome-unknown', 'unknown'); } + line(`project=${match[1]} op=${match[2]}`); + // Only the fields of this operation's receipt are exposed. + return json(res, 200, { project: value.project, archived: value.archived, at: new Date().toISOString() }); + } catch (err) { + // Unrecognized transport text is never reflected. Known refusal codes have fixed messages. + // Like chatHTTP: a write whose answer was lost is outcome-unknown; a read is invalid-response. + const known = err instanceof BusReadError && Object.hasOwn(STATUS, err.code); + const code = write ? (known ? err.code : 'outcome-unknown') + : known && err.code !== 'outcome-unknown' ? err.code : 'invalid-response'; + if (write) line(`error=${code}`); + return refuse(STATUS[code] ?? 502, code, write && code === 'outcome-unknown' ? 'unknown' : false); + } +} diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js index d358afe05..ce243ab56 100644 --- a/packages/webui/src/public/chat.js +++ b/packages/webui/src/public/chat.js @@ -35,6 +35,7 @@ function forget() { generation++; controller?.abort(); states.clear(); forms.clear(); libraries.clear(); reverts.clear(); window.MosaicRequests?.forget(); + window.MosaicProjectArchive?.forget(); if (current) current.container.replaceChildren(); current = null; } @@ -397,6 +398,7 @@ if (!c.canSend) html += '

Creating and sending require signed-in send mode. This Console only reads.

'; c.container.innerHTML = html + ''; window.MosaicRequests?.mount(c.container); + window.MosaicProjectArchive?.mount(c.container, c); document.title = `${c.container.querySelector('h1')?.textContent ?? 'Chat'} · Mosaic Console`; const log = c.container.querySelector('.chat-log'); if (log) { diff --git a/packages/webui/src/public/index.html b/packages/webui/src/public/index.html index d4ab18ba8..d51c58db7 100644 --- a/packages/webui/src/public/index.html +++ b/packages/webui/src/public/index.html @@ -3,8 +3,9 @@ Mosaic Console + - +
MosaicConsole diff --git a/packages/webui/src/public/project-archive.css b/packages/webui/src/public/project-archive.css new file mode 100644 index 000000000..1916c913e --- /dev/null +++ b/packages/webui/src/public/project-archive.css @@ -0,0 +1,19 @@ +/* #797 MVP3: the owner's project archive controls (project-archive.js). Minimal on purpose; + the Console's visual lane owns polish. Two hooks come from the shell: the current project's + archive panel (.project-archive-control) and the list's Show-archived section + (.project-archived-filter). The shell's .panel, .btn and .muted classes carry the rest. */ +.project-archive-control ul, +.project-archived-filter ul { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.35rem; +} +.project-archive-control li, +.project-archived-filter li { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} diff --git a/packages/webui/src/public/project-archive.js b/packages/webui/src/public/project-archive.js new file mode 100644 index 000000000..7af4f833d --- /dev/null +++ b/packages/webui/src/public/project-archive.js @@ -0,0 +1,166 @@ +/* #797 MVP3 (owner report 2026-11-06): the owner's project archive controls. chat.js calls + MosaicProjectArchive.mount(container, current) after each paint and forget() on sign-out, the + same hook pattern requests.js uses. Everything here renders through textContent or static + markup only; project names never cross as HTML. Owner-only: a granted user's page never loads + this file, and the bus refuses the verbs for everyone but the human anyway. */ +(() => { + 'use strict'; + const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const out = () => document.body.classList.contains('signed-out'); + const owner = c => c && !c.user && !c.tester; + let shown = false; // the "Show archived" toggle's state + let cached = null; // { at, rows } of the last archived-projects read + let pending = false; // one archive/unarchive write at a time + let receipt = ''; // the section's last status line + + const refreshView = () => { window.dispatchEvent(new HashChangeEvent('hashchange')); }; + + async function archived(signal) { + if (cached && Date.now() - cached.at < 5000) return cached.rows; + const res = await fetch('/api/chat/projects/archived', { cache: 'no-store', redirect: 'manual', signal }); + if (res.type === 'opaqueredirect' || res.status === 401) return null; + const data = await res.json().catch(() => null); + if (!res.ok || !Array.isArray(data?.rows) + || data.rows.some(p => !UUID.test(p?.id) || typeof p.name !== 'string' || typeof p.repo !== 'string' && p.repo !== null)) + throw Error('invalid'); + cached = { at: Date.now(), rows: data.rows }; + return data.rows; + } + + async function change(project, name, op) { + if (pending || out()) return; + const question = op === 'archive' + ? `Archive ${name}? Archived projects refuse new threads and messages. Everything stays readable and it can be unarchived later.` + : `Unarchive ${name}? It returns to the project list and accepts new threads and messages again.`; + if (!window.confirm(question)) return; + pending = true; + let done = false; + receipt = op === 'archive' ? 'Archiving…' : 'Unarchiving…'; + document.getElementById('project-archive-status')?.replaceChildren(receipt); + document.getElementById('project-archive-button')?.setAttribute('disabled', ''); + try { + const res = await fetch(`/api/chat/projects/${project}/${op}`, { + method: 'POST', cache: 'no-store', redirect: 'manual', + headers: { 'content-type': 'application/json' }, body: '{}', + }); + if (res.type === 'opaqueredirect' || res.status === 401) { receipt = 'Sign-in expired. Refresh to try again.'; return; } + const data = await res.json().catch(() => null); + done = res.ok; + receipt = done ? (op === 'archive' ? 'Project archived.' : 'Project unarchived.') + : data?.sent === false && typeof data?.message === 'string' + ? data.message.slice(0, 120) + : 'Outcome unknown. Read the project list before trying again; nothing was retried.'; + } catch { receipt = 'Outcome unknown. Read the project list before trying again; nothing was retried.'; } + finally { + pending = false; cached = null; + document.getElementById('project-archive-status')?.replaceChildren(receipt); + if (done) refreshView(); + } + } + + function listRow(p, unarchive) { + const li = document.createElement('li'); + const a = document.createElement('a'); + a.href = `#/chat/projects/${p.id}`; + a.textContent = p.name; + li.append(a, ' '); + if (unarchive) { + const button = document.createElement('button'); + button.type = 'button'; button.className = 'btn'; button.textContent = 'Unarchive'; + button.addEventListener('click', () => void change(p.id, p.name, 'unarchive')); + li.append(button); + } + return li; + } + + function section(title, kind) { + const section = document.createElement('section'); + section.className = 'panel ' + kind; + section.setAttribute('aria-label', title); + const heading = document.createElement('h2'); heading.textContent = title; + section.append(heading); + return section; + } + + function projectControls(container, current) { + const project = current.projects.find(p => p.id === current.id); + const box = section(project ? 'Archive project' : 'Archived project', 'project-archive-control'); + const status = document.createElement('p'); + status.id = 'project-archive-status'; status.setAttribute('role', 'status'); + if (project) { + const line = document.createElement('p'); + line.textContent = 'Archiving hides this project from the list and refuses new threads and messages. Nothing is deleted.'; + const button = document.createElement('button'); + button.type = 'button'; button.className = 'btn'; button.id = 'project-archive-button'; + button.textContent = 'Archive project'; button.disabled = pending; + button.addEventListener('click', () => void change(project.id, project.name, 'archive')); + box.append(line, button); + } else { + const line = document.createElement('p'); + line.textContent = 'This project is archived. Its records stay readable; unarchive it to use it again.'; + box.append(line); + } + box.append(status); + status.replaceChildren(receipt); + container.append(box); + } + + function archiveList(container) { + const box = section('Archived projects', 'project-archived-filter'); + const toggle = document.createElement('button'); + toggle.type = 'button'; toggle.className = 'btn'; toggle.id = 'project-archive-toggle'; + toggle.setAttribute('aria-expanded', String(shown)); + toggle.textContent = shown ? 'Hide archived' : 'Show archived'; + toggle.addEventListener('click', () => { + // Both directions re-render through the shell's hashchange, which repaints chat.js's + // markup and calls mount again: the section is rebuilt once, with the new toggle state. + shown = !shown; receipt = ''; cached = null; + refreshView(); + }); + box.append(toggle); + const list = document.createElement('ul'); + list.id = 'project-archive-list'; + box.append(list); + container.append(box); + if (!shown) return; + toggle.disabled = true; + const controller = new AbortController(); + archived(controller.signal).then(rows => { + if (out() || !shown) return; + list.replaceChildren(...(rows ?? []).filter(p => p.archived === true).map(p => listRow(p, true))); + if (!list.children.length) { + const li = document.createElement('li'); li.className = 'muted'; li.textContent = 'No archived projects.'; + list.append(li); + } + }).catch(() => { + if (out()) return; + const li = document.createElement('li'); li.className = 'muted'; + li.textContent = 'Archived projects unavailable. Refresh to try again.'; + list.replaceChildren(li); + }).finally(() => { toggle.disabled = false; }); + } + + function mountList(container, current) { + if (!owner(current)) return; + archiveList(container); + } + + function mountProject(container, current) { + if (!owner(current)) return; + // The live list carries the project while it is unarchived; an archived project is missing + // from it (chat.js then titles the page "Project unavailable"), which selects the Unarchive + // control. A stale live view answers the button click with the bus's own refusal message. + projectControls(container, current); + } + + // chat.js calls this after each paint with its own current view state. + function mount(container, current) { + if (out() || !container || !current?.canSend) return; + if (current.kind === 'list') mountList(container, current); + else if (current.kind === 'project') mountProject(container, current); + } + + function forget() { shown = false; cached = null; pending = false; receipt = ''; } + + window.MosaicProjectArchive = Object.freeze({ mount, forget }); +})(); diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index 3fb2cb5f4..9321a4345 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -11,6 +11,7 @@ const busStatus = code => ['tester-off', 'tester-required'].includes(code) ? 403 import { ReadError, readStatus, fieldTable } from './reads.mjs'; import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs'; import { chatHTTP } from './chat-http.mjs'; +import { projectArchiveHTTP } from './project-archive-http.mjs'; import { processingHTTP } from './processing-http.mjs'; import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } from './decision-http.mjs'; import { REQUEST_ROUTE, requestHTTP, requestRefuse } from './request-http.mjs'; @@ -33,8 +34,8 @@ const files = new Map([ ['/', ['index.html', 'text/html; charset=utf-8']], ['/settings/model-proxy', ['proxy.html', 'text/html; charset=utf-8']], ['/proxy.js', ['proxy.js', 'text/javascript; charset=utf-8']], - ...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]), - ...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'processing.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]), + ...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]), + ...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'project-archive.css', 'processing.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]), ['/editor.js', ['editor.js', 'text/javascript; charset=utf-8']], // Row 225 (C2-10b): the one reply renderer, shared with the Discord relay, imported by chat.js. ['/render.mjs', ['../../../library/src/render.mjs', 'text/javascript; charset=utf-8']], @@ -96,6 +97,8 @@ const DECISION_LISTS = ['roles', 'recent']; const SIGN_IN_COOKIE = /(?:^|;)\s*__host-mosaic_(?:session|login)\s*=/i; // Row 177 (#1675): the chat writes the tester listener lets through, exactly the four chatHTTP routes. const TESTER_CHAT_WRITE = /^\/api\/chat\/(?:projects|threads|threads\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/(?:messages|open))$/i; +// #797 MVP3: the owner's archive/unarchive writes (project-archive-http.mjs). +const PROJECT_ARCHIVE_ROUTE = /^\/api\/chat\/projects\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/(?:archive|unarchive)$/i; async function busRead(res, bus, verb, search) { let arg; if (verb === 'thread.claims' && search) return json(res, 400, { error: 'invalid-request', message: 'thread.claims takes no arguments' }); @@ -863,6 +866,10 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF return json(res, busStatus(code), { error: code }); } } + // #797 MVP3: the project archive routes (project-archive-http.mjs), ahead of chatHTTP, whose + // /api/chat/projects route they extend. A tester POST is already stopped by the fence above. + if (path === '/api/chat/projects/archived' || PROJECT_ARCHIVE_ROUTE.test(path)) + return projectArchiveHTTP(req, res, { path, search, chat, named, send, preview: writer, log }); if (path === '/api/chat' || path.startsWith('/api/chat/')) { return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log }); } diff --git a/packages/webui/tests/browser.test.mjs b/packages/webui/tests/browser.test.mjs index 302cf3243..ca12b658e 100644 --- a/packages/webui/tests/browser.test.mjs +++ b/packages/webui/tests/browser.test.mjs @@ -18,8 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4'); await b.evaluate('document.querySelector("#hide-offline").click()'); assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4); - // Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. - assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/chat.js', '/agents.js', '/bus.js', '/requests.js']); + // Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 MVP3 adds project-archive.js (the owner's archive controls) right after chat.js, which calls it. + assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']); assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1); assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1); assert.equal(await b.evaluate('!!window.injected'), false); diff --git a/packages/webui/tests/project-archive-http.test.mjs b/packages/webui/tests/project-archive-http.test.mjs new file mode 100644 index 000000000..cbfd29e78 --- /dev/null +++ b/packages/webui/tests/project-archive-http.test.mjs @@ -0,0 +1,136 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { request } from 'node:http'; +import { startServer } from '../src/serve.mjs'; +import { createChat } from '../src/chat.mjs'; +import { BusReadError } from '../src/bus.mjs'; +import { createSsoSession } from './helpers/sso-session.mjs'; + +// #797 MVP3: the Console's project archive routes (src/project-archive-http.mjs), served ahead +// of chatHTTP. Three shapes: GET /api/chat/projects/archived, POST .../archive, POST .../unarchive. + +const P = '10000000-0000-4000-8000-000000000001'; +const ROW = { id: P, at: '2026-11-06T00:00:00.000Z', name: 'demo', repo: null, archived: true }; + +async function setup(t, { enabled = true } = {}) { + const calls = [], state = { value: null }; + const auth = await createSsoSession(t, { host: 'console.example' }); + const chat = createChat({ call: async (verb, args) => { + if (verb === 'plugins') return { plugins: { channel: ['webui'] } }; + calls.push([verb, args]); + if (state.value instanceof Error) throw state.value; + return state.value; + } }); + const server = await startServer({ port: 0, sso: auth.sso, send: enabled ? async () => ({}) : null, chat, log: () => {} }); + t.after(() => new Promise(r => { server.close(r); server.closeIdleConnections(); })); + const session = await auth.signIn(server.address().port), headers = auth.writeHeaders(session); + const call = (path, { method = 'GET', value, hs = headers, query = '' } = {}) => new Promise((resolve, reject) => { + const req = request({ host: '127.0.0.1', port: server.address().port, path: `/api/chat/projects/${path}${query}`, method, headers: hs }, res => { + let text = ''; res.on('data', c => text += c); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(text) })); + }); + req.on('error', reject); req.end(value === undefined ? undefined : JSON.stringify(value)); + }); + return { calls, state, call, chat, headers }; +} + +test('archive seam exposes the three fixed calls', async t => { + const f = await setup(t); + f.state.value = []; + await f.chat.projects({ includeArchived: true }); + assert.deepEqual(f.calls.at(-1), ['project.list', { includeArchived: true }]); + await f.chat.projects(); + assert.deepEqual(f.calls.at(-1), ['project.list', {}]); + f.state.value = { project: P, archived: true }; + await f.chat.archiveProject({ project: P }); + assert.deepEqual(f.calls.at(-1), ['project.archive', { project: P }]); + f.state.value = { project: P, archived: false }; + await f.chat.unarchiveProject({ project: P }); + assert.deepEqual(f.calls.at(-1), ['project.unarchive', { project: P }]); +}); + +test('GET archived lists flagged rows; archive and unarchive answer their receipts', async t => { + const f = await setup(t); + f.state.value = [ROW]; + const list = await f.call('archived'); + assert.equal(list.status, 200); + assert.deepEqual(list.body.rows, [ROW]); + assert.ok(typeof list.body.at === 'string'); + assert.deepEqual(f.calls.at(-1), ['project.list', { includeArchived: true }]); + f.state.value = { project: P, archived: true }; + const archived = await f.call(`${P}/archive`, { method: 'POST', value: {} }); + assert.equal(archived.status, 200); + assert.equal(archived.body.project, P); + assert.equal(archived.body.archived, true); + assert.ok(typeof archived.body.at === 'string'); + assert.deepEqual(f.calls.at(-1), ['project.archive', { project: P }]); + f.state.value = { project: P, archived: false }; + const back = await f.call(`${P}/unarchive`, { method: 'POST', value: {} }); + assert.equal(back.status, 200); + assert.deepEqual([back.body.project, back.body.archived], [P, false]); + assert.deepEqual(f.calls.at(-1), ['project.unarchive', { project: P }]); +}); + +test('routes enforce methods, payloads and query strictness before the transport', async t => { + const f = await setup(t); + const cases = [ + ['archived', { method: 'POST', value: {} }, 405], + [`${P}/archive`, {}, 405], + [`${P}/unarchive`, {}, 405], + ['archived', { query: '?limit=1' }, 400], + [`${P}/archive`, { method: 'POST', value: {}, query: '?x=1' }, 400], + [`${P}/archive`, { method: 'POST', value: { project: P } }, 400], + [`${P}/archive`, { method: 'POST', value: [] }, 400], + ]; + for (const [path, opts, status] of cases) + assert.equal((await f.call(path, opts)).status, status, path); + assert.deepEqual(f.calls, []); +}); + +test('write fences refuse before the transport, like chatHTTP', async t => { + const f = await setup(t), off = await setup(t, { enabled: false }); + assert.equal((await off.call(`${P}/archive`, { method: 'POST', value: {} })).status, 403); + for (const hs of [ + { ...f.headers, origin: 'https://evil.example' }, + { ...f.headers, 'sec-fetch-site': 'cross-site' }, + Object.fromEntries(Object.entries(f.headers).filter(([key]) => key !== 'cookie')), + ]) assert.ok([401, 403].includes((await f.call(`${P}/archive`, { method: 'POST', value: {}, hs })).status), JSON.stringify(hs)); + assert.deepEqual(f.calls, []); + assert.deepEqual(off.calls, []); +}); + +test('known refusals keep their codes, fixed messages and never transport prose', async t => { + const f = await setup(t); + const cases = [ + ['human-required', 403, `${P}/archive`], + ['project-not-found', 404, `${P}/archive`], + ['project-already-archived', 409, `${P}/archive`], + ['project-not-archived', 409, `${P}/unarchive`], + ]; + for (const [code, status, path] of cases) { + f.state.value = new BusReadError(code, 'private transport reason'); + const r = await f.call(path, { method: 'POST', value: {} }); + assert.equal(r.status, status, code); + assert.equal(r.body.error, code); + assert.equal(r.body.sent, false); + assert.ok(r.body.message.length > 0); + assert.equal(JSON.stringify(r.body).includes('private transport reason'), false); + } + assert.equal(f.calls.length, cases.length); +}); + +test('malformed or lost receipts are outcome-unknown and never retried', async t => { + const f = await setup(t); + for (const value of [null, { project: P }, { project: P, archived: null }, { project: P, archived: 'yes' }, new Error('socket closed')]) { + f.state.value = value; + const before = f.calls.length, r = await f.call(`${P}/archive`, { method: 'POST', value: {} }); + assert.equal(r.status, 503); + assert.equal(r.body.error, 'outcome-unknown'); + assert.equal(r.body.sent, 'unknown'); + assert.equal(f.calls.length, before + 1); + } + // A malformed list read answers invalid-response, also without prose. + f.state.value = [{ id: 'bad' }]; + assert.equal((await f.call('archived')).status, 502); + f.state.value = new BusReadError('outcome-unknown'); + assert.equal((await f.call('archived')).body.error, 'invalid-response'); +}); diff --git a/scripts/stack-release.sh b/scripts/stack-release.sh index 1298fec3d..c4b5be1d3 100755 --- a/scripts/stack-release.sh +++ b/scripts/stack-release.sh @@ -419,7 +419,7 @@ plugin_checks() { # Row 181: the tables a bus writer fills without an event in the same transaction, in schema # order. A restore drops their rows after STAMP as it drops events, so the guard counts them too. # BUILD.md for row 181 names each writer; the T46 guard classifies every table in the schema. -COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects threads sessions session_defaults thread_opens thread_shares instance_names usage_records session_ops message_cancels request_events request_effects request_snapshots request_snapshot_events request_project_events" +COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects project_archives threads sessions session_defaults thread_opens thread_shares instance_names usage_records session_ops message_cancels request_events request_effects request_snapshots request_snapshot_events request_project_events" STORE_JS=' const {DatabaseSync}=require("node:sqlite");const {pathToFileURL}=require("node:url");