feat(bus): owner project archive and unarchive (#797)

Jason's owner report 2026-11-06: no way to archive or delete projects.
Archive is the reversible half; delete stays out of scope because project
and run records are write-once evidence.

- project.archive / project.unarchive: human-only (project.create's gate
  minus the tester carve-out; tester, user and agent refused). State is the
  latest row of a new append-only project_archives journal (3s->3t, additive;
  the rows are the audit trail), so projects itself never changes and nothing
  is deleted. Repeats refuse project-already-archived / project-not-archived.
- project.list hides archived projects until {includeArchived:true}, which
  flags each with archived:true; a user still lists granted projects only.
- While archived, thread.create, thread.send, room.open and room.send refuse
  project-archived; every existing record stays readable and unarchive
  restores everything.
- Console: new public/project-archive.js (+css) and project-archive-http.mjs
  routes (chatHTTP's write fence); chat.js gains two one-line hooks,
  index.html one script and one link tag; styling only through the shell's
  .project-archive-control / .project-archived-filter hooks.
- CLI: mosaic project archive|unarchive <name-or-id> and list --archived.
- stack-release COUNTED/LISTED gain project_archives (schema order); schema
  step-count pins and migration fixtures updated for 3t.

bus 1601/1601 (Node 24; Node 26 has the pre-existing project-file socket
stall), webui 582/582, plugins 132/132, cli 1191/1194 (two pre-existing
load flakes of the row253 C4 and land_work families; both pass isolated and
the base commit's full suite fails four of the same family).
This commit is contained in:
jason.woltje committed 2026-11-05 23:50:51 -06:00
1 parent 05fdfef9ea
commit 3e21d239b3
42 files changed
+958 -80

No files matched your search

+1
View File
@@ -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-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-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
+3 -1
View File
@@ -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. | | `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. | | `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.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.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.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. | | `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. |
+21
View File
@@ -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_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_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; 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_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_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; 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;
+53 -4
View File
@@ -1408,6 +1408,15 @@ export class Broker {
#owner(s) { #owner(s) {
return s.tester ? 'tester' : s.user ? 'user:' + s.user : s.human; 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 // 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 // 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). // verb's own check asks the level it needs (#granted).
@@ -7372,11 +7381,43 @@ export class Broker {
} }
case 'project.list': { case 'project.list': {
if (!s.tester && !s.user) this.#human(s); if (!s.tester && !s.user) this.#human(s);
keys(a, []); keys(a, ['includeArchived']);
// FED R3 (design 3.5): a user lists granted projects only. 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 return this.#store
.all('SELECT id,at,name,repo FROM projects WHERE business=? ORDER BY seq', s.business) .all(
.filter((p) => !s.user || this.#granted(s, 'project.list', p.id, null)); `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. // C2-09b: canonical per-instance threads form one human-owned project room.
case 'room.open': case 'room.open':
@@ -7389,6 +7430,9 @@ export class Broker {
sending ? ['project', 'body'] : ['project']); sending ? ['project', 'body'] : ['project']);
const project = identifier(a.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'); 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(); const members = Object.keys(b.roles).filter((instance) => this.#threaded(s.business, instance)).sort();
if (!members.length) fail('thread-instance-missing'); if (!members.length) fail('thread-instance-missing');
const owner = this.#owner(s); 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)) if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', identifier(a.project), s.business))
fail('project-not-found'); 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. // 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 (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing');
if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous'); 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); if (!s.tester && !s.user) this.#human(s);
keys(a, ['thread', 'session', 'body', 'class'], ['thread', 'body']); keys(a, ['thread', 'session', 'body', 'class'], ['thread', 'body']);
const t = this.#grantedThread(s, 'thread.send', this.#thread(s, a.thread)); 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); const session = this.#threadSession(t, a.session);
// Row 271 (264d, D7 step 1): nor sent to, by name or as the default. // Row 271 (264d, D7 step 1): nor sent to, by name or as the default.
this.#sessionFence(session); this.#sessionFence(session);
+21
View File
@@ -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;
+8 -2
View File
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8'); const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
export const SCHEMA_SHA256 = 'fde7a46505a964d0e3802aaee96a1e2d9fb8c7163fbafc2f86aca914ecc50f03'; export const SCHEMA_SHA256 = '1cec88b0b697c648bcab18b45f86d85526aa1701a7619046367e16c38476aee8';
export const SCHEMA_VERSION = '3s'; export const SCHEMA_VERSION = '3t';
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned. // 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 legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65'; const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
@@ -107,6 +107,11 @@ const migrations = [{
// Task 6b: checked advancement after Task 3's complete fifteen-kind union. // Task 6b: checked advancement after Task 3's complete fifteen-kind union.
from: '3r', to: '3s', from: '3r', to: '3s',
sql: readFileSync(new URL('./migrations/3r-3s.sql', import.meta.url), 'utf8'), 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 = [ const timedTables = [
'events', 'events',
@@ -132,6 +137,7 @@ const timedTables = [
'request_snapshots', 'request_snapshots',
'request_snapshot_events', 'request_snapshot_events',
'request_project_events', 'request_project_events',
'project_archives',
]; ];
const hex = (value) => createHash('sha256').update(value).digest('hex'); const hex = (value) => createHash('sha256').update(value).digest('hex');
if (hex(ddl) !== SCHEMA_SHA256) throw Error('schema-source-mismatch'); if (hex(ddl) !== SCHEMA_SHA256) throw Error('schema-source-mismatch');
@@ -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']; 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 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. // 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) { function source3h(t) {
const root = mkdtempSync(join(tmpdir(), 'account-migrate-')); const root = mkdtempSync(join(tmpdir(), 'account-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true })); 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/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/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/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; 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); const { root, path } = source3h(t), before = snapshot(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close(); 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. // 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(migrated3l(snapshot(path, before.columns).rows), before.rows);
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]); 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); assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
// Still a whole 3h store: the real step migrates it. // Still a whole 3h store: the real step migrates it.
new Store(root, { migrateSchema: true }).close(); 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); 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, schema: ddl3h,
store: (source) => source 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_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'), .replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
}); });
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8'); 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.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), /schema-mismatch/);
assert.throws(() => new Store3h(root, { migrateSchema: true }), /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(master(path), was);
assert.deepEqual(snapshot(path, after.columns).rows, after.rows); assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
}); });
+2 -2
View File
@@ -614,7 +614,7 @@ async function database3c(t) {
let source = readFileSync(here('../src/store.mjs'), 'utf8'); let source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source 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_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'); .replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.notEqual(pinned, source); assert.notEqual(pinned, source);
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step'); 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/); assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); const store = new Store(root, { migrateSchema: true });
t.after(() => store.close()); 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 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'); 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 }); const b = new Broker({ store, businesses: demo(), organization: null });
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
const original = readFileSync(source); const original = readFileSync(source);
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); 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 body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0); 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; 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'); const sha = createHash('sha256').update(futureDDL).digest('hex');
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8'); 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}';`) 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("export const SCHEMA_VERSION = '3t';", "export const SCHEMA_VERSION = '3u';")
.replace('}];\nconst timedTables', `}, { from: '3s', to: '3t', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`); .replace('}];\nconst timedTables', `}, { from: '3t', to: '3u', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
writeFileSync(join(moduleRoot, 'src/store.mjs'), source); writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL); 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)); 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 { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
const future = new FutureStore(root, { migrateSchema: true }); 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 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 count(*) n FROM task_comment_cursors").n, 0);
assert.equal(future.get("SELECT id FROM events").id, 'preserved'); 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 command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' }); const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr); 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(); new Store(root).close();
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' }); const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
assert.equal(usage.status, 2); assert.equal(usage.status, 2);
+7 -6
View File
@@ -36,11 +36,11 @@ function source3j(t) {
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens']; 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 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. // 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) { function rows(path) {
const db = new DatabaseSync(path, { readOnly: true }); const db = new DatabaseSync(path, { readOnly: true });
try { 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() .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 }))])); .filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); } } 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/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/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/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; return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
} }
function fresh(t) { 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); const { root, path } = source3j(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close(); new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3s'); assert.equal(version(path), '3t');
assert.deepEqual(migrated3l(rows(path)), before); assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path); const now = master(path);
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r))); 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(master(path), was);
assert.deepEqual(rows(path), before); assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close(); 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 => { 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, schema: ddl3j,
store: (source) => source 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_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'), .replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
}); });
const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8'); 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.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), /schema-mismatch/);
assert.throws(() => new Store3j(root, { migrateSchema: true }), /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(master(path), was);
assert.deepEqual(rows(path), after); assert.deepEqual(rows(path), after);
}); });
+7 -6
View File
@@ -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']; 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 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. // 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) { function source3i(t) {
const root = mkdtempSync(join(tmpdir(), 'grant-migrate-')); const root = mkdtempSync(join(tmpdir(), 'grant-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true })); t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -42,7 +42,7 @@ function source3i(t) {
function rows(path) { function rows(path) {
const db = new DatabaseSync(path, { readOnly: true }); const db = new DatabaseSync(path, { readOnly: true });
try { 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() .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 }))])); .filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); } } 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/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/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/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; return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
} }
function fresh(t) { 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); const { root, path } = source3i(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close(); new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3s'); assert.equal(version(path), '3t');
assert.deepEqual(migrated3l(rows(path)), before); assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path).filter(r => !LATER.includes(r.name)); 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))); 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(master(path), was);
assert.deepEqual(rows(path), before); assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close(); 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 => { 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, schema: ddl3i,
store: (source) => source 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_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'), .replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
}); });
const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8'); 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.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), /schema-mismatch/);
assert.throws(() => new Store3i(root, { migrateSchema: true }), /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(master(path), was);
assert.deepEqual(rows(path), after); assert.deepEqual(rows(path), after);
}); });
+1 -1
View File
@@ -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/); assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); const store = new Store(root, { migrateSchema: true });
try { 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.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.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) }); assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
+3 -1
View File
@@ -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 { Store, schemaDigest } = await import('../src/store.mjs');
const root = tmp(t, 'prd-migration-'); mkdirSync(join(root, 'bus'), { mode: 0o700 }); 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') 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'", ''); .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 db = new DatabaseSync(path); db.exec(ddl);
const at = '2026-11-03T00:00:00.000Z'; 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/); assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); t.after(() => store.close()); const store = new Store(root, { migrateSchema: true }); t.after(() => store.close());
assert.deepEqual(store.all('SELECT * FROM request_events'), before); 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); 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('DELETE FROM request_events'), /append-only/);
assert.throws(() => store.run("UPDATE request_events SET body='{}'"), /append-only/); assert.throws(() => store.run("UPDATE request_events SET body='{}'"), /append-only/);
+225
View File
@@ -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();
});
+6 -3
View File
@@ -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 => { 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-')); const root = mkdtempSync(join(tmpdir(), 'request-migration-3r-'));
t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 }); 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 path = join(root, 'bus/bus.sqlite'), AT = '2026-11-03T00:00:00.000Z';
const current = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8'); 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. // 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'); assert.notEqual(ddl3q, current, 'the fixture is the narrower schema');
const migration = readFileSync(new URL('../src/migrations/3q-3r.sql', import.meta.url), 'utf8'); 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'); 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); db.close(); chmodSync(path, 0o600);
assert.throws(() => new Store(root), /schema-mismatch/); 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' }); 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); 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'); 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); const fresh = new DatabaseSync(':memory:'); fresh.exec(current);
@@ -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 root = tmp(t, 'continuation-migration-'); mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'); const path = join(root, 'bus/bus.sqlite');
const db = new DatabaseSync(path); 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_version', '3r');
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_digest', schemaDigest(db)); 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'); 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 * 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.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 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); 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.equal(store.get("SELECT max(seq) n FROM request_events").n, 42);
assert.throws(() => store.run("UPDATE request_events SET kind='opened'"), /append-only/); assert.throws(() => store.run("UPDATE request_events SET kind='opened'"), /append-only/);
+10 -4
View File
@@ -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-')); const root = mkdtempSync(join(tmpdir(), 'request-migration-'));
t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 }); 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. // 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'); 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); assert.ok(currentDDL.endsWith(newDDL)); const oldDDL = currentDDL.slice(0, -newDDL.length);
const { schemaDigest, SCHEMA_VERSION } = awaitImportStore; 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); db.close(); chmodSync(path, 0o600); const before = readFileSync(path);
assert.throws(() => new Store(root), /schema-mismatch/); assert.deepEqual(readFileSync(path), before); 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' }); 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'); 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); 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); 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 }); t.after(() => rmSync(root, { recursive: true, force: true })); mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'); const path = join(root, 'bus/bus.sqlite');
// The 3o schema is the current one with the narrower kind list. // 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 { schemaDigest } = awaitImportStore, AT = '2026-11-03T00:00:00.000Z';
const db = new DatabaseSync(path); db.exec(ddl3o); const db = new DatabaseSync(path); db.exec(ddl3o);
db.prepare('INSERT INTO meta VALUES(?,?)').run('schema_version', '3o'); 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); db.close(); chmodSync(path, 0o600);
assert.throws(() => new Store(root), /schema-mismatch/); 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' }); 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); 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_events ORDER BY seq'), before, 'every row keeps its seq and bytes');
assert.deepEqual(store.all('SELECT * FROM request_effects ORDER BY seq'), effects); assert.deepEqual(store.all('SELECT * FROM request_effects ORDER BY seq'), effects);
+4 -3
View File
@@ -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 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. // 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. // #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. // Task 4 (3p to 3q), Task 3 (3q to 3r) and Task 6b (3r to 3s) widen request_events.kind in place.
assert.equal(count('table') - 1, 30); // #797 MVP3 (3s to 3t) adds project_archives and its five guards; project-archive.test.mjs tests them.
assert.equal(count('trigger'), 114); assert.equal(count('table') - 1, 31);
assert.equal(count('trigger'), 119);
assert.equal(count('view'), 5); assert.equal(count('view'), 5);
}); });
+4 -4
View File
@@ -530,9 +530,9 @@ test('T4 schema: a 3m store migrates to 3n, session_ops and message_cancels are
const before = tables(path); const before = tables(path);
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close(); new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3s'); assert.equal(version(path), '3t');
const after = tables(path); 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); assert.deepEqual(after[name], [], name);
delete after[name]; delete after[name];
} }
@@ -571,7 +571,7 @@ async function engine3m(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8'); const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source 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_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'); .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'); 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); 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); const Store3m = await engine3m(root);
assert.throws(() => new Store3m(root), /schema-mismatch/); assert.throws(() => new Store3m(root), /schema-mismatch/);
assert.throws(() => new Store3m(root, { migrateSchema: true }), /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(master(path), was);
assert.deepEqual(tables(path), before); 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. // The same engine opens a fresh 3m store, so the refusal is the version's and not a broken copy.
+3 -3
View File
@@ -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); const { root, path } = source3k(t);
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close(); 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. // The migrated schema is the fresh one, object for object.
const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-')); const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true })); 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 source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source 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_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'); .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'); 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); 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); const Store3k = await engine3k(root);
assert.throws(() => new Store3k(root), /schema-mismatch/); assert.throws(() => new Store3k(root), /schema-mismatch/);
assert.throws(() => new Store3k(root, { migrateSchema: true }), /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(master(path), was);
assert.deepEqual(rows(path), before); 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. // The same engine opens a fresh 3k store, so the refusal is the version's and not a broken copy.
+1 -1
View File
@@ -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/); assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); const store = new Store(root, { migrateSchema: true });
try { 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.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.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []); assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
+3 -3
View File
@@ -49,7 +49,7 @@ async function database3d(t) {
const source = readFileSync(here('../src/store.mjs'), 'utf8'); const source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source 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_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'); .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'); assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned); 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/); assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); const store = new Store(root, { migrateSchema: true });
t.after(() => store.close()); 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']) for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table); 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. // 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 command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' }); const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr); 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'));
}); });
+4 -4
View File
@@ -403,12 +403,12 @@ test('a 3l store migrates to 3m: thread_shares is new and empty, and every other
const before = tables(path); const before = tables(path);
assert.throws(() => new Store(root), /schema-mismatch/); assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close(); new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3s'); assert.equal(version(path), '3t');
const after = tables(path); const after = tables(path);
assert.deepEqual(after.thread_shares, []); assert.deepEqual(after.thread_shares, []);
delete after.thread_shares; delete after.thread_shares;
// Row 271 (#1776): the 3m→3n step's session_ops and message_cancels, new and empty too. // 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); assert.deepEqual(after[name], [], name);
delete after[name]; delete after[name];
} }
@@ -439,7 +439,7 @@ async function engine3l(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8'); const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source 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_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'); .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'); 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); 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); const Store3l = await engine3l(root);
assert.throws(() => new Store3l(root), /schema-mismatch/); assert.throws(() => new Store3l(root), /schema-mismatch/);
assert.throws(() => new Store3l(root, { migrateSchema: true }), /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(master(path), was);
assert.deepEqual(tables(path), before); 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. // The same engine opens a fresh 3l store, so the refusal is the version's and not a broken copy.
+1 -1
View File
@@ -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/); assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true }); const store = new Store(root, { migrateSchema: true });
try { 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.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 count(*) AS n FROM usage_records').n, 0);
assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder'); assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder');
+8 -2
View File
@@ -33,7 +33,9 @@ mosaic trail <task|decision> [--business <id>] [--json]
mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>] mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>]
mosaic follow [--business <id>] mosaic follow [--business <id>]
mosaic project create <name> [--repo <repo>] [--business <id>] [--tester] mosaic project create <name> [--repo <repo>] [--business <id>] [--tester]
mosaic project list [--business <id>] [--json] [--tester] mosaic project list [--archived] [--business <id>] [--json] [--tester]
mosaic project archive <name-or-id> [--business <id>]
mosaic project unarchive <name-or-id> [--business <id>]
mosaic threads [--project <project>] [--business <id>] [--json] [--tester] mosaic threads [--project <project>] [--business <id>] [--json] [--tester]
mosaic thread create <project> <name> [--business <id>] [--tester] mosaic thread create <project> <name> [--business <id>] [--tester]
mosaic thread open <thread> [--business <id>] [--tester] mosaic thread open <thread> [--business <id>] [--tester]
@@ -99,7 +101,11 @@ mosaic launches list [--json]
the bus record; `project init` and `project check` (`docs/TOOLS.md`, "Local 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 records") work on the repository on disk and keep their arguments.
`project list` prints name, repo (`-` for none) and the first 8 characters of `project list` prints name, repo (`-` for none) and the first 8 characters of
the id. `threads` prints `<project>/<name>`, the instance and the short id of the id; `--archived` includes archived projects, each marked `archived`.
`project archive <name-or-id>` (#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 <name-or-id>` restores it (resolution reads
the archived list too, so an archived project can be named). `threads` prints `<project>/<name>`, the instance and the short id of
the caller's threads, optionally for one project. `thread create` prints the 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 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 launcher reads; `thread read` pages the thread's messages oldest first and
+1 -1
View File
@@ -639,7 +639,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
if (argv.length === 0) return app(io, deps); if (argv.length === 0) return app(io, deps);
const [verb, ...rest] = argv; const [verb, ...rest] = argv;
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps); 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 === "threads") return threadsCommand(rest, io, (p) => channelCallFor(p, io, deps));
if (verb === "session") return sessionCommand(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); if (verb === "thread") return threadCommand(rest, io, (p) => channelCallFor(p, io, deps), formatMessage);
+28 -6
View File
@@ -12,17 +12,23 @@ export const PROJECT_USAGE = 'mosaic project init <business> <project> [--tester
// init --tester (row 175, #1673) binds the tester once before it writes. // init --tester (row 175, #1673) binds the tester once before it writes.
export const PROJECT_BUS_USAGE = [ export const PROJECT_BUS_USAGE = [
'mosaic project create <name> [--repo <repo>] [--business <id>] [--tester]', 'mosaic project create <name> [--repo <repo>] [--business <id>] [--tester]',
'mosaic project list [--business <id>] [--json] [--tester]', 'mosaic project list [--archived] [--business <id>] [--json] [--tester]',
'mosaic project archive <name-or-id> [--business <id>]',
'mosaic project unarchive <name-or-id> [--business <id>]',
'A thread project named for a business repository must carry that repo: --repo <name>.', 'A thread project named for a business repository must carry that repo: --repo <name>.',
'list --archived includes archived projects, each marked; archive is reversible and deletes nothing.',
].join('\n '); ].join('\n ');
export async function projectBusCommand(args, io, callFor) { export async function projectBusCommand(args, io, callFor) {
const [sub, ...rest] = args; const [sub, ...rest] = args;
const parsed = parseArgs(rest, PROJECT_BUS_USAGE, sub === 'create' const parsed = parseArgs(rest, PROJECT_BUS_USAGE, sub === 'create'
? { flags: ['--tester'], values: ['--repo', '--business'] } ? { flags: ['--tester'], values: ['--repo', '--business'] }
: { flags: ['--json', '--tester'], values: ['--business'] }); : sub === 'list'
if (parsed.positional.length !== (sub === 'create' ? 1 : 0)) ? { flags: ['--json', '--tester', '--archived'], values: ['--business'] }
throw new CliError(`project ${sub} takes ${sub === 'create' ? 'one name' : 'no arguments'}\n${PROJECT_BUS_USAGE}`, 4); : { 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); const call = callFor(parsed);
if (sub === 'create') { if (sub === 'create') {
const [name] = parsed.positional; 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`); 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`); 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 // Row 175 (#1673, Q173, mos-0872 Q2): `init --tester` may run inside an agent. Before anything
+65
View File
@@ -60,3 +60,68 @@ test('project dispatcher reaches the human CLI', async t => {
assert.equal(result.status, 4); assert.equal(result.status, 4);
assert.match(result.stderr, /mosaic project/); 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`);
});
+2 -2
View File
@@ -306,7 +306,7 @@ test('#811 migration readers follow the engine order and refuse malformed pairs'
assert.notEqual(engineSource,source); assert.notEqual(engineSource,source);
const engine=await import(`data:text/javascript;base64,${Buffer.from(engineSource).toString('base64')}`); const engine=await import(`data:text/javascript;base64,${Buffer.from(engineSource).toString('base64')}`);
const pairs=engine.migrations.map(({from,to})=>[from,to]); 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 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 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'); 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]; 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 // sage-1163's list 2, written out so that a table dropped from the script's COUNTED fails T45 as
// well as T46. // 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 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()}; 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()};
+2
View File
@@ -4,6 +4,8 @@ export const interfaceVersion = 1;
const VERBS = new Set([ const VERBS = new Set([
"project.list", "project.list",
"project.create", "project.create",
"project.archive",
"project.unarchive",
"thread.list", "thread.list",
"thread.create", "thread.create",
"thread.messages", "thread.messages",
+2
View File
@@ -7,6 +7,8 @@ const VERBS = new Set([
"room.send", "room.send",
"project.list", "project.list",
"project.create", "project.create",
"project.archive",
"project.unarchive",
"thread.list", "thread.list",
"thread.create", "thread.create",
"thread.messages", "thread.messages",
+5
View File
@@ -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 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 **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. 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 All four POST operations need `--sso --send`, the configured public Host, a
valid OIDC session, the Console's HTTPS Origin, same-origin Fetch valid OIDC session, the Console's HTTPS Origin, same-origin Fetch
+4 -1
View File
@@ -25,8 +25,11 @@ export function createChat({ call, owner = null }) {
roomOpen: ({ project }) => channel('room.open', { project }), roomOpen: ({ project }) => channel('room.open', { project }),
roomRead: ({ project, after, limit }) => channel('room.read', { project, ...(after ? { after } : {}), ...(limit === undefined ? {} : { limit }) }), roomRead: ({ project, after, limit }) => channel('room.read', { project, ...(after ? { after } : {}), ...(limit === undefined ? {} : { limit }) }),
roomSend: ({ project, body }) => channel('room.send', { project, body }), 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 }) }), 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 } : {}) }), threads: ({ project, shared } = {}) => channel('thread.list', { ...(project === undefined ? {} : { project }), ...(shared === true ? { shared: true } : {}) }),
shares: ({ thread }) => channel('thread.shares', { thread }), shares: ({ thread }) => channel('thread.shares', { thread }),
share: ({ thread, to }) => channel('thread.share', { thread, to }), share: ({ thread, to }) => channel('thread.share', { thread, to }),
@@ -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);
}
}
+2
View File
@@ -35,6 +35,7 @@
function forget() { function forget() {
generation++; controller?.abort(); states.clear(); forms.clear(); libraries.clear(); reverts.clear(); generation++; controller?.abort(); states.clear(); forms.clear(); libraries.clear(); reverts.clear();
window.MosaicRequests?.forget(); window.MosaicRequests?.forget();
window.MosaicProjectArchive?.forget();
if (current) current.container.replaceChildren(); if (current) current.container.replaceChildren();
current = null; current = null;
} }
@@ -397,6 +398,7 @@
if (!c.canSend) html += '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>'; if (!c.canSend) html += '<p class="muted">Creating and sending require signed-in send mode. This Console only reads.</p>';
c.container.innerHTML = html + '</div>'; c.container.innerHTML = html + '</div>';
window.MosaicRequests?.mount(c.container); window.MosaicRequests?.mount(c.container);
window.MosaicProjectArchive?.mount(c.container, c);
document.title = `${c.container.querySelector('h1')?.textContent ?? 'Chat'} · Mosaic Console`; document.title = `${c.container.querySelector('h1')?.textContent ?? 'Chat'} · Mosaic Console`;
const log = c.container.querySelector('.chat-log'); const log = c.container.querySelector('.chat-log');
if (log) { if (log) {
+2 -1
View File
@@ -3,8 +3,9 @@
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Mosaic Console</title> <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Mosaic Console</title>
<link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/chat.css"><link rel="stylesheet" href="/shell.css"> <link rel="stylesheet" href="/shared/app.css"><link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/console.css"><link rel="stylesheet" href="/live.css"><link rel="stylesheet" href="/bus.css"><link rel="stylesheet" href="/chat.css"><link rel="stylesheet" href="/shell.css">
<link rel="stylesheet" href="/editor.css"><script src="/editor.js" defer></script> <link rel="stylesheet" href="/editor.css"><script src="/editor.js" defer></script>
<link rel="stylesheet" href="/project-archive.css">
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script> <link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
<script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/chat.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head> <script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
<body> <body>
<a class="skip" href="#main">Skip to content</a> <a class="skip" href="#main">Skip to content</a>
<header class="cmdbar"><a href="/" class="wordmark"><svg class="mark" viewBox="0 0 16 17" aria-hidden="true"><use href="/icons.svg#mark"/></svg>Mosaic<small>Console</small></a> <header class="cmdbar"><a href="/" class="wordmark"><svg class="mark" viewBox="0 0 16 17" aria-hidden="true"><use href="/icons.svg#mark"/></svg>Mosaic<small>Console</small></a>
@@ -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;
}
@@ -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 });
})();
+9 -2
View File
@@ -11,6 +11,7 @@ const busStatus = code => ['tester-off', 'tester-required'].includes(code) ? 403
import { ReadError, readStatus, fieldTable } from './reads.mjs'; import { ReadError, readStatus, fieldTable } from './reads.mjs';
import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs'; import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs';
import { chatHTTP } from './chat-http.mjs'; import { chatHTTP } from './chat-http.mjs';
import { projectArchiveHTTP } from './project-archive-http.mjs';
import { processingHTTP } from './processing-http.mjs'; import { processingHTTP } from './processing-http.mjs';
import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } from './decision-http.mjs'; import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } from './decision-http.mjs';
import { REQUEST_ROUTE, requestHTTP, requestRefuse } from './request-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']], ['/', ['index.html', 'text/html; charset=utf-8']],
['/settings/model-proxy', ['proxy.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']], ['/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']]), ...['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', 'processing.css'].map(f => ['/' + f, [f, 'text/css; 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']], ['/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. // 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']], ['/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; 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. // 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; 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) { async function busRead(res, bus, verb, search) {
let arg; let arg;
if (verb === 'thread.claims' && search) return json(res, 400, { error: 'invalid-request', message: 'thread.claims takes no arguments' }); 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 }); 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/')) { if (path === '/api/chat' || path.startsWith('/api/chat/')) {
return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log }); return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log });
} }
+2 -2
View File
@@ -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'); assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
await b.evaluate('document.querySelector("#hide-offline").click()'); await b.evaluate('document.querySelector("#hide-offline").click()');
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4); 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. // 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', '/agents.js', '/bus.js', '/requests.js']); 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="/processing.js"]').length`), 1);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1); assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
assert.equal(await b.evaluate('!!window.injected'), false); assert.equal(await b.evaluate('!!window.injected'), false);
@@ -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');
});
+1 -1
View File
@@ -419,7 +419,7 @@ plugin_checks() {
# Row 181: the tables a bus writer fills without an event in the same transaction, in schema # 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. # 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. # 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=' STORE_JS='
const {DatabaseSync}=require("node:sqlite");const {pathToFileURL}=require("node:url"); const {DatabaseSync}=require("node:sqlite");const {pathToFileURL}=require("node:url");