bus, cli, business, harness: messages addressed by agent name (row 141, #1635)
Row 141 round 1, Darkwing's candidate (agents/darkwing/work/queue-141/, build.patch bb036c6d, manifest d474b330, 26 paths, base18aee8fa1), rebased without change by Sage onto7e40fb83bas round 2 (agents/sage/work/queue-141-r2/, build.patch 72b890d9, manifest cae29087). Row 148 moved two of the paths, packages/bus/README.md and packages/bus/src/broker.mjs; the interdiff touches only index and @@ lines. Filbert approved round 1 (comment 28841). Schema 3f->3g adds instance_names, an append-only log whose latest row per instance is its current name. message.send resolves a to that is not an instance key or a role through the names table, and records to_name. The new bus writer name.set and read names back the CLI verbs mosaic name INSTANCE NAME (human-only) and mosaic names [--json]. `instance.name` is routed under REQ-MSG-2 (1.5). It joins ACTIONS and ROUTED in the vocabulary and the broker, and roles/chief-of-staff.json grants it within-role, as row 139's brief asks of whichever of 139 and 141 lands second. The chief has no tool for it until row 155, so the chief's untypedActions and bundle untyped list are ["instance.name"] (harness tools.test.mjs:43, bundle.test.mjs:94). Follow-ups: Vikunja P19 tasks for TOOLS.md and acceptance 314 step 10 (Filbert, doc-only) and for refusing a new key or definition equal to a current name. Row 155's brief carries the return of untyped to []. Sage's landing gate ran on7e40fb83bplus the candidate: 25 steps, all rc 0 (15 packages, 10 script suites), 22:30:48Z to 22:35:15Z, network sage-r141_default removed. The commit sits on2a85b41da, which adds one docs/SESSIONS.md line that no test reads. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
+13
-5
@@ -187,9 +187,11 @@ an operator inspect content and delivery evidence after an uncertain result.
|
||||
| `decision.resolve` | `{id,choice,note?}`; option must exist, resolver must be routed role or outside-agent human CLI. Sends the raising role a DECISION message (Decision close messages, below). |
|
||||
| `decision.seen` | `{id,note?}`; acknowledgement by resolver, not a resolution. |
|
||||
| `decision.withdraw` | `{id,note?}`; raiser or human. Never rewrites the decision. A human withdraw sends the raising role a DECISION message; the raising run's own withdraw sends none. |
|
||||
| `message.send` | `{to,body,class?,in_reply_to?,corrects?,decision?}`; returns `{id,request}`, plus `waiting` when no run holds the target (Launch notices, below). Human instruction's `request` is its `human.input` event ID. |
|
||||
| `message.send` | `{to,body,class?,in_reply_to?,corrects?,decision?}`; returns `{id,request}`, plus `waiting` when no run holds the target (Launch notices, below). Human instruction's `request` is its `human.input` event ID. `to` is a role instance, its name (row 141, #1635, REQ-MSG-2), `human` or `tester`. A name resolves to its instance before any check; the message stores the instance in `to_role` and the name in `to_name`, null for a send by instance. An instance key, `human`, `tester` and a role definition the business knows win over a name equal to them, so an instance added later with a key some instance holds as a name still gets its messages, and a send to a later definition's name refuses `unknown-role`. A name the business doesn't hold refuses `unknown-role`. |
|
||||
| `message.receive` | No args; up to 100 undelivered messages for current holder, including originating `request` for human instructions. A human decision close's message carries the close's `human.input` answer event as its `request`; a role close's has `null`. |
|
||||
| `message.read` | `{id}`; recipient holder's acknowledgement; no new holder can acknowledge old holder's delivery. |
|
||||
| `name.set` | `{instance,name}`; action `instance.name`, routed under REQ-MSG-2 (row 141, #1635). The outside-agent human CLI may; an agent only when its role file grants `instance.name` within-role, which the chief of staff's does, and the write logs `action.allowed` with the instance as target. Any other class refuses `not-authorized`, so no decision sets a name. `name` matches the business ID pattern. It refuses `name-is-instance` when it equals an instance key, `human` or `tester`, `name-is-role` when it equals a role definition the loaded business knows (one an instance uses or one it defines), and `name-taken` when another instance of the business holds it now. The `instance_names` trigger backs up the last. Naming an instance again replaces its name. Returns `{instance,name}`. |
|
||||
| `names` | No args; human and agent sessions. Each named instance's current `{instance,name,at}`, by instance. |
|
||||
| `staff.request` | `{task_ref,roles,note?,decision?}`; action `message.send` (row 140, #1634, REQ-COS-1). `roles` is 1 to 10 distinct `{role,count}` entries, `count` an integer 1 to 20; any other shape is `invalid-request`. Only an instance of the `pm` definition sends it (`staff-request-refused`), and it goes to the business's launcher, which must be an instance of `chief-of-staff` (`no-chief-of-staff`); the definitions' `messaging` lists apply as for `message.send`. Sends that instance a STAFF-REQUEST message naming the task and the roles with counts, and writes an `action.allowed` event with `operation: staff.request`, `staff_for` and `roles`. `message.send` and `thread.send` refuse the class `invalid-class`, so no other path writes one. It grants nothing: the chief of staff launches through `role.launch` within the organization's limits and replies to the message with the instances it launched or the refusal. Returns `{message,to}`. |
|
||||
| `review.request` | `{task_ref,commit,to,note?,decision?}`; action `review.request` (row 93, #1587). `commit` must be the task's latest pushed commit: an older one refuses `stale-commit`, any other `unknown-commit`. `to` must be another role holding `review.verdict` (`unknown-role`, `self-review`, `not-a-reviewer`). Writes a `review.requested` event `{task_ref,project,ref,commit,to,message,held}` and sends `to` a REVIEW-REQUEST message naming it. `held` is the run id holding the `to` role when the request is routed (row 105, #1599), or `null` when no run holds it: the message then waits for a claim, and the requester should tell whoever launches roles. Returns `{request,message,commit,ref,held}`. |
|
||||
| `review.verdict` | `{task_ref,request,verdict,body,decision?}`; action `review.verdict`. `verdict` is `approve` or `changes`. `request` must be a `review.requested` event on the task addressed to the caller's role (`request-not-found`), with no verdict yet (`verdict-exists`). Writes a `review.verdict` event `{task_ref,request,commit,verdict,message}` and sends the requesting role a REVIEW-RESULT in reply to the request's message. Returns `{verdict,message,commit}`. |
|
||||
@@ -641,10 +643,10 @@ scope probes and rotation/expiry notifications; S2 never probes real services.
|
||||
|
||||
## Schema and verification
|
||||
|
||||
`schema.sql` is v3f, SHA-256
|
||||
`472cbcd0d4548848873f3029e8250dc07bba4fb8db2a93241aae5b8e7e3771e2`.
|
||||
It has 14 tables, 64 triggers and five views. Source digest and a trusted full
|
||||
sqlite_master digest are checked at open, alongside schema version `3f`, WAL,
|
||||
`schema.sql` is v3g, SHA-256
|
||||
`a79b3dbe6f4dfba10871c01ad8513ea290071eb8d8af2b87be6db52dbcf23428`.
|
||||
It has 15 tables, 68 triggers and five views. Source digest and a trusted full
|
||||
sqlite_master digest are checked at open, alongside schema version `3g`, WAL,
|
||||
quick_check and foreign-key integrity. Missing metadata refuses; it never
|
||||
reinitializes an existing file. Full synchronous WAL transactions either commit
|
||||
or roll back. SQL UPDATE, DELETE and INSERT OR REPLACE refuse on evidence tables.
|
||||
@@ -763,3 +765,9 @@ with nulls on existing rows. It changes no event or resolution and creates no
|
||||
spec/confirmation tables. The 3e fixture is pinned from 26a0b0fab's tree; no live
|
||||
store is used. Mos runs this step only in an approved stopped-bus/re-pin window,
|
||||
as described in the candidate packet's MIGRATION.md.
|
||||
|
||||
The 3f→3g step (row 141, #1635, REQ-MSG-2) adds the append-only `instance_names` table,
|
||||
its triggers, and a nullable `to_name` column on messages with nulls on existing rows. It
|
||||
changes no other row. The 3f fixture, `tests/fixtures/schema-3f.sql`, is pinned from
|
||||
4ebef2284's tree; no live store is used. The step runs only in its own stopped-bus/re-pin
|
||||
window on Mos's go.
|
||||
|
||||
+17
-1
@@ -79,7 +79,7 @@ CREATE TABLE messages (
|
||||
decision TEXT REFERENCES decisions(id),
|
||||
corrects TEXT REFERENCES messages(id),
|
||||
body TEXT NOT NULL
|
||||
) STRICT;
|
||||
, to_name TEXT) STRICT;
|
||||
CREATE TABLE deliveries (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message TEXT NOT NULL REFERENCES messages(id),
|
||||
@@ -319,3 +319,19 @@ CREATE TRIGGER message_threads_no_replace BEFORE INSERT ON message_threads WHEN
|
||||
CREATE TRIGGER thread_opens_no_update BEFORE UPDATE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
|
||||
CREATE TRIGGER thread_opens_no_delete BEFORE DELETE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
|
||||
CREATE TRIGGER thread_opens_no_replace BEFORE INSERT ON thread_opens WHEN EXISTS (SELECT 1 FROM thread_opens WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
|
||||
CREATE TABLE instance_names (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
instance TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
by TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE TRIGGER instance_names_unique BEFORE INSERT ON instance_names
|
||||
WHEN EXISTS (SELECT 1 FROM instance_names n WHERE n.business = NEW.business AND n.name = NEW.name
|
||||
AND n.instance <> NEW.instance
|
||||
AND n.seq = (SELECT max(seq) FROM instance_names WHERE business = n.business AND instance = n.instance))
|
||||
BEGIN SELECT RAISE(ABORT, 'an instance name is unique in its business'); END;
|
||||
CREATE TRIGGER instance_names_no_update BEFORE UPDATE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
|
||||
CREATE TRIGGER instance_names_no_delete BEFORE DELETE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
|
||||
CREATE TRIGGER instance_names_no_replace BEFORE INSERT ON instance_names WHEN EXISTS (SELECT 1 FROM instance_names WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
|
||||
|
||||
+78
-11
@@ -1,6 +1,7 @@
|
||||
import { randomUUID, randomBytes } from 'node:crypto';
|
||||
import { loadRequirements } from '../../tasks/src/requirements.mjs';
|
||||
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
|
||||
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
|
||||
|
||||
// Kept closed while S1 lands; its vocabulary is the integration authority.
|
||||
export const ACTIONS = Object.freeze([
|
||||
@@ -24,6 +25,8 @@ export const ACTIONS = Object.freeze([
|
||||
'role.revoke',
|
||||
'role.end',
|
||||
'organization.change',
|
||||
// Row 141 (#1635): routed under PRD 1.5 REQ-MSG-2.
|
||||
'instance.name',
|
||||
'credential.mint',
|
||||
'spend',
|
||||
'deploy',
|
||||
@@ -55,6 +58,7 @@ const ROUTED = new Set([
|
||||
'role.revoke',
|
||||
'role.end',
|
||||
'organization.change',
|
||||
'instance.name',
|
||||
'policy.change',
|
||||
'prd.approve',
|
||||
'decision.resolve.technical',
|
||||
@@ -544,6 +548,22 @@ export class Broker {
|
||||
#sender(s) {
|
||||
return s.human ? 'human' : s.tester ? 'tester' : s.role;
|
||||
}
|
||||
// Row 141 (#1635, REQ-MSG-2): the role names a name may not equal, which are the definitions the
|
||||
// loaded business knows (sage-0590). The broker doesn't read roles/.
|
||||
#definitions(b) {
|
||||
return new Set([...Object.keys(b.definitions ?? {}), ...Object.values(b.roles).map((r) => r.definition)]);
|
||||
}
|
||||
// Row 141 (#1635, REQ-MSG-2): the instance that currently holds `name` in the business, or null.
|
||||
// An instance's latest instance_names row is its current name.
|
||||
#named(business, name) {
|
||||
return (
|
||||
this.#store.get(
|
||||
'SELECT instance FROM instance_names n WHERE business=? AND name=? AND seq=(SELECT max(seq) FROM instance_names WHERE business=n.business AND instance=n.instance)',
|
||||
business,
|
||||
name,
|
||||
)?.instance ?? null
|
||||
);
|
||||
}
|
||||
#decision(s, id) {
|
||||
const d = this.#store.get('SELECT * FROM decisions WHERE id=? AND business=?', id, s.business);
|
||||
if (!d) fail('decision-not-found');
|
||||
@@ -1577,30 +1597,39 @@ export class Broker {
|
||||
}
|
||||
case 'message.send': {
|
||||
keys(a, ['to', 'body', 'class', 'in_reply_to', 'corrects', 'decision'], ['to', 'body']);
|
||||
// Row 141 (#1635, REQ-MSG-2): `to` is an instance key, an instance's name, "human" or
|
||||
// "tester". A name resolves to its instance first, so every check below reads the instance
|
||||
// key, and the message records both. name.set refuses a name equal to a key or a role, but an
|
||||
// instance or definition added later can take a word some instance holds as its name. The key
|
||||
// or the role wins: a send to a definition no instance is keyed by refuses unknown-role.
|
||||
const named = typeof a.to === 'string' && !Object.hasOwn(b.roles, a.to) && a.to !== 'human' && a.to !== 'tester'
|
||||
&& !this.#definitions(b).has(a.to)
|
||||
? this.#named(s.business, a.to) : null;
|
||||
const to = named ?? a.to;
|
||||
if (s.tester) {
|
||||
// A tester writes to a role only, says so in its first words, and cites no decision.
|
||||
// It consumes no authority and writes no human.input.
|
||||
keys(a, ['to', 'body', 'class', 'in_reply_to'], ['to', 'body']);
|
||||
if (!Object.hasOwn(b.roles, a.to)) fail('unknown-role');
|
||||
if (!Object.hasOwn(b.roles, to)) fail('unknown-role');
|
||||
if (typeof a.body !== 'string' || !a.body.startsWith(TESTER_PREFIX)) fail('tester-prefix');
|
||||
} else if (!s.human) {
|
||||
const withinRole = this.#classification(s, 'message.send') === 'within-role';
|
||||
this.#consumeAuthority(s, 'message.send', {
|
||||
decision: withinRole ? null : (a.decision ?? null),
|
||||
target: a.to,
|
||||
target: to,
|
||||
});
|
||||
// A role answers a tester only in reply to a tester's message.
|
||||
if (
|
||||
a.to === 'tester' &&
|
||||
to === 'tester' &&
|
||||
!(id(a.in_reply_to) && this.#store.get("SELECT 1 FROM messages WHERE id=? AND business=? AND from_role='tester'", a.in_reply_to, s.business))
|
||||
)
|
||||
fail('tester-reply-required');
|
||||
} else {
|
||||
this.#human(s);
|
||||
if (a.to === 'tester') fail('unknown-role');
|
||||
if (to === 'tester') fail('unknown-role');
|
||||
}
|
||||
if (a.to !== 'human' && a.to !== 'tester' && !Object.hasOwn(b.roles, a.to)) fail('unknown-role');
|
||||
this.#checkMessaging(s.business, this.#sender(s), a.to);
|
||||
if (to !== 'human' && to !== 'tester' && !Object.hasOwn(b.roles, to)) fail('unknown-role');
|
||||
this.#checkMessaging(s.business, this.#sender(s), to);
|
||||
string(a.body, 32768);
|
||||
if (
|
||||
a.class !== undefined &&
|
||||
@@ -1624,27 +1653,28 @@ export class Broker {
|
||||
}
|
||||
}
|
||||
if (a.decision) this.#decision(s, a.decision);
|
||||
const thread = this.#threadFor(s, a.to, a.in_reply_to);
|
||||
const thread = this.#threadFor(s, to, a.in_reply_to);
|
||||
const mid = randomUUID();
|
||||
this.#store.run(
|
||||
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,to_name,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
mid,
|
||||
this.#now(),
|
||||
s.business,
|
||||
s.human ? 'human' : s.role,
|
||||
s.human ? 'human-cli' : s.tester ? 'tester-cli' : s.run,
|
||||
a.to,
|
||||
to,
|
||||
named ? a.to : null,
|
||||
a.class ?? 'INFO',
|
||||
a.in_reply_to ?? null,
|
||||
a.decision ?? null,
|
||||
a.corrects ?? null,
|
||||
a.body,
|
||||
);
|
||||
this.#route(s.business, mid, a.to, thread);
|
||||
this.#route(s.business, mid, to, thread);
|
||||
const request = s.human
|
||||
? this.#event(s, 'human.input', { kind: 'instruction', message: mid }, mid)
|
||||
: null;
|
||||
const waiting = this.#waiting(s, mid, a.to, thread);
|
||||
const waiting = this.#waiting(s, mid, to, thread);
|
||||
return waiting ? { id: mid, request, waiting } : { id: mid, request };
|
||||
}
|
||||
case 'message.receive': {
|
||||
@@ -1958,6 +1988,43 @@ export class Broker {
|
||||
// --read`, row 150); since row 142 `mosaic talk` prints only replies to its own message.
|
||||
// An id naming no message in the business refuses message-not-found, as message.send does
|
||||
// for in_reply_to; trail would return [].
|
||||
// Row 141 (#1635, REQ-MSG-2): the human names an instance through the CLI; an agent may only
|
||||
// with `instance.name` granted within-role, which the chief of staff holds. Any other class
|
||||
// refuses here, so neither a cross-role grant nor a decision can set a name. A name is unique
|
||||
// in the business (the store's trigger backs this up) and equals no instance key, no role
|
||||
// definition, "human" or "tester", so `to` never means two things.
|
||||
case 'name.set': {
|
||||
keys(a, ['instance', 'name'], ['instance', 'name']);
|
||||
if (!s.human && this.#classification(s, 'instance.name', a.instance) !== 'within-role') {
|
||||
this.#agent(s);
|
||||
fail('not-authorized');
|
||||
}
|
||||
if (s.human) this.#human(s);
|
||||
if (!Object.hasOwn(b.roles, identifier(a.instance))) fail('unknown-role');
|
||||
if (typeof a.name !== 'string' || !ID_PATTERN.test(a.name)) fail('invalid-request');
|
||||
if (Object.hasOwn(b.roles, a.name) || a.name === 'human' || a.name === 'tester') fail('name-is-instance');
|
||||
if (this.#definitions(b).has(a.name)) fail('name-is-role');
|
||||
const holder = this.#named(s.business, a.name);
|
||||
if (holder && holder !== a.instance) fail('name-taken');
|
||||
if (!s.human) this.#consumeAuthority(s, 'instance.name', { target: a.instance });
|
||||
this.#store.run(
|
||||
'INSERT INTO instance_names(at,business,instance,name,by) VALUES(?,?,?,?,?)',
|
||||
this.#now(),
|
||||
s.business,
|
||||
a.instance,
|
||||
a.name,
|
||||
s.human || s.run,
|
||||
);
|
||||
return { instance: a.instance, name: a.name };
|
||||
}
|
||||
case 'names': {
|
||||
keys(a, []);
|
||||
if (!s.human) this.#agent(s);
|
||||
return this.#store.all(
|
||||
'SELECT instance,name,at FROM instance_names n WHERE business=? AND seq=(SELECT max(seq) FROM instance_names WHERE business=n.business AND instance=n.instance) ORDER BY instance',
|
||||
s.business,
|
||||
);
|
||||
}
|
||||
// Row 121 (#1615): projects group the user's threads (REQ-ORG-2). `repo` names a
|
||||
// project from the business file; the rename to repos is a P19 follow-up.
|
||||
case 'project.create': {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Row 141 (#1635, REQ-MSG-2): a role instance's name. Append-only; an instance's latest row is its
|
||||
-- current name. The broker checks the name against the business file's instance keys and roles.
|
||||
-- messages.to_name records the name a send used; to_role stays the instance key.
|
||||
ALTER TABLE messages ADD COLUMN to_name TEXT;
|
||||
CREATE TABLE instance_names (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
instance TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
by TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE TRIGGER instance_names_unique BEFORE INSERT ON instance_names
|
||||
WHEN EXISTS (SELECT 1 FROM instance_names n WHERE n.business = NEW.business AND n.name = NEW.name
|
||||
AND n.instance <> NEW.instance
|
||||
AND n.seq = (SELECT max(seq) FROM instance_names WHERE business = n.business AND instance = n.instance))
|
||||
BEGIN SELECT RAISE(ABORT, 'an instance name is unique in its business'); END;
|
||||
CREATE TRIGGER instance_names_no_update BEFORE UPDATE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
|
||||
CREATE TRIGGER instance_names_no_delete BEFORE DELETE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
|
||||
CREATE TRIGGER instance_names_no_replace BEFORE INSERT ON instance_names WHEN EXISTS (SELECT 1 FROM instance_names WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
|
||||
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
|
||||
export const SCHEMA_SHA256 = '472cbcd0d4548848873f3029e8250dc07bba4fb8db2a93241aae5b8e7e3771e2';
|
||||
export const SCHEMA_VERSION = '3f';
|
||||
export const SCHEMA_SHA256 = 'a79b3dbe6f4dfba10871c01ad8513ea290071eb8d8af2b87be6db52dbcf23428';
|
||||
export const SCHEMA_VERSION = '3g';
|
||||
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
|
||||
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
|
||||
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
|
||||
@@ -42,6 +42,11 @@ const migrations = [{
|
||||
from: '3e',
|
||||
to: '3f',
|
||||
sql: readFileSync(new URL('./migrations/3e-3f.sql', import.meta.url), 'utf8'),
|
||||
}, {
|
||||
// Row 141 (#1635): instance names, and the name a message was sent to. Messages keep a null to_name.
|
||||
from: '3f',
|
||||
to: '3g',
|
||||
sql: readFileSync(new URL('./migrations/3f-3g.sql', import.meta.url), 'utf8'),
|
||||
}];
|
||||
const timedTables = [
|
||||
'events',
|
||||
@@ -55,6 +60,7 @@ const timedTables = [
|
||||
'threads',
|
||||
'thread_claims',
|
||||
'thread_opens',
|
||||
'instance_names',
|
||||
];
|
||||
const hex = (value) => createHash('sha256').update(value).digest('hex');
|
||||
if (hex(ddl) !== SCHEMA_SHA256) throw Error('schema-source-mismatch');
|
||||
|
||||
@@ -369,7 +369,7 @@ async function database3c(t) {
|
||||
let source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const pinned = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3f';", "export const SCHEMA_VERSION = '3c';")
|
||||
.replace("export const SCHEMA_VERSION = '3g';", "export const SCHEMA_VERSION = '3c';")
|
||||
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.notEqual(pinned, source);
|
||||
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
|
||||
@@ -393,7 +393,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
t.after(() => store.close());
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3f');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3g');
|
||||
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
|
||||
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
|
||||
const b = new Broker({ store, businesses: demo(), organization: null });
|
||||
|
||||
@@ -28,12 +28,12 @@ function legacy(t) {
|
||||
return { root, source, path };
|
||||
}
|
||||
|
||||
test('explicit 3b copy migration preserves evidence and opens as 3f; normal startup never migrates', t => {
|
||||
test('explicit 3b copy migration preserves evidence and opens as 3g; normal startup never migrates', t => {
|
||||
const { root, source } = legacy(t);
|
||||
const original = readFileSync(source);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3f');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3g');
|
||||
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
|
||||
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
|
||||
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
|
||||
@@ -62,7 +62,7 @@ test('migration refuses active writers and drifted legacy schemas, with no parti
|
||||
assert.throws(() => new Store(other.root, { migrateSchema: true }), /writer-locked/);
|
||||
});
|
||||
|
||||
test('named migration registry chains 3b through 3f to a fixture-only future version in order', async t => {
|
||||
test('named migration registry chains 3b through 3g to a fixture-only future version in order', async t => {
|
||||
const { root } = legacy(t);
|
||||
const { writeFileSync } = await import('node:fs');
|
||||
const { pathToFileURL } = await import('node:url');
|
||||
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3f to a fixture-only future ver
|
||||
const sha = createHash('sha256').update(futureDDL).digest('hex');
|
||||
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
|
||||
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3f';", "export const SCHEMA_VERSION = '3g';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3f', to: '3g', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
||||
.replace("export const SCHEMA_VERSION = '3g';", "export const SCHEMA_VERSION = '3h';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3g', to: '3h', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
|
||||
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
|
||||
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql'])
|
||||
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql'])
|
||||
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
|
||||
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
|
||||
const future = new FutureStore(root, { migrateSchema: true });
|
||||
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3g');
|
||||
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3h');
|
||||
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
|
||||
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
|
||||
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
|
||||
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
|
||||
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
|
||||
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
|
||||
assert.equal(migrated.status, 0, migrated.stderr);
|
||||
assert.match(migrated.stdout, /bus schema 3f ready/);
|
||||
assert.match(migrated.stdout, /bus schema 3g ready/);
|
||||
new Store(root).close();
|
||||
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
|
||||
assert.equal(usage.status, 2);
|
||||
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
|
||||
CREATE TABLE events (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN (
|
||||
'session.launched',
|
||||
'session.ended',
|
||||
'action.allowed',
|
||||
'action.refused',
|
||||
'task.created',
|
||||
'task.assigned',
|
||||
'task.state',
|
||||
'task.closed',
|
||||
'task.changed.external',
|
||||
'task.conflict',
|
||||
'task.missing',
|
||||
'review.requested',
|
||||
'review.verdict',
|
||||
'human.input',
|
||||
'config.refused',
|
||||
'credential.expiring',
|
||||
'credential.expired',
|
||||
'credential.changed',
|
||||
'launch.revoked',
|
||||
'launch.restored',
|
||||
'digest.sent')),
|
||||
actor_role TEXT, actor_run TEXT,
|
||||
subject TEXT,
|
||||
corrects TEXT REFERENCES events(id),
|
||||
body TEXT NOT NULL CHECK (json_valid(body))
|
||||
) STRICT;
|
||||
CREATE TABLE role_claims (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL, role TEXT NOT NULL,
|
||||
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
|
||||
holder_run TEXT NOT NULL,
|
||||
harness TEXT NOT NULL, address TEXT,
|
||||
by TEXT NOT NULL, reason TEXT,
|
||||
decision TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE decisions (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL, project TEXT,
|
||||
raised_by_role TEXT NOT NULL, raised_by_run TEXT NOT NULL,
|
||||
class TEXT NOT NULL CHECK (class IN ('routine','within-role','cross-role','gated')),
|
||||
action TEXT NOT NULL,
|
||||
route_to TEXT NOT NULL,
|
||||
question TEXT NOT NULL,
|
||||
options TEXT NOT NULL CHECK (json_valid(options) AND json_array_length(options) BETWEEN 2 AND 9),
|
||||
recommendation TEXT NOT NULL,
|
||||
task_ref TEXT CHECK (task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*')), requirement_ref TEXT,
|
||||
blocking INTEGER NOT NULL CHECK (blocking IN (0,1)),
|
||||
supersedes TEXT REFERENCES decisions(id)
|
||||
, spec_path TEXT, spec_commit TEXT) STRICT;
|
||||
CREATE TABLE decision_events (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
at TEXT NOT NULL,
|
||||
op TEXT NOT NULL CHECK (op IN ('seen','resolved','withdrawn','expired')),
|
||||
by TEXT NOT NULL,
|
||||
choice TEXT, note TEXT, via TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE messages (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
from_role TEXT NOT NULL, from_run TEXT NOT NULL,
|
||||
to_role TEXT NOT NULL,
|
||||
class TEXT NOT NULL,
|
||||
in_reply_to TEXT REFERENCES messages(id),
|
||||
decision TEXT REFERENCES decisions(id),
|
||||
corrects TEXT REFERENCES messages(id),
|
||||
body TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE TABLE deliveries (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message TEXT NOT NULL REFERENCES messages(id),
|
||||
at TEXT NOT NULL,
|
||||
op TEXT NOT NULL CHECK (op IN ('routed','delivered','failed','read')),
|
||||
holder_run TEXT, transport TEXT, address TEXT, detail TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE task_snapshots (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
task_ref TEXT NOT NULL CHECK (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*'),
|
||||
updated TEXT NOT NULL,
|
||||
etag TEXT,
|
||||
digest TEXT NOT NULL CHECK (length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'),
|
||||
fields TEXT NOT NULL CHECK (json_valid(fields)),
|
||||
source TEXT NOT NULL CHECK (source IN ('self','poll')),
|
||||
via TEXT CHECK (via IN ('board','cursor','task','reconcile')),
|
||||
read_at TEXT,
|
||||
role TEXT, run TEXT,
|
||||
CHECK ((source = 'self') = (role IS NOT NULL AND run IS NOT NULL)),
|
||||
CHECK ((source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)),
|
||||
CHECK (json_type(fields, '$.bucket') IS 'integer'
|
||||
OR (source IS 'poll' AND via IS 'task' AND json_type(fields, '$.gone') IS 'text'))
|
||||
) STRICT;
|
||||
CREATE INDEX task_snapshots_ref ON task_snapshots (business, task_ref, seq);
|
||||
CREATE TRIGGER decisions_resolve_once BEFORE INSERT ON decision_events
|
||||
WHEN NEW.op IN ('resolved','withdrawn','expired') AND EXISTS (
|
||||
SELECT 1 FROM decision_events WHERE decision = NEW.decision AND op IN ('resolved','withdrawn','expired'))
|
||||
BEGIN SELECT RAISE(ABORT, 'decision already closed'); END;
|
||||
CREATE TRIGGER decisions_resolved_choice BEFORE INSERT ON decision_events
|
||||
WHEN NEW.op = 'resolved' AND (NEW.choice IS NULL OR NOT EXISTS (
|
||||
SELECT 1 FROM decisions d, json_each(d.options) o WHERE d.id = NEW.decision AND json_extract(o.value,'$.key') = NEW.choice))
|
||||
BEGIN SELECT RAISE(ABORT, 'resolution must name one of the options'); END;
|
||||
CREATE TRIGGER role_one_holder BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op = 'claim' AND (SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) = 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'role already held'); END;
|
||||
CREATE TRIGGER role_release_by_holder BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1),'') <> 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'role is not held'); END;
|
||||
CREATE TRIGGER role_release_same_run BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
|
||||
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
|
||||
CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
|
||||
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
|
||||
CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
|
||||
CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
|
||||
CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
|
||||
CREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
|
||||
CREATE TRIGGER role_claims_no_update BEFORE UPDATE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
|
||||
CREATE TRIGGER role_claims_no_delete BEFORE DELETE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
|
||||
CREATE TRIGGER decisions_no_update BEFORE UPDATE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
|
||||
CREATE TRIGGER decisions_no_delete BEFORE DELETE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
|
||||
CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
|
||||
CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
|
||||
CREATE TRIGGER messages_no_update BEFORE UPDATE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
|
||||
CREATE TRIGGER messages_no_delete BEFORE DELETE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
|
||||
CREATE TRIGGER deliveries_no_update BEFORE UPDATE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
|
||||
CREATE TRIGGER deliveries_no_delete BEFORE DELETE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
|
||||
CREATE TRIGGER meta_no_replace BEFORE INSERT ON meta WHEN EXISTS (SELECT 1 FROM meta WHERE key = NEW.key) BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
|
||||
CREATE TRIGGER events_no_replace BEFORE INSERT ON events WHEN EXISTS (SELECT 1 FROM events WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
|
||||
CREATE TRIGGER role_claims_no_replace BEFORE INSERT ON role_claims WHEN EXISTS (SELECT 1 FROM role_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
|
||||
CREATE TRIGGER decisions_no_replace BEFORE INSERT ON decisions WHEN EXISTS (SELECT 1 FROM decisions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
|
||||
CREATE TRIGGER decision_events_no_replace BEFORE INSERT ON decision_events WHEN EXISTS (SELECT 1 FROM decision_events WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
|
||||
CREATE TRIGGER messages_no_replace BEFORE INSERT ON messages WHEN EXISTS (SELECT 1 FROM messages WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
|
||||
CREATE TRIGGER deliveries_no_replace BEFORE INSERT ON deliveries WHEN EXISTS (SELECT 1 FROM deliveries WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
|
||||
CREATE TRIGGER task_snapshots_no_update BEFORE UPDATE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
|
||||
CREATE TRIGGER task_snapshots_no_delete BEFORE DELETE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
|
||||
CREATE TRIGGER task_snapshots_no_replace BEFORE INSERT ON task_snapshots WHEN EXISTS (SELECT 1 FROM task_snapshots WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
|
||||
CREATE TRIGGER decisions_blocking_needs_task BEFORE INSERT ON decisions
|
||||
WHEN NEW.blocking = 1 AND NEW.task_ref IS NULL
|
||||
BEGIN SELECT RAISE(ABORT, 'a blocking decision cites the task it blocks'); END;
|
||||
CREATE TRIGGER events_launch_by_human BEFORE INSERT ON events
|
||||
WHEN NEW.kind IN ('launch.revoked','launch.restored') AND (NEW.actor_role IS NOT NULL OR NEW.actor_run IS NOT NULL)
|
||||
BEGIN SELECT RAISE(ABORT, 'only the human revokes or restores launching'); END;
|
||||
CREATE TRIGGER events_credential_body BEFORE INSERT ON events
|
||||
WHEN NEW.kind GLOB 'credential.*' AND (
|
||||
json_extract(NEW.body, '$.service') IS NULL OR json_extract(NEW.body, '$.service') NOT IN ('gitea','vikunja')
|
||||
OR json_extract(NEW.body, '$.instance') IS NULL)
|
||||
BEGIN SELECT RAISE(ABORT, 'credential events name a service and a role instance'); END;
|
||||
CREATE TRIGGER events_task_missing_body BEFORE INSERT ON events
|
||||
WHEN NEW.kind = 'task.missing' AND (
|
||||
json_extract(NEW.body, '$.reason') IS NULL OR json_extract(NEW.body, '$.reason') NOT IN ('moved','not-found','no-access')
|
||||
OR (json_extract(NEW.body, '$.reason') = 'moved' AND json_type(NEW.body, '$.project') IS NOT 'integer'))
|
||||
BEGIN SELECT RAISE(ABORT, 'task.missing carries a reason, and the new project when moved'); END;
|
||||
CREATE TRIGGER events_task_subject BEFORE INSERT ON events
|
||||
WHEN NEW.kind GLOB 'task.*' AND (NEW.subject IS NULL OR NOT (NEW.subject GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(NEW.subject, 9) GLOB '*[^0-9/]*' AND NOT substr(NEW.subject, 9) GLOB '*/*/*'))
|
||||
BEGIN SELECT RAISE(ABORT, 'a task event names its task in subject'); END;
|
||||
CREATE TRIGGER events_task_created_body BEFORE INSERT ON events
|
||||
WHEN NEW.kind = 'task.created' AND (
|
||||
json_type(NEW.body, '$.request') IS NOT 'text'
|
||||
OR NOT EXISTS (SELECT 1 FROM events WHERE id = json_extract(NEW.body, '$.request')
|
||||
AND kind = 'human.input' AND business = NEW.business)
|
||||
OR json_type(NEW.body, '$.requirement') IS NOT 'text'
|
||||
OR NOT json_extract(NEW.body, '$.requirement') GLOB 'REQ-[A-Z]*-[1-9]*'
|
||||
OR json_extract(NEW.body, '$.requirement') GLOB 'REQ-*[^A-Z0-9-]*')
|
||||
BEGIN SELECT RAISE(ABORT, 'task.created cites the human.input that asked for it and a requirement id'); END;
|
||||
CREATE VIEW launch_state AS
|
||||
SELECT business, CASE kind WHEN 'launch.revoked' THEN 'revoked' ELSE 'allowed' END AS state, at
|
||||
FROM events e WHERE kind IN ('launch.revoked','launch.restored')
|
||||
AND seq = (SELECT max(seq) FROM events WHERE business = e.business AND kind IN ('launch.revoked','launch.restored'));
|
||||
CREATE VIEW task_external_changes AS
|
||||
SELECT p.business, p.task_ref, p.seq, p.via, p.updated, p.digest, ls.digest AS self_digest
|
||||
FROM task_snapshots p
|
||||
LEFT JOIN task_snapshots ls ON ls.seq = (SELECT max(seq) FROM task_snapshots
|
||||
WHERE business = p.business AND task_ref = p.task_ref AND source = 'self')
|
||||
WHERE p.source = 'poll'
|
||||
AND p.seq = (SELECT max(seq) FROM task_snapshots WHERE business = p.business AND task_ref = p.task_ref)
|
||||
AND (ls.seq IS NULL OR (p.updated >= ls.updated AND p.read_at > ls.at AND p.digest <> ls.digest));
|
||||
CREATE VIEW task_current AS
|
||||
SELECT s.seq, s.at, s.business, s.task_ref, s.updated, s.etag, s.digest, s.fields, s.source, s.via, s.read_at, s.role, s.run
|
||||
FROM task_snapshots s
|
||||
WHERE s.seq = (SELECT max(c.seq) FROM task_snapshots c
|
||||
WHERE c.business = s.business AND c.task_ref = s.task_ref
|
||||
AND (c.source = 'self'
|
||||
OR c.read_at > coalesce((SELECT ls.at FROM task_snapshots ls
|
||||
WHERE ls.business = c.business AND ls.task_ref = c.task_ref AND ls.source = 'self'
|
||||
ORDER BY ls.seq DESC LIMIT 1), '')));
|
||||
CREATE VIEW tasks_open AS
|
||||
SELECT s.business, s.task_ref, json_extract(s.fields, '$.bucket') AS bucket, s.seq
|
||||
FROM task_current s
|
||||
WHERE json_type(s.fields, '$.gone') IS NULL
|
||||
AND json_extract(s.fields, '$.done') = 0;
|
||||
CREATE VIEW urgent_inbox AS
|
||||
SELECT d.id, d.business, d.task_ref, d.question, d.at
|
||||
FROM decisions d
|
||||
WHERE d.class = 'gated' AND d.blocking = 1
|
||||
AND NOT EXISTS (SELECT 1 FROM decision_events x WHERE x.decision = d.id AND x.op IN ('resolved','withdrawn','expired'));
|
||||
CREATE TABLE task_comment_cursors (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
business TEXT NOT NULL,
|
||||
task_id INTEGER NOT NULL CHECK (task_id > 0),
|
||||
last_comment INTEGER NOT NULL CHECK (last_comment > 0)
|
||||
) STRICT;
|
||||
CREATE INDEX task_comment_cursors_latest ON task_comment_cursors (business, task_id, seq);
|
||||
CREATE TRIGGER task_comment_cursors_no_update BEFORE UPDATE ON task_comment_cursors
|
||||
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
|
||||
CREATE TRIGGER task_comment_cursors_no_delete BEFORE DELETE ON task_comment_cursors
|
||||
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
|
||||
CREATE TRIGGER task_comment_cursors_no_replace BEFORE INSERT ON task_comment_cursors
|
||||
WHEN EXISTS (SELECT 1 FROM task_comment_cursors WHERE seq = NEW.seq)
|
||||
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
|
||||
CREATE TRIGGER task_comment_cursors_forward BEFORE INSERT ON task_comment_cursors
|
||||
WHEN NEW.last_comment <= COALESCE((SELECT last_comment FROM task_comment_cursors
|
||||
WHERE business = NEW.business AND task_id = NEW.task_id ORDER BY seq DESC LIMIT 1), 0)
|
||||
BEGIN SELECT RAISE(ABORT, 'comment cursor must advance'); END;
|
||||
CREATE TABLE projects (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
repo TEXT,
|
||||
created_by TEXT NOT NULL,
|
||||
UNIQUE (business, name)
|
||||
) STRICT;
|
||||
CREATE TABLE threads (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
project TEXT NOT NULL REFERENCES projects(id),
|
||||
instance TEXT NOT NULL,
|
||||
owner TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
UNIQUE (project, name)
|
||||
) STRICT;
|
||||
CREATE TABLE thread_claims (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
|
||||
holder_run TEXT NOT NULL,
|
||||
harness TEXT NOT NULL, address TEXT,
|
||||
by TEXT NOT NULL, reason TEXT,
|
||||
decision TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE message_threads (
|
||||
message TEXT PRIMARY KEY REFERENCES messages(id),
|
||||
thread TEXT NOT NULL REFERENCES threads(id)
|
||||
) STRICT;
|
||||
CREATE TABLE thread_opens (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
by TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX thread_claims_thread ON thread_claims (thread, seq);
|
||||
CREATE INDEX message_threads_thread ON message_threads (thread);
|
||||
CREATE TRIGGER threads_project_business BEFORE INSERT ON threads
|
||||
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
|
||||
BEGIN SELECT RAISE(ABORT, 'a thread belongs to its project''s business'); END;
|
||||
CREATE TRIGGER thread_one_holder BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) = 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'thread already held'); END;
|
||||
CREATE TRIGGER thread_one_per_run BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'claim' AND EXISTS (SELECT 1 FROM thread_claims c WHERE c.holder_run = NEW.holder_run AND c.business = NEW.business
|
||||
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = c.thread) AND c.op = 'claim')
|
||||
BEGIN SELECT RAISE(ABORT, 'run already holds a thread'); END;
|
||||
CREATE TRIGGER thread_release_by_holder BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1),'') <> 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'thread is not held'); END;
|
||||
CREATE TRIGGER thread_release_same_run BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
|
||||
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
|
||||
CREATE TRIGGER thread_revoke_needs_decision BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
|
||||
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
|
||||
CREATE TRIGGER thread_claims_business BEFORE INSERT ON thread_claims
|
||||
WHEN (SELECT business FROM threads WHERE id = NEW.thread) IS NOT NEW.business
|
||||
BEGIN SELECT RAISE(ABORT, 'a thread claim names the thread''s business'); END;
|
||||
CREATE TRIGGER message_threads_match BEFORE INSERT ON message_threads
|
||||
WHEN NOT EXISTS (SELECT 1 FROM messages m JOIN threads t ON t.id = NEW.thread
|
||||
WHERE m.id = NEW.message AND m.business = t.business AND t.instance IN (m.to_role, m.from_role))
|
||||
BEGIN SELECT RAISE(ABORT, 'a threaded message is to or from the thread''s instance'); END;
|
||||
CREATE TRIGGER delivery_same_thread BEFORE INSERT ON deliveries
|
||||
WHEN NEW.op IN ('routed','delivered') AND (NEW.op = 'delivered' OR NEW.holder_run IS NOT NULL)
|
||||
AND EXISTS (SELECT 1 FROM message_threads mt JOIN messages m ON m.id = mt.message JOIN threads t ON t.id = mt.thread
|
||||
WHERE mt.message = NEW.message AND m.to_role = t.instance
|
||||
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = t.id)
|
||||
AND c.op = 'claim' AND c.holder_run IS NEW.holder_run))
|
||||
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its thread''s holder'); 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_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 threads_no_update BEFORE UPDATE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
|
||||
CREATE TRIGGER threads_no_delete BEFORE DELETE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
|
||||
CREATE TRIGGER threads_no_replace BEFORE INSERT ON threads WHEN EXISTS (SELECT 1 FROM threads WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
|
||||
CREATE TRIGGER thread_claims_no_update BEFORE UPDATE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
|
||||
CREATE TRIGGER thread_claims_no_delete BEFORE DELETE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
|
||||
CREATE TRIGGER thread_claims_no_replace BEFORE INSERT ON thread_claims WHEN EXISTS (SELECT 1 FROM thread_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
|
||||
CREATE TRIGGER message_threads_no_update BEFORE UPDATE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
|
||||
CREATE TRIGGER message_threads_no_delete BEFORE DELETE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
|
||||
CREATE TRIGGER message_threads_no_replace BEFORE INSERT ON message_threads WHEN EXISTS (SELECT 1 FROM message_threads WHERE message = NEW.message) BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
|
||||
CREATE TRIGGER thread_opens_no_update BEFORE UPDATE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
|
||||
CREATE TRIGGER thread_opens_no_delete BEFORE DELETE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
|
||||
CREATE TRIGGER thread_opens_no_replace BEFORE INSERT ON thread_opens WHEN EXISTS (SELECT 1 FROM thread_opens WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
|
||||
@@ -0,0 +1,69 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { Store, schemaDigest } from '../src/store.mjs';
|
||||
// Row 141 (#1635, REQ-MSG-2): the 3f→3g step on a copy of a 3f store built here from the pinned 3f
|
||||
// schema (4ebef2284's schema.sql) and fixture rows. No live store is read or copied.
|
||||
const AT = '2026-10-16T00:00:00.000Z';
|
||||
function source3f(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'name-migrate-'));
|
||||
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);
|
||||
const ddl = readFileSync(new URL('./fixtures/schema-3f.sql', import.meta.url), 'utf8');
|
||||
assert.equal(createHash('sha256').update(ddl).digest('hex'), '472cbcd0d4548848873f3029e8250dc07bba4fb8db2a93241aae5b8e7e3771e2');
|
||||
db.exec(ddl);
|
||||
const run = (sql, ...args) => db.prepare(sql).run(...args);
|
||||
run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3f');
|
||||
run('INSERT INTO meta VALUES (?,?)', 'schema_digest', schemaDigest(db));
|
||||
run('INSERT INTO meta VALUES (?,?)', 'comment_cursor_epoch', AT);
|
||||
run('INSERT INTO events(id,at,business,kind,body) VALUES(?,?,?,?,?)', 'event', AT, 'demo', 'action.allowed', '{}');
|
||||
run('INSERT INTO role_claims(at,business,role,op,holder_run,harness,by) VALUES(?,?,?,?,?,?,?)', AT, 'demo', 'coder', 'claim', 'run', 'pi', 'run');
|
||||
run('INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking,spec_path,spec_commit) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
'decision', AT, 'demo', 'coder', 'run', 'gated', 'deploy', 'human', '?', '[{"key":"yes","text":"Yes"},{"key":"no","text":"No"}]', 'yes', 0, 'docs/spec.md', 'c'.repeat(40));
|
||||
run('INSERT INTO decision_events(decision,at,op,by,choice) VALUES(?,?,?,?,?)', 'decision', AT, 'resolved', 'jason', 'yes');
|
||||
run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)', 'message', AT, 'demo', 'human', 'human', 'assistant', 'INFO', 'kept');
|
||||
run('INSERT INTO deliveries(message,at,op) VALUES(?,?,?)', 'message', AT, 'routed');
|
||||
run('INSERT INTO task_snapshots(at,business,task_ref,updated,digest,fields,source,role,run) VALUES(?,?,?,?,?,?,?,?,?)', AT, 'demo', 'vikunja:1/1', AT, 'a'.repeat(64), '{"bucket":1,"done":false}', 'self', 'coder', 'run');
|
||||
run('INSERT INTO task_comment_cursors(business,task_id,last_comment) VALUES(?,?,?)', 'demo', 1, 8);
|
||||
run('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)', 'project', AT, 'demo', 'Work', 'stack', 'jason');
|
||||
run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', 'thread', AT, 'demo', 'project', 'assistant', 'jason', 'One');
|
||||
run('INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,by) VALUES(?,?,?,?,?,?,?)', AT, 'demo', 'thread', 'claim', 'assistant-run', 'pi', 'assistant-run');
|
||||
run('INSERT INTO message_threads VALUES(?,?)', 'message', 'thread');
|
||||
run('INSERT INTO thread_opens(at,thread,by) VALUES(?,?,?)', AT, 'thread', 'jason');
|
||||
db.close(); chmodSync(path, 0o600);
|
||||
const copy = join(root, 'untouched-3f.sqlite'); copyFileSync(path, copy);
|
||||
return { root, path, copy };
|
||||
}
|
||||
function snapshot(path, columns) {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
columns ??= Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all()
|
||||
.map(({ name }) => [name, db.prepare(`PRAGMA table_info(${name})`).all().map(c => c.name)]));
|
||||
const rows = Object.fromEntries(Object.entries(columns).map(([table, keys]) => [table,
|
||||
db.prepare(`SELECT ${keys.join(',')} FROM ${table} ORDER BY rowid`).all()
|
||||
.filter(r => table !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key))]));
|
||||
return { columns, rows };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
test('3f copy migrates to 3g preserving every old column/row, with an empty instance_names and null to_name', t => {
|
||||
const { root, path, copy } = source3f(t), before = snapshot(path), untouched = readFileSync(copy);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3g');
|
||||
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
|
||||
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
|
||||
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
|
||||
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
|
||||
} finally { store.close(); }
|
||||
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
|
||||
assert.deepEqual(readFileSync(copy), untouched);
|
||||
assert.deepEqual(snapshot(copy).rows, before.rows);
|
||||
new Store(root).close();
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
|
||||
// Row 141 (#1635, REQ-MSG-2): messages are addressed by an instance's name. The human sets names
|
||||
// through the CLI; the chief of staff sets them because its role file grants `instance.name`
|
||||
// within-role. The grant is read from the shipped roles/chief-of-staff.json, so removing it there
|
||||
// fails the chief's test. Instance keys differ from definitions here, so each refusal is its own.
|
||||
const shipped = (name) =>
|
||||
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
|
||||
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
|
||||
const roles = {
|
||||
chief: { definition: 'chief-of-staff', authority: shipped('chief-of-staff').authority },
|
||||
lead: { definition: 'pm', ...none },
|
||||
dev: { definition: 'coder', authority: { withinRole: ['message.send'], crossRole: ['instance.name'] } },
|
||||
};
|
||||
const demo = () => ({
|
||||
demo: {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'lead', delivery: 'chief' },
|
||||
roles,
|
||||
definitions: { reviewer: {} },
|
||||
vars: { 'bus.tester': true },
|
||||
},
|
||||
});
|
||||
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-names-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const b = new Broker({ store, businesses: demo() });
|
||||
const caps = { human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
|
||||
for (const role of Object.keys(roles)) {
|
||||
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' });
|
||||
b.request(caps[role], { verb: 'role.claim' });
|
||||
}
|
||||
caps.reader = b.bindReader({ business: 'demo' });
|
||||
caps.tester = b.bindTester({ business: 'demo' });
|
||||
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
|
||||
return { b, store, caps, call };
|
||||
}
|
||||
|
||||
test('REQ-MSG-2: the chief of staff names an instance under its within-role grant, and the write is logged', (t) => {
|
||||
const { store, call } = setup(t);
|
||||
assert.ok(shipped('chief-of-staff').authority.withinRole.includes('instance.name'), 'the shipped chief file grants instance.name');
|
||||
assert.deepEqual(call('chief', 'name.set', { instance: 'dev', name: 'rex' }), { instance: 'dev', name: 'rex' });
|
||||
const [event, ...rest] = store
|
||||
.all("SELECT actor_role, subject, body FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='instance.name'")
|
||||
.map((e) => ({ ...e, body: JSON.parse(e.body) }));
|
||||
assert.equal(rest.length, 0);
|
||||
assert.equal(event.actor_role, 'chief');
|
||||
assert.equal(event.subject, 'dev');
|
||||
assert.equal(event.body.class, 'within-role');
|
||||
assert.equal(event.body.target, 'dev');
|
||||
assert.deepEqual({ ...store.get('SELECT business, instance, name, by FROM instance_names') }, {
|
||||
business: 'demo',
|
||||
instance: 'dev',
|
||||
name: 'rex',
|
||||
by: 'chief-run',
|
||||
});
|
||||
assert.deepEqual(
|
||||
call('chief', 'names').map(({ instance, name }) => ({ instance, name })),
|
||||
[{ instance: 'dev', name: 'rex' }],
|
||||
);
|
||||
});
|
||||
|
||||
test('REQ-MSG-2: the human names an instance through the CLI', (t) => {
|
||||
const { store, call } = setup(t);
|
||||
assert.deepEqual(call('human', 'name.set', { instance: 'lead', name: 'patty' }), { instance: 'lead', name: 'patty' });
|
||||
assert.equal(store.get('SELECT by FROM instance_names').by, 'jason');
|
||||
assert.deepEqual(
|
||||
call('human', 'names').map(({ instance, name }) => ({ instance, name })),
|
||||
[{ instance: 'lead', name: 'patty' }],
|
||||
);
|
||||
});
|
||||
|
||||
test('REQ-MSG-2: a role without the grant, or holding it cross-role, is refused not-authorized', (t) => {
|
||||
const { store, call } = setup(t);
|
||||
// lead holds no instance.name at all; dev holds it cross-role. Neither may set a name: the refusal
|
||||
// comes before #checkAuthority, so dev gets not-authorized, not decision-required, and name.set
|
||||
// takes no decision argument at all.
|
||||
assert.throws(() => call('lead', 'name.set', { instance: 'dev', name: 'rex' }), /not-authorized/);
|
||||
assert.throws(() => call('dev', 'name.set', { instance: 'dev', name: 'rex' }), /not-authorized/);
|
||||
assert.throws(
|
||||
() => call('dev', 'name.set', { instance: 'dev', name: 'rex', decision: '00000000-0000-4000-8000-000000000000' }),
|
||||
/invalid-request/,
|
||||
);
|
||||
assert.throws(() => call('reader', 'name.set', { instance: 'dev', name: 'rex' }), /read-only/);
|
||||
assert.throws(() => call('tester', 'name.set', { instance: 'dev', name: 'rex' }), /tester-refused/);
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
|
||||
});
|
||||
|
||||
test('REQ-MSG-2: a name is unique in the business and is never an instance key or a role name', (t) => {
|
||||
const { store, call } = setup(t);
|
||||
call('human', 'name.set', { instance: 'dev', name: 'rex' });
|
||||
assert.throws(() => call('human', 'name.set', { instance: 'lead', name: 'rex' }), /name-taken/);
|
||||
assert.throws(() => call('chief', 'name.set', { instance: 'lead', name: 'rex' }), /name-taken/);
|
||||
for (const key of ['chief', 'lead', 'human', 'tester'])
|
||||
assert.throws(() => call('human', 'name.set', { instance: 'dev', name: key }), /name-is-instance/, key);
|
||||
// A definition an instance uses, and one the business only defines.
|
||||
for (const definition of ['coder', 'chief-of-staff', 'reviewer'])
|
||||
assert.throws(() => call('human', 'name.set', { instance: 'dev', name: definition }), /name-is-role/, definition);
|
||||
for (const bad of ['', 'Rex', '_bus', 'a b', 'x'.repeat(65), 7])
|
||||
assert.throws(() => call('human', 'name.set', { instance: 'dev', name: bad }), /invalid-request/, String(bad));
|
||||
assert.throws(() => call('human', 'name.set', { instance: 'nobody', name: 'zed' }), /unknown-role/);
|
||||
// Renaming frees the old name for another instance.
|
||||
call('human', 'name.set', { instance: 'dev', name: 'rexy' });
|
||||
call('human', 'name.set', { instance: 'lead', name: 'rex' });
|
||||
assert.deepEqual(
|
||||
call('human', 'names').map(({ instance, name }) => ({ instance, name })),
|
||||
[{ instance: 'dev', name: 'rexy' }, { instance: 'lead', name: 'rex' }],
|
||||
);
|
||||
// The store refuses a duplicate on its own, and the table is append-only.
|
||||
assert.throws(
|
||||
() => store.run("INSERT INTO instance_names(at,business,instance,name,by) VALUES('2026-01-01T00:00:00.000Z','demo','chief','rex','t')"),
|
||||
/unique in its business/,
|
||||
);
|
||||
assert.throws(() => store.run("UPDATE instance_names SET name='zed'"), /append-only/);
|
||||
assert.throws(() => store.run('DELETE FROM instance_names'), /append-only/);
|
||||
});
|
||||
|
||||
test('REQ-MSG-2: a send by name reaches the instance and records both; a send by instance key is unchanged', (t) => {
|
||||
const { call } = setup(t);
|
||||
call('human', 'name.set', { instance: 'dev', name: 'rex' });
|
||||
const byName = call('lead', 'message.send', { to: 'rex', body: 'by name' });
|
||||
const byKey = call('lead', 'message.send', { to: 'dev', body: 'by key' });
|
||||
const fromHuman = call('human', 'message.send', { to: 'rex', body: 'from the human' });
|
||||
const got = call('dev', 'message.receive');
|
||||
assert.deepEqual(
|
||||
got.map(({ id, to_role, to_name, body }) => ({ id, to_role, to_name, body })),
|
||||
[
|
||||
{ id: byName.id, to_role: 'dev', to_name: 'rex', body: 'by name' },
|
||||
{ id: byKey.id, to_role: 'dev', to_name: null, body: 'by key' },
|
||||
{ id: fromHuman.id, to_role: 'dev', to_name: 'rex', body: 'from the human' },
|
||||
],
|
||||
);
|
||||
// The tester may send by name too; a name no instance holds is an unknown role.
|
||||
call('tester', 'message.send', { to: 'rex', body: '[mos test] by name' });
|
||||
assert.equal(call('dev', 'message.receive')[0].to_role, 'dev');
|
||||
assert.throws(() => call('lead', 'message.send', { to: 'nobody', body: 'x' }), /unknown-role/);
|
||||
// After a rename, the old name no longer reaches anyone.
|
||||
call('human', 'name.set', { instance: 'dev', name: 'rexy' });
|
||||
assert.throws(() => call('lead', 'message.send', { to: 'rex', body: 'x' }), /unknown-role/);
|
||||
assert.equal(call('lead', 'message.send', { to: 'rexy', body: 'renamed' }).id.length, 36);
|
||||
});
|
||||
|
||||
test('REQ-MSG-2: an instance key beats a name that equals it, as when a later instance takes the key', (t) => {
|
||||
const { store, call } = setup(t);
|
||||
// name.set refuses this name; the store's trigger doesn't know the business, so a row stands in for
|
||||
// a name set before an instance with that key joined.
|
||||
store.run("INSERT INTO instance_names(at,business,instance,name,by) VALUES('2026-01-01T00:00:00.000Z','demo','dev','lead','jason')");
|
||||
call('chief', 'message.send', { to: 'lead', body: 'to the key' });
|
||||
const [m] = call('lead', 'message.receive');
|
||||
assert.equal(m.body, 'to the key');
|
||||
assert.equal(m.to_role, 'lead');
|
||||
assert.equal(m.to_name, null);
|
||||
assert.deepEqual(call('dev', 'message.receive'), []);
|
||||
});
|
||||
|
||||
test('REQ-MSG-2: a role name beats a name that equals it, as when a later definition takes the word (sage-0590)', (t) => {
|
||||
const { store, call } = setup(t);
|
||||
// As above, rows stand in for names set before the business knew these definitions: one an
|
||||
// instance uses (coder) and one the business only defines (reviewer). Neither is an instance key.
|
||||
store.run("INSERT INTO instance_names(at,business,instance,name,by) VALUES('2026-01-01T00:00:00.000Z','demo','dev','reviewer','jason')");
|
||||
store.run("INSERT INTO instance_names(at,business,instance,name,by) VALUES('2026-01-01T00:00:00.000Z','demo','lead','coder','jason')");
|
||||
for (const word of ['reviewer', 'coder'])
|
||||
assert.throws(() => call('chief', 'message.send', { to: word, body: 'to the role' }), /unknown-role/, word);
|
||||
assert.deepEqual(call('dev', 'message.receive'), []);
|
||||
assert.deepEqual(call('lead', 'message.receive'), []);
|
||||
});
|
||||
@@ -16,9 +16,13 @@ test('Broker keeps the existing sandbox client import closure: no new imports or
|
||||
"import { loadRequirements } from '../../tasks/src/requirements.mjs';",
|
||||
// Row 137 (#1631): land-work's checks, in RUNNER_FILES beside the broker. They import nothing.
|
||||
"import { MERGE, LandingRefusal, readLanding } from './landing.mjs';",
|
||||
// Row 141 (#1635): the business ID_PATTERN for instance names. The vocabulary is already in
|
||||
// RUNNER_FILES and imports nothing.
|
||||
"import { ID_PATTERN } from '../../business/src/vocabulary.mjs';",
|
||||
]);
|
||||
assert.doesNotMatch(source, /\bimport\s*\(/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../../business/src/vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
});
|
||||
|
||||
test('without a trusted project adapter all three async paths refuse project-not-configured', async t => {
|
||||
|
||||
@@ -517,7 +517,8 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
|
||||
const count = (type) => db.prepare('SELECT count(*) n FROM sqlite_master WHERE type = ?').get(type).n;
|
||||
for (const label of refused) assert.ok(tested.has(label), label);
|
||||
// Row 121 (#1615) adds 5 tables and 24 triggers for projects and threads; threads.test.mjs tests them.
|
||||
assert.equal(count('table') - 1, 14);
|
||||
assert.equal(count('trigger'), 63);
|
||||
// Row 141 (#1635) adds instance_names and its 4 triggers; names.test.mjs tests them.
|
||||
assert.equal(count('table') - 1, 15);
|
||||
assert.equal(count('trigger'), 67);
|
||||
assert.equal(count('view'), 5);
|
||||
});
|
||||
|
||||
@@ -48,12 +48,12 @@ function snapshot(path, columns) {
|
||||
return { columns, rows };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
test('3e copy migrates to 3f preserving every old column/row and adding null spec citations', t => {
|
||||
test('3e copy migrates through 3f to 3g preserving every old column/row and adding null spec citations', t => {
|
||||
const { root, path, copy } = source3e(t), before = snapshot(path), untouched = readFileSync(copy);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3f');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3g');
|
||||
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null });
|
||||
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
|
||||
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
|
||||
|
||||
@@ -49,7 +49,7 @@ async function database3d(t) {
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const pinned = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3f';", "export const SCHEMA_VERSION = '3d';")
|
||||
.replace("export const SCHEMA_VERSION = '3g';", "export const SCHEMA_VERSION = '3d';")
|
||||
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
|
||||
@@ -86,6 +86,12 @@ const rows = (path) => {
|
||||
db.close();
|
||||
}
|
||||
};
|
||||
// Row 141: 3f→3g adds messages.to_name, which reads null on a message from before names. Rows from
|
||||
// node:sqlite have a null prototype, so the expected rows keep one.
|
||||
const named = ({ messages, deliveries }) => ({
|
||||
messages: messages.map((m) => Object.assign(Object.create(null), m, { to_name: null })),
|
||||
deliveries,
|
||||
});
|
||||
|
||||
test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and leaves messages from before threads as written', async (t) => {
|
||||
const { root, path, copy } = await database3d(t);
|
||||
@@ -95,13 +101,13 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
t.after(() => store.close());
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3f');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3g');
|
||||
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
|
||||
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
|
||||
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
|
||||
assert.deepEqual(store.migrationReport, REPORT);
|
||||
store.close();
|
||||
assert.deepEqual(rows(path), before);
|
||||
assert.deepEqual(rows(path), named(before));
|
||||
assert.deepEqual(rows(copy), before);
|
||||
// Opening again needs no step and reports nothing.
|
||||
const again = new Store(root, { migrateSchema: true });
|
||||
@@ -135,7 +141,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
|
||||
]);
|
||||
assert.deepEqual(b.request(run, { verb: 'message.receive', args: {} }), []);
|
||||
current.close();
|
||||
assert.deepEqual(rows(path), before);
|
||||
assert.deepEqual(rows(path), named(before));
|
||||
});
|
||||
|
||||
test('REQ-THREAD-1: a 3d bus with a changed schema is refused and keeps its version and tables', async (t) => {
|
||||
@@ -155,5 +161,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th
|
||||
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
|
||||
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
|
||||
assert.equal(migrated.status, 0, migrated.stderr);
|
||||
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3f ready', ''].join('\n'));
|
||||
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3g ready', ''].join('\n'));
|
||||
});
|
||||
|
||||
@@ -32,6 +32,9 @@ export const ACTIONS = Object.freeze([
|
||||
// so the grant lives in a role file. No shipped role is granted role.revoke, so it stays gated.
|
||||
"role.end",
|
||||
"organization.change",
|
||||
// Row 141 (#1635): sets a role instance's name. Routed under PRD 1.5 REQ-MSG-2, which has the chief of
|
||||
// staff set names once its role file grants that within-role.
|
||||
"instance.name",
|
||||
"credential.mint",
|
||||
"spend",
|
||||
"deploy",
|
||||
@@ -50,7 +53,8 @@ export const ACTIONS = Object.freeze([
|
||||
// to ACTIONS is a hard wait until a PRD version says it isn't, so a new
|
||||
// action joins this list only by the reviewed commit that names it routed.
|
||||
// git.merge.protected, policy.change, prd.approve and role.revoke left the
|
||||
// hard waits in 1.5 and route by REQ-DEC-2.
|
||||
// hard waits in 1.5 and route by REQ-DEC-2. instance.name (row 141) is routed
|
||||
// under REQ-MSG-2 (1.5).
|
||||
export const ROUTED = Object.freeze([
|
||||
"task.create",
|
||||
"task.assign",
|
||||
@@ -70,6 +74,7 @@ export const ROUTED = Object.freeze([
|
||||
"role.revoke",
|
||||
"role.end",
|
||||
"organization.change",
|
||||
"instance.name",
|
||||
"policy.change",
|
||||
"prd.approve",
|
||||
"decision.resolve.technical",
|
||||
|
||||
@@ -13,11 +13,12 @@ import { THREAD_IDLE_SECONDS } from "../src/business.mjs";
|
||||
import { businessDoc, launchDoc, REPO_ROLES, systemFor, tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
// Decision 104's list plus role.launch (decision 139) plus role.end and
|
||||
// organization.change (row 123, REQ-COS-1) and record.write (row 138). A role file that differs from it fails.
|
||||
// organization.change (row 123, REQ-COS-1) and record.write (row 138), plus instance.name
|
||||
// (row 141, REQ-MSG-2, sage-0589). A role file that differs from it fails.
|
||||
const APPROVED = Object.freeze({
|
||||
tools: ["read", "grep", "find", "ls"],
|
||||
network: "api-only",
|
||||
authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch", "role.end", "organization.change", "record.write"]), crossRole: Object.freeze([]) }),
|
||||
authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name"]), crossRole: Object.freeze([]) }),
|
||||
credentials: Object.freeze([]),
|
||||
});
|
||||
|
||||
@@ -84,9 +85,9 @@ test("the resolved record matches the approved list, so a widened file fails", (
|
||||
assert.deepEqual(record.credentials, {});
|
||||
});
|
||||
|
||||
test("the chief of staff holds role.launch, message.send, role.end, organization.change and record.write; every other action is gated", () => {
|
||||
test("the chief of staff holds role.launch, message.send, role.end, organization.change, record.write and instance.name; every other action is gated", () => {
|
||||
const record = resolved(loadFixture());
|
||||
const held = ["message.send", "role.launch", "role.end", "organization.change", "record.write"];
|
||||
const held = ["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name"];
|
||||
for (const action of ACTIONS) {
|
||||
assert.equal(classify(record, action), held.includes(action) ? "within" : "gated", action);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ test("REQ-WAIT-3: every action is routed or a hard wait, never both, and the rou
|
||||
for (const action of ACTIONS) assert.notEqual(ROUTED.includes(action), GATED_ONLY.includes(action), action);
|
||||
});
|
||||
|
||||
test("REQ-WAIT-3: instance.name is routed under REQ-MSG-2 (1.5), not a hard wait (row 141, sage-0589)", () => {
|
||||
assert.ok(ACTIONS.includes("instance.name"));
|
||||
assert.ok(ROUTED.includes("instance.name"));
|
||||
assert.ok(!GATED_ONLY.includes("instance.name"));
|
||||
});
|
||||
|
||||
test("REQ-WAIT-2: a role listing a hard wait at either class is refused; the actions that left can be granted", () => {
|
||||
for (const action of WAITS) {
|
||||
assert.throws(() => role({ withinRole: [action], crossRole: [] }), /always gated/, action);
|
||||
|
||||
@@ -67,6 +67,14 @@ test("shipped authority follows the note's table", () => {
|
||||
assert.deepEqual(auth("reviewer").crossRole, []);
|
||||
assert.ok(auth("chief-of-staff").withinRole.includes("role.launch"));
|
||||
assert.ok(!auth("pm").withinRole.includes("role.launch"));
|
||||
// Row 141 (REQ-MSG-2): the chief alone of the shipped roles sets instance names.
|
||||
assert.ok(auth("chief-of-staff").withinRole.includes("instance.name"));
|
||||
const others = readdirSync(REPO_ROLES).filter((f) => f.endsWith(".json") && !["conductor-policy.json", "chief-of-staff.json"].includes(f));
|
||||
assert.ok(others.length >= 10, String(others.length));
|
||||
for (const name of others.map((f) => f.slice(0, -5))) {
|
||||
assert.ok(!auth(name).withinRole.includes("instance.name"), name);
|
||||
assert.ok(!auth(name).crossRole.includes("instance.name"), name);
|
||||
}
|
||||
assert.ok(auth("cto").withinRole.includes("decision.resolve.technical"));
|
||||
assert.ok(!auth("pm").withinRole.includes("decision.resolve.technical"));
|
||||
});
|
||||
|
||||
@@ -32,6 +32,8 @@ mosaic agents [--business <id>] [--json]
|
||||
mosaic trail <task|decision> [--business <id>] [--json]
|
||||
mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>]
|
||||
mosaic follow [--business <id>]
|
||||
mosaic name <instance> <name> [--business <id>]
|
||||
mosaic names [--business <id>] [--json]
|
||||
mosaic stop <run>
|
||||
mosaic launches off|on [--business <id>]
|
||||
mosaic launches list [--json]
|
||||
@@ -102,6 +104,13 @@ mosaic launches list [--json]
|
||||
and `--follow` together refuse. These commands consume the same human
|
||||
inbox; run one receiving CLI at a time. Console only reads history, so a
|
||||
reply to a Console send stays unread in `inbox` until it is marked read.
|
||||
- `name` gives a role instance a name (row 141, #1635, REQ-MSG-2) and prints
|
||||
`named <instance> <name>`. `talk` and every `message.send` then accept the
|
||||
name where they took the instance. A name is unique in the business and
|
||||
can't be an instance key, a role definition, `human` or `tester`; the bus
|
||||
refuses with `name-taken`, `name-is-instance` or `name-is-role`. Naming an
|
||||
instance again replaces its name and frees the old one. `names` lists each
|
||||
named instance and its current name.
|
||||
- `launches off` and `on` are the broker's `launch.revoke` and
|
||||
`launch.restore`. While off, every `role.launch` refuses with
|
||||
`launch-revoked`; running sessions keep running. Stop them with `stop`.
|
||||
|
||||
@@ -40,6 +40,8 @@ export const USAGE = `usage:
|
||||
mosaic trail <task|decision> [--business <id>] [--json] [--tester]
|
||||
mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>] [--tester]
|
||||
mosaic follow [--business <id>] [--tester]
|
||||
mosaic name <instance> <name> [--business <id>]
|
||||
mosaic names [--business <id>] [--json]
|
||||
mosaic stop <run>
|
||||
mosaic launches off|on [--business <id>]
|
||||
mosaic launches list [--json]
|
||||
@@ -269,6 +271,21 @@ async function human(verb, rest, io, deps) {
|
||||
return print(await call("agents"), formatAgents);
|
||||
}
|
||||
|
||||
// Row 141 (#1635, REQ-MSG-2): `mosaic name` sets the name `talk` and message.send accept for an
|
||||
// instance; `mosaic names` lists the current ones. Both are the human's.
|
||||
async function names(verb, rest, io, deps) {
|
||||
const parsed = parse(rest, { flags: verb === "names" ? ["--json"] : [], values: ["--business"] });
|
||||
if (parsed.positional.length !== (verb === "name" ? 2 : 0)) throw usage(verb === "name" ? "name takes an instance and a name" : "names takes no arguments");
|
||||
const call = callFor(parsed, io, deps);
|
||||
if (verb === "name") {
|
||||
const [instance, name] = parsed.positional;
|
||||
await call("name.set", { instance, name });
|
||||
return io.stdout.write(`named ${instance} ${name}\n`);
|
||||
}
|
||||
const rows = await call("names");
|
||||
io.stdout.write(parsed.flags.has("--json") ? `${JSON.stringify(rows, null, 2)}\n` : rows.map((r) => `${r.instance} ${r.name}\n`).join(""));
|
||||
}
|
||||
|
||||
const TALK_WAIT_S = 900;
|
||||
const TESTER_PREFIX = "[mos test]";
|
||||
const TALK_POLL_MS = 2000;
|
||||
@@ -487,6 +504,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
if (verb === "talk") return talk(rest, io, deps);
|
||||
if (verb === "follow") return talk(rest, io, deps, true);
|
||||
if (verb === "name" || verb === "names") return names(verb, rest, io, deps);
|
||||
if (verb === "stop") return stop(rest, io, deps);
|
||||
if (verb === "launches") return launches(rest, io, deps);
|
||||
if (verb === "bus") return bus(rest, io, deps);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Row 141 (#1635, REQ-MSG-2): `mosaic name` and `mosaic names`, through the in-process broker from
|
||||
// helpers.mjs. The broker's own checks are in packages/bus/tests/names.test.mjs.
|
||||
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { main, USAGE } from "../src/cli.mjs";
|
||||
import { CliError } from "../src/errors.mjs";
|
||||
import { broker, io, tmp } from "./helpers.mjs";
|
||||
|
||||
function setup(t) {
|
||||
const bus = broker(t);
|
||||
const dataRoot = tmp(t);
|
||||
const run = async (argv, x = io()) => {
|
||||
try {
|
||||
await main(argv, x, { system: { dataRoot }, transport: bus.transport });
|
||||
return { code: 0, io: x };
|
||||
} catch (e) {
|
||||
if (!(e instanceof CliError)) throw e;
|
||||
return { code: e.exitCode, error: e.message, io: x };
|
||||
}
|
||||
};
|
||||
return { ...bus, run };
|
||||
}
|
||||
|
||||
test("the human names an instance, lists it, and talk's message.send reaches it by that name", async (t) => {
|
||||
const s = setup(t);
|
||||
const named = await s.run(["name", "coder", "rex", "--business", "demo"]);
|
||||
assert.equal(named.code, 0, named.error);
|
||||
assert.equal(named.io.out.text, "named coder rex\n");
|
||||
assert.deepEqual(s.calls.at(-1), { business: "demo", verb: "name.set", args: { instance: "coder", name: "rex" } });
|
||||
|
||||
const listed = await s.run(["names", "--business", "demo"]);
|
||||
assert.equal(listed.code, 0, listed.error);
|
||||
assert.equal(listed.io.out.text, "coder rex\n");
|
||||
const json = await s.run(["names", "--json", "--business", "demo"]);
|
||||
assert.deepEqual(
|
||||
JSON.parse(json.io.out.text).map(({ instance, name }) => ({ instance, name })),
|
||||
[{ instance: "coder", name: "rex" }],
|
||||
);
|
||||
|
||||
const sent = await s.run(["talk", "rex", "hello", "--wait", "0", "--business", "demo"]);
|
||||
assert.equal(sent.code, 0, sent.error);
|
||||
assert.match(sent.io.out.text, /^sent [0-9a-f]{8} to rex\n/);
|
||||
const [m] = s.b.request(s.coder, { verb: "message.receive" });
|
||||
assert.equal(m.to_role, "coder");
|
||||
assert.equal(m.to_name, "rex");
|
||||
});
|
||||
|
||||
test("name and names refuse the wrong number of arguments with the usage", async (t) => {
|
||||
const s = setup(t);
|
||||
for (const argv of [["name", "coder"], ["name", "coder", "rex", "extra"], ["names", "coder"]]) {
|
||||
const r = await s.run([...argv, "--business", "demo"]);
|
||||
assert.equal(r.code, 4, argv.join(" "));
|
||||
assert.ok(r.error.endsWith(USAGE), r.error);
|
||||
}
|
||||
assert.equal(s.calls.length, 0);
|
||||
assert.match(USAGE, /^ {2}mosaic name <instance> <name> \[--business <id>\]$/m);
|
||||
assert.match(USAGE, /^ {2}mosaic names \[--business <id>\] \[--json\]$/m);
|
||||
});
|
||||
@@ -95,7 +95,10 @@ and `count`, 1 to 20) and an optional `note`. A pm has no `launch` tool, and
|
||||
the chief of staff's `launch` offer is still its launch block's instances.
|
||||
|
||||
The chief of staff's two grants from row 123 have tools (row 132, #1628),
|
||||
so its manifest lists both in `typed` and nothing in `untyped`:
|
||||
so its manifest lists both in `typed`. Its `instance.name` grant (row 141,
|
||||
#1635) has no tool until row 155 (#1649), so the manifest lists it in
|
||||
`untyped` and the prompt names it; the human sets names with `mosaic name`
|
||||
until then. The two row 123 tools:
|
||||
|
||||
- `end_run` (role.end holders) sends `role.revoke` with `role` and a
|
||||
`reason` of 1 to 500 characters, never a `decision`. The broker refuses
|
||||
|
||||
@@ -93,7 +93,7 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
|
||||
const add = (name, verb, action, description, parameters) => tools.push({ name, verb, action, description, parameters });
|
||||
if (held.has("message.send")) {
|
||||
add("send_message", "message.send", "message.send", "Send a message to another role instance, or to \"human\". Your final answer to a message is sent back to its sender for you; use this for anything else.", obj({
|
||||
to: str("Role instance, or \"human\""),
|
||||
to: str("Role instance, its name, or \"human\""),
|
||||
body: str("Message text"),
|
||||
class: str("Message class", { enum: MESSAGE_CLASSES }),
|
||||
in_reply_to: str("Id of the message this answers"),
|
||||
|
||||
@@ -82,16 +82,18 @@ test("a chief-of-staff bundle lists launch among the bus tools and the manifest'
|
||||
assert.deepEqual(JSON.parse(readFileSync(b.files.policy, "utf8")).needs, []);
|
||||
});
|
||||
|
||||
// Row 132 (#1628, REQ-ROLE-2, REQ-COS-1): the 3d re-pin window checks this manifest: both tools typed, nothing untyped.
|
||||
test("a chief-of-staff bundle lists end_run and change_organization as typed and nothing untyped, for either harness", (t) => {
|
||||
// Row 132 (#1628, REQ-ROLE-2, REQ-COS-1): the 3d re-pin window checks this manifest: both tools typed.
|
||||
// Row 141 (#1635, REQ-MSG-2) grants instance.name before row 155 (#1649) gives it a tool, so it is
|
||||
// untyped until then, and the prompt tells the chief so.
|
||||
test("a chief-of-staff bundle lists end_run and change_organization as typed and only instance.name untyped, for either harness", (t) => {
|
||||
for (const harness of ["pi", "claude-code"]) {
|
||||
const r = resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { harness, model: "claude-opus-5-5" }));
|
||||
const b = build(t, r);
|
||||
const prefix = harness === "pi" ? "" : "mcp__mosaic__";
|
||||
assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "change_organization", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"].map((n) => prefix + n), harness);
|
||||
assert.deepEqual(b.manifest.untyped, [], harness);
|
||||
assert.deepEqual(b.manifest.untyped, ["instance.name"], harness);
|
||||
assert.match(readFileSync(b.files.prompt, "utf8"), /Bus tools: send_message, launch, end_run, change_organization, read_spec, write_record, raise_decision, /);
|
||||
assert.doesNotMatch(readFileSync(b.files.prompt, "utf8"), /Actions you hold that have no bus tool yet/);
|
||||
assert.match(readFileSync(b.files.prompt, "utf8"), /Actions you hold that have no bus tool yet: instance\.name\. /);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -38,8 +38,9 @@ test("the chief of staff gets launch, send_message, end_run and change_organizat
|
||||
assert.equal(launch.verb, "launch");
|
||||
assert.equal(launch.action, "role.launch");
|
||||
assert.deepEqual(launch.parameters.properties.instance.enum, ["coder", "reviewer"]);
|
||||
// Row 132 (#1628): role.end and organization.change have their tools, so nothing the chief holds is untyped.
|
||||
assert.deepEqual(untypedActions(chief, tools), []);
|
||||
// Row 132 (#1628): role.end and organization.change have their tools. Row 141 (#1635) grants
|
||||
// instance.name, whose tool is row 155's (#1649), so it is the one untyped action until then.
|
||||
assert.deepEqual(untypedActions(chief, tools), ["instance.name"]);
|
||||
});
|
||||
|
||||
// Row 140 (#1634, REQ-COS-1): the pm asks for staff with request_staff, which spends message.send; the
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"tools": ["read", "grep", "find", "ls"],
|
||||
"network": "api-only",
|
||||
"authority": {
|
||||
"withinRole": ["message.send", "role.launch", "role.end", "organization.change", "record.write"],
|
||||
"withinRole": ["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name"],
|
||||
"crossRole": []
|
||||
},
|
||||
"credentials": []
|
||||
|
||||
Reference in New Issue
Block a user