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:
1 parent
05fdfef9ea
commit
3e21d239b3
42 files changed
+958
-80
No files matched your search
@@ -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
|
||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) });
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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'), []);
|
||||||
|
|||||||
@@ -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'));
|
||||||
});
|
});
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
});
|
||||||
@@ -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()};
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
})();
|
||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
|||||||
Reference in new issue
Block a user