Unclaimed files left in the canonical checkout. Inventory: canonical-cleanup-inventory.md (commit-as-evidence groups). detect-secrets scan clean on 2026-11-02: hits are sha256 pins, git object ids, test sentinels already in tracked tests and unit-file lines.
1346 lines
83 KiB
Diff
1346 lines
83 KiB
Diff
diff --git a/packages/bus/README.md b/packages/bus/README.md
|
|
index ce9a5b15d..6f13fadf4 100644
|
|
--- a/packages/bus/README.md
|
|
+++ b/packages/bus/README.md
|
|
@@ -144,9 +144,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}`. |
|
|
@@ -598,10 +600,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.
|
|
@@ -709,3 +711,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.
|
|
diff --git a/packages/bus/schema.sql b/packages/bus/schema.sql
|
|
index 6603cdad8..6e306431f 100644
|
|
--- a/packages/bus/schema.sql
|
|
+++ b/packages/bus/schema.sql
|
|
@@ -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;
|
|
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
|
|
index f7258d656..6ebfbb80d 100644
|
|
--- a/packages/bus/src/broker.mjs
|
|
+++ b/packages/bus/src/broker.mjs
|
|
@@ -1,5 +1,6 @@
|
|
import { randomUUID, randomBytes } from 'node:crypto';
|
|
import { loadRequirements } from '../../tasks/src/requirements.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([
|
|
@@ -23,6 +24,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',
|
|
@@ -54,6 +57,7 @@ const ROUTED = new Set([
|
|
'role.revoke',
|
|
'role.end',
|
|
'organization.change',
|
|
+ 'instance.name',
|
|
'policy.change',
|
|
'prd.approve',
|
|
'decision.resolve.technical',
|
|
@@ -536,6 +540,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');
|
|
@@ -1380,30 +1400,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 &&
|
|
@@ -1427,27 +1456,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': {
|
|
@@ -1763,6 +1793,43 @@ export class Broker {
|
|
// for in_reply_to; trail would return [].
|
|
// 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.
|
|
+ // 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,
|
|
+ );
|
|
+ }
|
|
case 'project.create': {
|
|
this.#human(s);
|
|
keys(a, ['name', 'repo'], ['name']);
|
|
diff --git a/packages/bus/src/migrations/3f-3g.sql b/packages/bus/src/migrations/3f-3g.sql
|
|
new file mode 100644
|
|
index 000000000..b40fda2b6
|
|
--- /dev/null
|
|
+++ b/packages/bus/src/migrations/3f-3g.sql
|
|
@@ -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;
|
|
diff --git a/packages/bus/src/store.mjs b/packages/bus/src/store.mjs
|
|
index 92bd59b50..8f29dd37c 100644
|
|
--- a/packages/bus/src/store.mjs
|
|
+++ b/packages/bus/src/store.mjs
|
|
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
|
|
import { createHash } from 'node:crypto';
|
|
|
|
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
|
|
-export const SCHEMA_SHA256 = '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');
|
|
diff --git a/packages/bus/tests/chief-of-staff.test.mjs b/packages/bus/tests/chief-of-staff.test.mjs
|
|
index 6de389662..46505ef73 100644
|
|
--- a/packages/bus/tests/chief-of-staff.test.mjs
|
|
+++ b/packages/bus/tests/chief-of-staff.test.mjs
|
|
@@ -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 });
|
|
diff --git a/packages/bus/tests/comment-migration.test.mjs b/packages/bus/tests/comment-migration.test.mjs
|
|
index 0aa4b0292..d7675e297 100644
|
|
--- a/packages/bus/tests/comment-migration.test.mjs
|
|
+++ b/packages/bus/tests/comment-migration.test.mjs
|
|
@@ -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);
|
|
diff --git a/packages/bus/tests/fixtures/schema-3f.sql b/packages/bus/tests/fixtures/schema-3f.sql
|
|
new file mode 100644
|
|
index 000000000..6603cdad8
|
|
--- /dev/null
|
|
+++ b/packages/bus/tests/fixtures/schema-3f.sql
|
|
@@ -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;
|
|
diff --git a/packages/bus/tests/name-migration.test.mjs b/packages/bus/tests/name-migration.test.mjs
|
|
new file mode 100644
|
|
index 000000000..3c3e361f9
|
|
--- /dev/null
|
|
+++ b/packages/bus/tests/name-migration.test.mjs
|
|
@@ -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();
|
|
+});
|
|
diff --git a/packages/bus/tests/names.test.mjs b/packages/bus/tests/names.test.mjs
|
|
new file mode 100644
|
|
index 000000000..e834e6a20
|
|
--- /dev/null
|
|
+++ b/packages/bus/tests/names.test.mjs
|
|
@@ -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('x','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('x','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('x','demo','dev','reviewer','jason')");
|
|
+ store.run("INSERT INTO instance_names(at,business,instance,name,by) VALUES('x','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'), []);
|
|
+});
|
|
diff --git a/packages/bus/tests/spec-migration.test.mjs b/packages/bus/tests/spec-migration.test.mjs
|
|
index bf6ec31c0..6034c4598 100644
|
|
--- a/packages/bus/tests/spec-migration.test.mjs
|
|
+++ b/packages/bus/tests/spec-migration.test.mjs
|
|
@@ -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'), []);
|
|
diff --git a/packages/bus/tests/thread-migration.test.mjs b/packages/bus/tests/thread-migration.test.mjs
|
|
index 5a3785580..64728cc71 100644
|
|
--- a/packages/bus/tests/thread-migration.test.mjs
|
|
+++ b/packages/bus/tests/thread-migration.test.mjs
|
|
@@ -49,7 +49,7 @@ async function database3d(t) {
|
|
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
|
const pinned = source
|
|
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
|
|
- .replace("export const SCHEMA_VERSION = '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);
|
|
@@ -95,7 +95,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
|
|
assert.throws(() => new Store(root), /schema-mismatch/);
|
|
const store = new Store(root, { migrateSchema: true });
|
|
t.after(() => store.close());
|
|
- assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '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.
|
|
@@ -155,5 +155,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'));
|
|
});
|
|
diff --git a/packages/business/src/vocabulary.mjs b/packages/business/src/vocabulary.mjs
|
|
index 05d09d626..325da91ff 100644
|
|
--- a/packages/business/src/vocabulary.mjs
|
|
+++ b/packages/business/src/vocabulary.mjs
|
|
@@ -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",
|
|
diff --git a/packages/business/tests/chief-of-staff.test.mjs b/packages/business/tests/chief-of-staff.test.mjs
|
|
index 1d85010b1..51a49123b 100644
|
|
--- a/packages/business/tests/chief-of-staff.test.mjs
|
|
+++ b/packages/business/tests/chief-of-staff.test.mjs
|
|
@@ -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);
|
|
}
|
|
diff --git a/packages/business/tests/hard-waits.test.mjs b/packages/business/tests/hard-waits.test.mjs
|
|
index 57132425d..06d8ff4c0 100644
|
|
--- a/packages/business/tests/hard-waits.test.mjs
|
|
+++ b/packages/business/tests/hard-waits.test.mjs
|
|
@@ -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);
|
|
diff --git a/packages/business/tests/role.test.mjs b/packages/business/tests/role.test.mjs
|
|
index a81c2b2b9..48917bb68 100644
|
|
--- a/packages/business/tests/role.test.mjs
|
|
+++ b/packages/business/tests/role.test.mjs
|
|
@@ -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"));
|
|
});
|
|
diff --git a/packages/cli/README.md b/packages/cli/README.md
|
|
index 3b6a307ad..bb2690f87 100644
|
|
--- a/packages/cli/README.md
|
|
+++ b/packages/cli/README.md
|
|
@@ -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`.
|
|
diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs
|
|
index 99a070ac0..0c1c81448 100644
|
|
--- a/packages/cli/src/cli.mjs
|
|
+++ b/packages/cli/src/cli.mjs
|
|
@@ -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);
|
|
diff --git a/packages/cli/tests/names.test.mjs b/packages/cli/tests/names.test.mjs
|
|
new file mode 100644
|
|
index 000000000..b2f749ec5
|
|
--- /dev/null
|
|
+++ b/packages/cli/tests/names.test.mjs
|
|
@@ -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);
|
|
+});
|
|
diff --git a/packages/harness/README.md b/packages/harness/README.md
|
|
index c304d38be..867c78d7a 100644
|
|
--- a/packages/harness/README.md
|
|
+++ b/packages/harness/README.md
|
|
@@ -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
|
|
diff --git a/packages/harness/src/tools.mjs b/packages/harness/src/tools.mjs
|
|
index 142650912..122b0b7b7 100644
|
|
--- a/packages/harness/src/tools.mjs
|
|
+++ b/packages/harness/src/tools.mjs
|
|
@@ -92,7 +92,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"),
|
|
diff --git a/packages/harness/tests/bundle.test.mjs b/packages/harness/tests/bundle.test.mjs
|
|
index d7f731273..513e009a7 100644
|
|
--- a/packages/harness/tests/bundle.test.mjs
|
|
+++ b/packages/harness/tests/bundle.test.mjs
|
|
@@ -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\. /);
|
|
}
|
|
});
|
|
|
|
diff --git a/packages/harness/tests/tools.test.mjs b/packages/harness/tests/tools.test.mjs
|
|
index 458d0ddbc..60961781a 100644
|
|
--- a/packages/harness/tests/tools.test.mjs
|
|
+++ b/packages/harness/tests/tools.test.mjs
|
|
@@ -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
|
|
diff --git a/roles/chief-of-staff.json b/roles/chief-of-staff.json
|
|
index 100c018bd..60fb6cf9a 100644
|
|
--- a/roles/chief-of-staff.json
|
|
+++ b/roles/chief-of-staff.json
|
|
@@ -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": []
|