bus, business, harness, webui: the human and the chief of staff configure the assistant (row 155, Q169, #1649)
Jason's Q169: only the chief of staff removes the assistant; the chief
and the human configure it and its seat name.
Configure. A new organization key, assistants, replaces the vars,
tracker or definition of an instance whose definition is already the
assistant. holder and credentials are refused, and the whole document
still goes through validateBusinessDocument. set() refuses
organization-definition-refused if any other instance's definition
changed. THREAD_DEFINITION and END_PROTECTED move to
packages/business/src/vocabulary.mjs, so the broker and
launch-settings.mjs read one list. The human writes it with
mosaic limits set --file or the Console Settings view's Assistant
section; the chief writes it with change_organization. Both carry the
file's revision.
Seat name. The chief gets a typed set_name tool (name.set,
instance.name). The Console gets POST /api/name on the settings
boundary, through human-cli.
Remove. role.end and role.revoke of a threaded instance revoke every
thread of that instance whose latest claim is a claim, and report
threads: N. The raise and the check both record a missing holder run as
null. A new human-only verb, assistant.stop, revokes the human's own
threads of an assistant instance; the Console calls it from POST
/api/assistant-stop. The bundle, the end_run description and
roles/chief-of-staff.md now say the chief ends and configures the
assistant. No schema step.
Owner Darkwing, brief docs/plans/2026-10-16_q169-assistant-config.md.
Reviewers: Dewey (security) r1 approve 29012, r2 29014, r3 29018; Filbert r1 29010,
r2 29015, r3 29017, on candidate manifest 96cae28e...9016. Round 2 is
confirm-only after rows 162 and 125 moved the base under docs/TOOLS.md
and the cli README. Round 3 bumps row 164's webui/src/bus.mjs byte
baseline in packages/bus/tests/channel-proof.test.mjs for the eight
STATUS entries; Sage's gate on r2 failed only there. Mos's decision 123 go for the public/bus.js diff
(mos-0816) covers 9f9ce31b, unchanged here. Sage gate on 197722caa:
GATE_PASSED 25/25 (node 2370, selftests 422) 21:11:38Z, a rerun on the same tree after a first run stopped at pkg-webui on a board-held.test.mjs timeout that passes singly.
Nothing live. Follow-up brief in the packet: the names read rethrow, one
shared settings snapshot, null tests for the two new routes.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
+10
-1
@@ -636,7 +636,10 @@ model responses) on stdout.
|
||||
|
||||
View: `scripts/mosaic limits --business ID --json`. Set: `scripts/mosaic limits
|
||||
set --business ID --file PATCH`, with the current revision and replacement
|
||||
`limits` and/or `models`, or `instances` mapping role names to model-table keys.
|
||||
`limits` and/or `models`, or `instances` mapping role names to model-table keys,
|
||||
or `assistants` mapping an assistant instance to any of its `vars`, `tracker`
|
||||
(`null` removes it) and `definition` (row 155, #1649, Jason's ruling Q169). The
|
||||
definition stays an assistant one; `holder` and `credentials` stay hand edits.
|
||||
Both use the human bus proof. Settings offers the same edits in signed-in send
|
||||
mode. Unset means unlimited, zero blocks new launches, and running sessions
|
||||
remain alive when a limit is lowered. Total, provider and exact model limits
|
||||
@@ -653,6 +656,12 @@ read shows it as `not enforced`; a failing check refuses and stops (fail
|
||||
closed). Without `--json` the read prints JSON without `tokens` followed by
|
||||
`token limits:` text lines, so scripts must pass `--json`.
|
||||
|
||||
The Console's Assistant section (signed-in send mode) also sets an instance's
|
||||
seat name, as `scripts/mosaic name` does, and stops the assistant's runs in the
|
||||
human's threads. The chief of staff makes the same changes with its
|
||||
`change_organization`, `set_name` and `end_run` tools. Until row 134 gives it a
|
||||
revision read, the revision comes from the human.
|
||||
|
||||
Convert an old organization file with `scripts/mosaic limits migrate --file
|
||||
ORGANIZATION --models MAPPING --roles-dir roles > CONVERTED`. It needs no bus,
|
||||
never edits the input or system config and has no write flag. Inherited models
|
||||
|
||||
+12
-8
@@ -181,8 +181,8 @@ an operator inspect content and delivery evidence after an uncertain result.
|
||||
|---|---|
|
||||
| `role.claim` | No args; claims capability's role/run. One current holder per business/instance; a revoked run cannot reclaim that role, including after restart. |
|
||||
| `role.release` | No args; only its current holder releases. |
|
||||
| `role.revoke` | `{role,decision}`; needs an approved decision for this exact target and holder incarnation, routed as REQ-DEC-2 says: the human's for an uncited gated raise, the arbiter's for one citing the current spec. A role whose file lists `role.end` (the chief of staff, row 123, REQ-COS-1) sends `{role,reason}` instead: no decision, a reason of 1 to 500 characters, logged in the `action.allowed` body. Sending both is `invalid-request`; the caller's own run is `role-end-refused`; any other role sending `reason` gets `decision-required`. An instance whose definition is in `END_PROTECTED` (the assistant) is ended or revoked only by the chief of staff or with a decision the human resolved through the CLI (`via` `cli`); anyone else's reason, or a decision an arbiter resolved, is `role-end-refused` (Jason's ruling Q169, row 139). |
|
||||
| `organization.set` | `{revision,limits?,models?,instances?}`; the keys `limits.set` takes, through the same writer and receipt. Needs `organization.change` in the caller's role file (the chief of staff); the receipt's actor is `<definition>.<instance>`, e.g. `chief-of-staff.chief`. Every other key is `invalid-request`. `limits` and `limits.set` stay human-only. |
|
||||
| `role.revoke` | `{role,decision}`; needs an approved decision for this exact target and holder incarnation, routed as REQ-DEC-2 says: the human's for an uncited gated raise, the arbiter's for one citing the current spec. A role whose file lists `role.end` (the chief of staff, row 123, REQ-COS-1) sends `{role,reason}` instead: no decision, a reason of 1 to 500 characters, logged in the `action.allowed` body. Sending both is `invalid-request`; the caller's own run is `role-end-refused`; any other role sending `reason` gets `decision-required`. An instance whose definition is in `END_PROTECTED` (the assistant) is ended or revoked only by the chief of staff or with a decision the human resolved through the CLI (`via` `cli`); anyone else's reason, or a decision an arbiter resolved, is `role-end-refused` (Jason's ruling Q169, row 139). A target whose definition is `THREAD_DEFINITION` (the assistant) has no role claim: the end revokes the current claim of each of its threads, refuses `not-held` when none is held, and returns `{revoked:true,threads:N}` (row 155, #1649). |
|
||||
| `organization.set` | `{revision,limits?,models?,instances?,assistants?}`; the keys `limits.set` takes, through the same writer and receipt. Needs `organization.change` in the caller's role file (the chief of staff); the receipt's actor is `<definition>.<instance>`, e.g. `chief-of-staff.chief`. Every other key is `invalid-request`. `limits` and `limits.set` stay human-only. `assistants` (row 155, Q169) is described in the business README; a refusal from it passes through as its `organization-*` code. No verb gives an agent the revision until row 134, so the chief of staff sends one the human gave it. |
|
||||
| `decision.raise` | `{action,question,options:[{key,text}],recommendation,blocking,domain?,target?,project?,task_ref?,requirement_ref?,supersedes?,choice?,approvalChoice?}`. Returns decision with decoded options and authorization context. A blocking decision holds a task: `blocking` true with no `task_ref` refuses `task-required`. A non-blocking one needs no task, and an uncited gated action such as `prd.approve` or `role.revoke` still routes to the human's inbox. |
|
||||
| `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. |
|
||||
@@ -191,6 +191,7 @@ an operator inspect content and delivery evidence after an uncertain result.
|
||||
| `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}`. |
|
||||
| `assistant.stop` | `{instance}`; outside-agent human CLI only (`human-required`), row 155 (#1649, Q169). `instance` must be a known instance (`unknown-role`) of `THREAD_DEFINITION` (`not-assistant`). Revokes the current claim of every thread of that instance the human made (REQ-USER-2), with `by` the human and reason `stopped by the human`; none held is `not-held`. Each run gets `not-holder` at its next bus call and exits. The launcher starts a run again only for a thread with pending messages or an open. Returns `{stopped:[{thread,run}]}`. |
|
||||
| `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}`. |
|
||||
@@ -270,12 +271,15 @@ A message delivered to a thread run that then ended without reading it is strand
|
||||
`unread`, so it neither starts a session nor keeps a later one from idling out. The user
|
||||
sends it again.
|
||||
|
||||
`role.revoke` doesn't reach a thread run. A thread instance has no `role_claims` row, so a
|
||||
decision to revoke it names no holder run, and using that decision refuses `decision-mismatch`.
|
||||
The chief of staff's reason path gets past the assistant's protection (Q169) and then refuses
|
||||
`not-held`. Row 155 (#1649) makes both paths stop the thread runs. A stuck thread run is stopped instead: the launcher stops it
|
||||
once the thread is idle, the human stops it with `mosaic stop <run>`, and the run's end
|
||||
releases its claim. `thread_revoke_needs_decision` guards the table against a direct write.
|
||||
Row 155 (#1649) makes `role.revoke` reach thread runs. A thread instance has no `role_claims`
|
||||
row, so a decision to revoke it records a null holder run, and the check compares it with the
|
||||
current holder read as null too. The chief of staff's reason path and a decision the human
|
||||
resolved through the CLI both get past the assistant's protection (Q169), and the end then writes
|
||||
a `revoke` row in `thread_claims`, with its decision or reason, for every thread of the instance
|
||||
whose latest claim is held. The human does the same from the Console with `assistant.stop`, for
|
||||
the threads they made. A revoked run can't claim its thread again (`run-revoked`). A stuck
|
||||
thread run can still be stopped another way: the launcher stops it once the thread is idle, the
|
||||
human stops it with `mosaic stop <run>`, and the run's end releases its claim. `thread_revoke_needs_decision` guards the table against a direct write.
|
||||
|
||||
### Agent state (row 59, #1548)
|
||||
|
||||
|
||||
+45
-10
@@ -1,7 +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';
|
||||
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED } from '../../business/src/vocabulary.mjs';
|
||||
|
||||
// Kept closed while S1 lands; its vocabulary is the integration authority.
|
||||
export const ACTIONS = Object.freeze([
|
||||
@@ -132,11 +132,11 @@ const taskRef = (x) => typeof x === 'string' && /^vikunja:[1-9][0-9]*\/[1-9][0-9
|
||||
// A git commit id, SHA-1 or SHA-256.
|
||||
const oid = (x) => typeof x === 'string' && /^[0-9a-f]{40}([0-9a-f]{24})?$/.test(x);
|
||||
// Row 123 (#1617): the one list of organization-file keys limits.set and organization.set accept.
|
||||
export const ORGANIZATION_KEYS = ['revision', 'limits', 'models', 'instances', 'reset'];
|
||||
// Row 123 (#1617): role.end never reaches the assistant (the user's seat) or the caller's own run.
|
||||
// Row 139 (#1633), Jason's ruling Q169 (PRD 1.6): only the chief of staff, or a decision the human
|
||||
// resolved through the CLI, ends or revokes one (#protectedEnd). A raise to revoke one is a hard wait.
|
||||
const END_PROTECTED = ['assistant'];
|
||||
// Row 155 (#1649) adds assistants: an assistant instance's vars, tracker and definition.
|
||||
export const ORGANIZATION_KEYS = ['revision', 'limits', 'models', 'instances', 'reset', 'assistants'];
|
||||
// Row 123 (#1617): role.end never reaches the caller's own run. END_PROTECTED (vocabulary.mjs, row
|
||||
// 155): only the chief of staff, or a decision the human resolved through the CLI, ends or revokes
|
||||
// an instance of one (#protectedEnd, Q169). A raise to revoke one is a hard wait.
|
||||
const VERDICTS = ['approve', 'changes'];
|
||||
// Row 140 (#1634, REQ-COS-1): a STAFF-REQUEST goes from an instance of STAFF_FROM to the business's
|
||||
// launcher, which must be an instance of STAFF_TO. Only staff.request writes the class. STAFF_ROLES
|
||||
@@ -149,9 +149,9 @@ const STAFF_COUNT = 20;
|
||||
// Row 93 (#1587): the review loop's evidence, written only by the verbs below. A push is an
|
||||
// action.allowed for git.push.working that names its commit, written only by recordPush.
|
||||
const REVIEW_KINDS = ['review.requested', 'review.verdict'];
|
||||
// Row 121 (#1615): an instance of this definition talks with the user in threads (REQ-THREAD-1).
|
||||
// Its runs claim a thread, not the role, and every message to or from it names its thread (REQ-MSG-1).
|
||||
const THREAD_DEFINITION = 'assistant';
|
||||
// Row 121 (#1615): an instance of THREAD_DEFINITION (vocabulary.mjs since row 155) talks with the
|
||||
// user in threads. Its runs claim a thread, not the role, and every message to or from it names its
|
||||
// thread (REQ-MSG-1).
|
||||
// Row 146 (#1640): the sender of the bus's notice to launch.by. A role instance name matches the
|
||||
// business ID_PATTERN, which starts with [a-z0-9], so no instance can be named this.
|
||||
const NOTICE_ROLE = '_bus';
|
||||
@@ -272,6 +272,17 @@ export class Broker {
|
||||
#threadHolder(thread) {
|
||||
return this.#store.get('SELECT * FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', thread);
|
||||
}
|
||||
// Row 155 (#1649): the held claims of every thread of a thread instance, oldest thread first, or
|
||||
// only the threads `owner` made. An end of the instance ends each of them.
|
||||
#threadHolders(business, instance, owner = null) {
|
||||
return this.#store.all(
|
||||
"SELECT c.* FROM threads t JOIN thread_claims c ON c.seq=(SELECT max(seq) FROM thread_claims WHERE thread=t.id) WHERE t.business=? AND t.instance=? AND (? IS NULL OR t.owner=?) AND c.op='claim' ORDER BY t.seq",
|
||||
business,
|
||||
instance,
|
||||
owner,
|
||||
owner,
|
||||
);
|
||||
}
|
||||
// The claim a run acts under: its thread's for a thread run, its role's otherwise.
|
||||
#own(s) {
|
||||
return s.thread ? this.#threadHolder(s.thread) : this.#holder(s.business, s.role);
|
||||
@@ -732,7 +743,9 @@ export class Broker {
|
||||
fail('decision-mismatch');
|
||||
if (r?.op !== 'resolved' || r.choice !== context.approvalChoice) fail('decision-not-approved');
|
||||
if (action === 'role.revoke') this.#protectedEnd(s, target, r);
|
||||
if (action === 'role.revoke' && context.holderRun !== this.#holder(s.business, target)?.holder_run)
|
||||
// A thread instance has no role claim, so both sides are null for it (row 155, #1649): the raise
|
||||
// records `?? null` and the current holder reads undefined.
|
||||
if (action === 'role.revoke' && (context.holderRun ?? null) !== (this.#holder(s.business, target)?.holder_run ?? null))
|
||||
fail('decision-mismatch');
|
||||
return { class: cls, decision: d.id };
|
||||
}
|
||||
@@ -1477,6 +1490,13 @@ export class Broker {
|
||||
identifier(a.decision);
|
||||
this.#consumeAuthority(s, 'role.revoke', { decision: a.decision, target: a.role });
|
||||
}
|
||||
// Row 155 (#1649): a thread instance holds a claim per thread, so the end revokes each one.
|
||||
if (this.#threaded(s.business, a.role)) {
|
||||
const held = this.#threadHolders(s.business, a.role);
|
||||
if (held.length === 0) fail('not-held');
|
||||
for (const h of held) this.#claimEnd(s, h, 'revoke', a.decision ?? null, a.reason ?? null);
|
||||
return { revoked: true, threads: held.length };
|
||||
}
|
||||
const h = this.#holder(s.business, a.role);
|
||||
if (h?.op !== 'claim') fail('not-held');
|
||||
this.#claimEnd(s, h, 'revoke', a.decision ?? null, a.reason ?? null);
|
||||
@@ -2110,6 +2130,19 @@ export class Broker {
|
||||
);
|
||||
return { instance: a.instance, name: a.name };
|
||||
}
|
||||
// Row 155 (#1649, Q169): the human stops the assistant from the Console. It revokes the claim of
|
||||
// every thread of the instance the human made (REQ-USER-2), so each run exits at its next bus
|
||||
// call. The launcher starts one again only for a thread with pending messages or an open.
|
||||
case 'assistant.stop': {
|
||||
this.#human(s);
|
||||
keys(a, ['instance'], ['instance']);
|
||||
if (!Object.hasOwn(b.roles, identifier(a.instance))) fail('unknown-role');
|
||||
if (!this.#threaded(s.business, a.instance)) fail('not-assistant');
|
||||
const held = this.#threadHolders(s.business, a.instance, s.human);
|
||||
if (held.length === 0) fail('not-held');
|
||||
for (const h of held) this.#claimEnd(s, h, 'revoke', null, 'stopped by the human');
|
||||
return { stopped: held.map((h) => ({ thread: h.thread, run: h.holder_run })) };
|
||||
}
|
||||
case 'names': {
|
||||
keys(a, []);
|
||||
if (!s.human) this.#agent(s);
|
||||
@@ -2287,6 +2320,8 @@ export class Broker {
|
||||
// Row 123 (#1617): a role.end revoke carries its reason in role_claims.reason and no decision.
|
||||
// Row 125 (#1619): a limit stop ends a thread claim with a reason too, and either claim with
|
||||
// by='limit-check'.
|
||||
// Row 155 (#1649): a thread claim is revoked too, by the chief's end of the assistant, a
|
||||
// human-resolved revoke or assistant.stop, and its reason goes in thread_claims.reason.
|
||||
#claimEnd(s, h, op, decision, reason = null, by = s.run ?? s.human) {
|
||||
if (h.thread)
|
||||
return this.#store.run(
|
||||
|
||||
@@ -11,7 +11,7 @@ const baselines = {
|
||||
"packages/bus/src/human.mjs": "b995fc5cff3af8ade4c990d0e3bebf9d83829e00f034ebc9c3dcfd05f252b573",
|
||||
"packages/bus/src/human-cli.mjs": "269c3a66008c0d57a54b14b73cf988d5f61f5e815455486296716e98ce5e8073",
|
||||
"packages/cli/src/transport.mjs": "5129c44b4d9f642e825344df65c9bfb4bac4fc8d6b25343b42d39853dbd40774",
|
||||
"packages/webui/src/bus.mjs": "dbad731ca8be19619064f050e766aeca856cf614783b9eca887caff3c2451f24",
|
||||
"packages/webui/src/bus.mjs": "d1f3a274f7aa15eb7ecc2267c65bf4fe7e45c898f77c34235b4e9c29cd1be241", // Row 155 (#1649): eight STATUS entries for the assistant routes, no proof or transport change
|
||||
};
|
||||
const repoFile = path => new URL("../../../" + path, import.meta.url);
|
||||
|
||||
|
||||
@@ -171,24 +171,26 @@ test('REQ-ROLE-2: a reason is required, bounded, and never sent with a decision'
|
||||
assert.deepEqual(call('chief', 'role.revoke', { role: 'coder', reason: 'x'.repeat(500) }), { revoked: true });
|
||||
});
|
||||
|
||||
test('REQ-COS-1: the chief of staff cannot end its own run; its end of the assistant gets past the protection', (t) => {
|
||||
test('REQ-COS-1: the chief of staff cannot end its own run; its end of the assistant stops the assistant\'s thread run', (t) => {
|
||||
const { store, call, lastClaim, allowed } = setup(t);
|
||||
assert.throws(() => call('chief', 'role.revoke', { role: 'chief', reason: 'no' }), /role-end-refused/);
|
||||
assert.deepEqual(lastClaim('chief'), { op: 'claim', decision: null, reason: null, by: 'chief-run' });
|
||||
// Jason's ruling Q169 (row 139): the chief of staff may end the assistant. Row 121 (#1615): the
|
||||
// assistant's run holds its thread, so its claim is in thread_claims, the end finds no role
|
||||
// claim and refuses not-held, and the run goes on. Row 155 (#1649) makes the end stop it.
|
||||
assert.throws(() => call('chief', 'role.revoke', { role: 'assistant', reason: 'Q169' }), /not-held/);
|
||||
// assistant's run holds its thread, so its claim is in thread_claims. Row 155 (#1649): the end
|
||||
// reads the thread claims for a thread instance and revokes each held one.
|
||||
assert.deepEqual(call('chief', 'role.revoke', { role: 'assistant', reason: 'Q169' }), { revoked: true, threads: 1 });
|
||||
assert.deepEqual(
|
||||
{ ...store.get('SELECT op, holder_run, by FROM thread_claims ORDER BY seq DESC LIMIT 1') },
|
||||
{ op: 'claim', holder_run: 'assistant-run', by: 'assistant-run' },
|
||||
{ ...store.get('SELECT op, holder_run, by, decision, reason FROM thread_claims ORDER BY seq DESC LIMIT 1') },
|
||||
{ op: 'revoke', holder_run: 'assistant-run', by: 'chief-run', decision: null, reason: 'Q169' },
|
||||
);
|
||||
// The refusal rolls its action.allowed back with the transaction.
|
||||
assert.equal(allowed('role.end').length, 0);
|
||||
// A role that is not held is refused as before, and logs nothing.
|
||||
assert.throws(() => call('assistant', 'inbox'), /not-holder/);
|
||||
assert.equal(allowed('role.end').length, 1);
|
||||
// With nothing held the end is refused not-held, and the refusal rolls its action.allowed back
|
||||
// with the transaction. A role that is not held is refused the same way.
|
||||
assert.throws(() => call('chief', 'role.revoke', { role: 'assistant', reason: 'again' }), /not-held/);
|
||||
call('chief', 'role.revoke', { role: 'pm', reason: 'first' });
|
||||
assert.throws(() => call('chief', 'role.revoke', { role: 'pm', reason: 'again' }), /not-held/);
|
||||
assert.equal(allowed('role.end').length, 1);
|
||||
assert.equal(allowed('role.end').length, 2);
|
||||
});
|
||||
|
||||
test('REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches the shared writer, named as the actor', (t) => {
|
||||
@@ -199,6 +201,12 @@ test('REQ-LIMIT-1, REQ-COS-1: organization.set from the chief of staff reaches t
|
||||
assert.deepEqual(organization, [['demo', 'limits.set', PATCH, 'chief-of-staff.chief']]);
|
||||
call('human', 'limits.set', PATCH);
|
||||
assert.deepEqual(organization.at(-1), ['demo', 'limits.set', PATCH, 'jason']);
|
||||
// Row 155 (#1649, Q169): the assistant's configuration takes the same path, for both actors.
|
||||
const assistants = { revision: 'a'.repeat(64), assistants: { assistant: { vars: { thinking: 'high' } } } };
|
||||
call('chief', 'organization.set', assistants);
|
||||
assert.deepEqual(organization.at(-1), ['demo', 'limits.set', assistants, 'chief-of-staff.chief']);
|
||||
call('human', 'limits.set', assistants);
|
||||
assert.deepEqual(organization.at(-1), ['demo', 'limits.set', assistants, 'jason']);
|
||||
});
|
||||
|
||||
test('REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and no action.allowed, like a refused limits.set', (t) => {
|
||||
@@ -232,8 +240,11 @@ test('REQ-LIMIT-1, REQ-COS-1: a refused organization.set logs action.refused and
|
||||
|
||||
test('REQ-LIMIT-1: organization.set is refused for every caller whose role file lacks organization.change', (t) => {
|
||||
const { call, organization, allowed } = setup(t);
|
||||
// Row 155 (#1649, Q169): an assistants change too, so the assistant can't configure itself.
|
||||
const assistants = { revision: 'a'.repeat(64), assistants: { assistant: { vars: { model: 'x' } } } };
|
||||
for (const who of ['pm', 'coder', 'assistant'])
|
||||
assert.throws(() => call(who, 'organization.set', PATCH), /decision-required/, who);
|
||||
for (const args of [PATCH, assistants])
|
||||
assert.throws(() => call(who, 'organization.set', args), /decision-required/, who);
|
||||
assert.throws(() => call('human', 'organization.set', PATCH), /agent-required/);
|
||||
assert.throws(() => call('reader', 'organization.set', PATCH), /read-only/);
|
||||
assert.throws(() => call('tester', 'organization.set', PATCH), /tester-refused/);
|
||||
@@ -340,6 +351,60 @@ test('REQ-ROLE-3, REQ-LIMIT-1: a chief-of-staff change is validated, written and
|
||||
assert.equal(receipts().length, count);
|
||||
});
|
||||
|
||||
test('Q169: the chief of staff\'s change of the assistant is validated, written and logged; a stale or protection-stripping one is refused', async (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-chief-assistant-'));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const file = join(root, 'businesses/demo.json');
|
||||
const doc = businessDoc(root, 'demo');
|
||||
doc.roles.assistant = { definition: 'assistant', vars: { model: 'claude-sonnet-5-5' } };
|
||||
writeJson(file, doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({
|
||||
dataRoot: join(root, 'data'),
|
||||
businesses: demo(),
|
||||
organization: { dir: root, rolesDir, system: systemFor(root) },
|
||||
});
|
||||
t.after(async () => {
|
||||
await bus.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const { broker } = bus;
|
||||
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const chief = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
||||
broker.request(chief, { verb: 'role.claim' });
|
||||
const set = (args) => broker.request(chief, { verb: 'organization.set', args });
|
||||
const document = () => JSON.parse(readFileSync(file, 'utf8'));
|
||||
const last = () => readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').map(JSON.parse).at(-1);
|
||||
|
||||
// Until row 134 the chief has no read of the revision, so the human's read hands it over here.
|
||||
const { revision } = broker.request(human, { verb: 'limits' });
|
||||
const done = set({ revision, assistants: { assistant: { vars: { model: 'claude-opus-5-5', thinking: 'high' } } } });
|
||||
assert.deepEqual(done.assistants.assistant, { definition: 'assistant', vars: { model: 'claude-opus-5-5', thinking: 'high' }, tracker: null });
|
||||
assert.deepEqual(document().roles.assistant, { definition: 'assistant', vars: { model: 'claude-opus-5-5', thinking: 'high' } });
|
||||
assert.equal(last().actor, 'chief-of-staff.chief');
|
||||
assert.ok(last().changes.some((c) => c.path === 'roles.assistant.vars.thinking' && c.new === 'high'));
|
||||
// The human's change, as `mosaic limits set` and the Console send it, is logged as the human.
|
||||
const byHuman = broker.request(human, {
|
||||
verb: 'limits.set',
|
||||
args: { revision: done.revision, assistants: { assistant: { definition: 'assistant', vars: { model: 'claude-sonnet-5-5' }, tracker: null } } },
|
||||
});
|
||||
assert.deepEqual(document().roles.assistant, { definition: 'assistant', vars: { model: 'claude-sonnet-5-5' } });
|
||||
assert.equal(last().actor, 'jason');
|
||||
assert.ok(last().changes.some((c) => c.path === 'roles.assistant.vars.thinking' && c.old === 'high'));
|
||||
const now = set({ revision: byHuman.revision, assistants: { assistant: { vars: { model: 'glm-5.3-flash' } } } });
|
||||
|
||||
const bytes = readFileSync(file, 'utf8');
|
||||
for (const [args, code] of [
|
||||
[{ revision: byHuman.revision, assistants: { assistant: { vars: {} } } }, /revision-conflict/],
|
||||
[{ revision: now.revision, assistants: { assistant: { definition: 'coder' } } }, /organization-definition-refused/],
|
||||
[{ revision: now.revision, assistants: { coder: { vars: {} } } }, /organization-not-assistant/],
|
||||
[{ revision: now.revision, assistants: { assistant: { holder: 'mallory' } } }, /invalid-request/],
|
||||
[{ revision: now.revision, assistants: { assistant: { vars: { model: 'no-such-model' } } } }, /invalid-request|unknown-model/],
|
||||
])
|
||||
assert.throws(() => set(args), code, JSON.stringify(args));
|
||||
assert.equal(readFileSync(file, 'utf8'), bytes);
|
||||
});
|
||||
|
||||
// REQ-ROLE-2, REQ-COS-1: the 3c→3d step. The 3c database comes out of row 49's real 3b→3c step, run by a
|
||||
// copy of the engine pinned at 3c (its schema.sql is tests/fixtures/schema-3c.sql), not hand-built DDL.
|
||||
const here = (path) => new URL(path, import.meta.url);
|
||||
|
||||
@@ -105,22 +105,63 @@ test('REQ-DEC-2: a stale or unconfirmed citation refuses without a decision; an
|
||||
// Jason's ruling Q169 (PRD 1.6): only the chief of staff, or a decision the human resolved through
|
||||
// the CLI, gets past END_PROTECTED. A raise to revoke the assistant goes to the human even when
|
||||
// cited; any other cited revoke routes as REQ-DEC-2 says. The assistant's runs hold thread claims,
|
||||
// so neither path can stop one yet (row 155, #1649), and the assistant tests assert the protection only.
|
||||
// and row 155 (#1649) makes both paths revoke every held one.
|
||||
const revoke = (f, cap, role, extra = {}) => f.call(cap, 'decision.raise', { action: 'role.revoke', target: role,
|
||||
question: 'Revoke?', options: [{ key: 'yes', text: 'Revoke' }, { key: 'no', text: 'Keep' }], recommendation: 'no',
|
||||
blocking: false, ...extra });
|
||||
const grants = withinRole => ({ authority: { withinRole: ['message.send', ...withinRole], crossRole: [] } });
|
||||
const seats = { assistant: { definition: 'assistant', ...grants([]) }, pm: { definition: 'pm', ...grants([]) } };
|
||||
// Row 155: an assistant run holding its own thread, so an end has a claim to stop.
|
||||
const held = (f, run) => {
|
||||
const project = f.call(f.human, 'project.create', { name: `p-${run}` }).id;
|
||||
const id = f.call(f.human, 'thread.create', { project, name: run }).id;
|
||||
const cap = f.broker.bindLaunch({ business: 'demo', role: 'assistant', run, harness: 'pi', thread: id });
|
||||
f.call(cap, 'role.claim');
|
||||
return { id, cap };
|
||||
};
|
||||
const lastThreadClaim = (f, thread) =>
|
||||
({ ...f.store.get('SELECT op, holder_run, by, decision, reason FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', thread) });
|
||||
const allowedCount = f => f.store.get("SELECT count(*) n FROM events WHERE kind='action.allowed'").n;
|
||||
|
||||
test('Q169: the chief of staff\'s end of the assistant is not refused role-end-refused; its own run and another ender\'s are', async t => {
|
||||
test('Q169: the chief of staff\'s end of the assistant stops every thread run; its own run and another ender\'s are refused', async t => {
|
||||
const f = await fixture(t, { roles: { ...seats,
|
||||
chief: { definition: 'chief-of-staff', ...grants(['role.end']) }, lead: { definition: 'lead', ...grants(['role.end']) } } });
|
||||
const chief = f.agent('chief'), lead = f.agent('lead');
|
||||
// Past the protection the end reads role_claims, and the assistant holds none (row 155).
|
||||
// No thread is held yet, so there is nothing to end.
|
||||
assert.throws(() => f.call(chief, 'role.revoke', { role: 'assistant', reason: 'Q169' }), /not-held/);
|
||||
const a = held(f, 'a-run'), b = held(f, 'b-run');
|
||||
assert.throws(() => f.call(chief, 'role.revoke', { role: 'chief', reason: 'own run' }), /role-end-refused/);
|
||||
assert.throws(() => f.call(lead, 'role.revoke', { role: 'assistant', reason: 'not the chief' }), /role-end-refused/);
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE kind='action.allowed'").n, 0);
|
||||
assert.equal(allowedCount(f), 0);
|
||||
assert.deepEqual(f.call(chief, 'role.revoke', { role: 'assistant', reason: 'Q169' }), { revoked: true, threads: 2 });
|
||||
for (const [x, run] of [[a, 'a-run'], [b, 'b-run']])
|
||||
assert.deepEqual(lastThreadClaim(f, x.id), { op: 'revoke', holder_run: run, by: 'chief-run', decision: null, reason: 'Q169' });
|
||||
// The run's next bus call is refused, which the runner reads as the session gone, and it can't claim
|
||||
// its thread again. A new run for the thread can.
|
||||
assert.throws(() => f.call(a.cap, 'inbox'), /not-holder/);
|
||||
assert.throws(() => f.call(a.cap, 'role.claim'), /run-revoked/);
|
||||
f.call(f.broker.bindLaunch({ business: 'demo', role: 'assistant', run: 'a2-run', harness: 'pi', thread: a.id }), 'role.claim');
|
||||
assert.deepEqual(f.call(chief, 'role.revoke', { role: 'assistant', reason: 'again' }), { revoked: true, threads: 1 });
|
||||
assert.throws(() => f.call(chief, 'role.revoke', { role: 'assistant', reason: 'none left' }), /not-held/);
|
||||
assert.equal(allowedCount(f), 2);
|
||||
});
|
||||
|
||||
test('Q169: a revoke of the assistant the chief of staff raises goes to the human, cited or not, and no arbiter resolves it', async t => {
|
||||
const f = await fixture(t, { roles: { ...seats, chief: { definition: 'chief-of-staff', ...grants(['role.end']) } } });
|
||||
const chief = f.agent('chief');
|
||||
for (const args of [{}, cite(f), { ...cite(f), domain: 'technical' }, { ...cite(f), domain: 'delivery' }]) {
|
||||
const d = await revoke(f, chief, 'assistant', args);
|
||||
assert.deepEqual(routed(f, d), { class: 'gated', route_to: 'human' }, JSON.stringify(args));
|
||||
assert.throws(() => f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
}
|
||||
// Resolved by the human, the chief's revoke citing it stops the thread run, once.
|
||||
const a = held(f, 'a-run');
|
||||
const d = await revoke(f, chief, 'assistant', cite(f));
|
||||
f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
assert.deepEqual(f.call(chief, 'role.revoke', { role: 'assistant', decision: d.id }), { revoked: true, threads: 1 });
|
||||
assert.deepEqual(lastThreadClaim(f, a.id), { op: 'revoke', holder_run: 'a-run', by: 'chief-run', decision: d.id, reason: null });
|
||||
held(f, 'b-run');
|
||||
assert.throws(() => f.call(chief, 'role.revoke', { role: 'assistant', decision: d.id }), /decision-consumed/);
|
||||
});
|
||||
|
||||
test('Q169: another role\'s cited revoke of the assistant, resolved by an arbiter, is refused', async t => {
|
||||
@@ -138,14 +179,16 @@ test('Q169: another role\'s cited revoke of the assistant, resolved by an arbite
|
||||
assert.throws(() => later.request(coder, { verb: 'role.revoke', args: { role: 'pm', decision: d.id } }), /role-end-refused/);
|
||||
});
|
||||
|
||||
test('Q169: a revoke of the assistant citing a decision the human resolved is not refused by the protection', async t => {
|
||||
test('Q169: a revoke of the assistant citing a decision the human resolved stops its thread run', async t => {
|
||||
const f = await fixture(t, { roles: seats });
|
||||
const d = await revoke(f, f.coder, 'assistant', cite(f));
|
||||
assert.throws(() => f.call(f.reviewer, 'decision.resolve', { id: d.id, choice: 'yes' }), /human-required/);
|
||||
f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
// Past the protection, the recorded holder run (null) and the role_claims one (undefined)
|
||||
// mismatch, because the assistant's runs hold thread claims (row 155).
|
||||
assert.throws(() => f.call(f.coder, 'role.revoke', { role: 'assistant', decision: d.id }), /decision-mismatch/);
|
||||
// Row 155: the recorded holder run (null) and the role_claims one (none) compare equal, so the
|
||||
// revoke reaches the thread claims and stops the held one.
|
||||
const a = held(f, 'a-run');
|
||||
assert.deepEqual(f.call(f.coder, 'role.revoke', { role: 'assistant', decision: d.id }), { revoked: true, threads: 1 });
|
||||
assert.deepEqual(lastThreadClaim(f, a.id), { op: 'revoke', holder_run: 'a-run', by: 'coder-run', decision: d.id, reason: null });
|
||||
});
|
||||
|
||||
test('Q169: a cited revoke of a role not in END_PROTECTED still goes to the arbiter, and executes', async t => {
|
||||
|
||||
@@ -17,8 +17,9 @@ test('Broker keeps the existing sandbox client import closure: no new imports or
|
||||
// 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';",
|
||||
// RUNNER_FILES and imports nothing. Row 155 (#1649) reads THREAD_DEFINITION and END_PROTECTED
|
||||
// from it too.
|
||||
"import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED } from '../../business/src/vocabulary.mjs';",
|
||||
]);
|
||||
assert.doesNotMatch(source, /\bimport\s*\(/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
|
||||
@@ -393,8 +393,8 @@ test('REQ-THREAD-2 REQ-LAUNCH-1: a message delivered to a run that ended unread
|
||||
});
|
||||
|
||||
// Dewey's F2 in 28591, documented rather than supported: a thread run is stopped, not revoked.
|
||||
test('REQ-ROLE-2 REQ-THREAD-1: an approved role.revoke never reaches a thread run, and the thread stays held', (t) => {
|
||||
const { store, call, count, A } = twoThreads(t);
|
||||
test('REQ-ROLE-2 REQ-THREAD-1 Q169: a role.revoke the human approved ends every thread run of the assistant', (t) => {
|
||||
const { store, call, count, A, B } = twoThreads(t);
|
||||
const d = call('pm', 'decision.raise', {
|
||||
action: 'role.revoke',
|
||||
target: 'assistant',
|
||||
@@ -407,13 +407,70 @@ test('REQ-ROLE-2 REQ-THREAD-1: an approved role.revoke never reaches a thread ru
|
||||
blocking: false,
|
||||
});
|
||||
call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
// The assistant has no role_claims row, so the decision names no holder run, and using it refuses.
|
||||
assert.throws(() => call('pm', 'role.revoke', { role: 'assistant', decision: d.id }), /decision-mismatch/);
|
||||
// Row 155 (#1649): the assistant has no role_claims row, so the decision names no holder run, and
|
||||
// the revoke reads the thread claims instead. Every held thread gets a revoke citing the decision.
|
||||
assert.deepEqual(call('pm', 'role.revoke', { role: 'assistant', decision: d.id }), { revoked: true, threads: 2 });
|
||||
assert.equal(count("SELECT count(*) n FROM role_claims WHERE role='assistant'"), 0);
|
||||
assert.deepEqual({ ...store.get('SELECT op, holder_run FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', A) }, {
|
||||
op: 'claim',
|
||||
holder_run: 'a-run',
|
||||
for (const [thread, run] of [
|
||||
[A, 'a-run'],
|
||||
[B, 'b-run'],
|
||||
])
|
||||
assert.deepEqual(
|
||||
{ ...store.get('SELECT op, holder_run, by, decision FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', thread) },
|
||||
{ op: 'revoke', holder_run: run, by: 'pm-run', decision: d.id },
|
||||
);
|
||||
assert.throws(() => call('pm', 'role.revoke', { role: 'assistant', decision: d.id }), /decision-consumed/);
|
||||
});
|
||||
|
||||
// Row 155 (#1649): the human stops the assistant from the Console. Q169 reads "remove" as ending
|
||||
// its runs; the instance stays in the organization file.
|
||||
test('Q169 REQ-USER-2: assistant.stop ends the human\'s own assistant thread runs, and only the human\'s', (t) => {
|
||||
const { b, store, caps, call, launch, claim, project, A, B } = twoThreads(t);
|
||||
const other = 'thread-of-someone-else';
|
||||
store.run(
|
||||
'INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)',
|
||||
other,
|
||||
now(),
|
||||
'demo',
|
||||
project,
|
||||
'assistant',
|
||||
'someone-else',
|
||||
'theirs',
|
||||
);
|
||||
launch('o', 'assistant', { thread: other });
|
||||
claim('o');
|
||||
const last = (thread) => ({
|
||||
...store.get('SELECT op, holder_run, by, decision, reason FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', thread),
|
||||
});
|
||||
// A human session from inside an agent run is refused at the bind, so it never reaches the verb.
|
||||
assert.throws(() => b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: false }), { code: 'human-required' });
|
||||
for (const who of ['pm', 'chief', 'a'])
|
||||
assert.throws(() => call(who, 'assistant.stop', { instance: 'assistant' }), { code: 'human-required' }, who);
|
||||
assert.throws(() => call('human', 'assistant.stop', { instance: 'ghost' }), { code: 'unknown-role' });
|
||||
assert.throws(() => call('human', 'assistant.stop', { instance: 'pm' }), { code: 'not-assistant' });
|
||||
assert.throws(() => call('human', 'assistant.stop', {}), { code: 'invalid-request' });
|
||||
assert.throws(() => call('human', 'assistant.stop', { instance: 'assistant', run: 'a-run' }), { code: 'invalid-request' });
|
||||
assert.equal(last(A).op, 'claim');
|
||||
assert.deepEqual(call('human', 'assistant.stop', { instance: 'assistant' }), {
|
||||
stopped: [
|
||||
{ thread: A, run: 'a-run' },
|
||||
{ thread: B, run: 'b-run' },
|
||||
],
|
||||
});
|
||||
for (const [thread, run] of [
|
||||
[A, 'a-run'],
|
||||
[B, 'b-run'],
|
||||
])
|
||||
assert.deepEqual(last(thread), { op: 'revoke', holder_run: run, by: 'jason', decision: null, reason: 'stopped by the human' });
|
||||
assert.deepEqual(last(other), { op: 'claim', holder_run: 'o-run', by: 'o-run', decision: null, reason: null });
|
||||
// The stopped run's next call is refused, which its runner reads as the session gone, and it can't
|
||||
// claim the thread again. The next run for the thread can.
|
||||
assert.throws(() => call('a', 'inbox'), { code: 'not-holder' });
|
||||
assert.throws(() => call('a', 'role.claim'), { code: 'run-revoked' });
|
||||
assert.throws(() => call('human', 'assistant.stop', { instance: 'assistant' }), { code: 'not-held' });
|
||||
launch('a2', 'assistant', { thread: A });
|
||||
claim('a2');
|
||||
assert.deepEqual(call('human', 'assistant.stop', { instance: 'assistant' }), { stopped: [{ thread: A, run: 'a2-run' }] });
|
||||
});
|
||||
|
||||
test('REQ-USER-2: the human verbs refuse another owner\'s thread', (t) => {
|
||||
|
||||
@@ -144,7 +144,10 @@ assistant and never talks to the user. It holds `message.send` and
|
||||
(no shipped role is granted `role.revoke`, so it stays gated), and it changes the organization
|
||||
file through the broker's `organization.set`, limited to the keys the human's
|
||||
`limits.set` takes; the harness's `end_run` and `change_organization` tools
|
||||
call them (row 132). It has no credentials and the same read-only tools. It is the only shipped role that holds `role.launch`, so
|
||||
call them (row 132). Under Jason's ruling Q169 (row 155, #1649) the chief of
|
||||
staff is the one agent that ends the assistant, which ends every assistant
|
||||
thread run, and it configures the assistant through `assistants` (Assistant
|
||||
settings, below) and names instances through `set_name`. It has no credentials and the same read-only tools. It is the only shipped role that holds `role.launch`, so
|
||||
it is the only role `launch.by` can name. The PM holds no `role.launch`
|
||||
(REQ-LAUNCH-1); it asks the chief of staff for a session.
|
||||
|
||||
@@ -310,13 +313,46 @@ torn evidence refuses organization writes. Bus startup, Settings reads and
|
||||
launches keep using the whole validated file on disk, without consulting receipts. A stale writer lock also refuses: an operator must
|
||||
confirm that its old writer is gone before clearing it; the host does not
|
||||
race another recovering process by deleting locks automatically. Settings reads never repair evidence themselves.
|
||||
`launch-settings.mjs` restricts human edits to limits, model table and instance
|
||||
model selections and validates effective selections before publication. `plugin-settings.mjs`
|
||||
`launch-settings.mjs` restricts human edits to limits, model table, instance
|
||||
model selections and assistant settings, and validates effective selections before publication. `plugin-settings.mjs`
|
||||
is the only writer of `plugins`: the host calls it, and no broker verb, `mosaic`
|
||||
command or Settings form reaches it. A refused `plugins` write throws
|
||||
`invalid-plugins` or `invalid-request` before the writer runs and logs nothing. Migration
|
||||
uses none of this: it only prints a converted document (decision 145).
|
||||
|
||||
### Assistant settings (row 155, #1649)
|
||||
|
||||
Jason's ruling Q169 lets the human and the chief of staff configure the
|
||||
assistant. The update key is `assistants`, next to `limits`, `models` and
|
||||
`instances`:
|
||||
|
||||
```json
|
||||
{ "revision": "<64 hex>", "assistants": { "assistant": { "vars": { "model": "opus" }, "tracker": null } } }
|
||||
```
|
||||
|
||||
- The key maps an instance to any of `vars`, `tracker` and `definition`. A
|
||||
given field replaces that field of the entry, `tracker: null` removes the
|
||||
tracker, and a field left out stays. An empty entry is `invalid-request`.
|
||||
- The instance must exist (`invalid-request`) and its definition must be
|
||||
`THREAD_DEFINITION` from `vocabulary.mjs`, or the write refuses
|
||||
`organization-not-assistant`.
|
||||
- A given `definition` must be `THREAD_DEFINITION` too, or
|
||||
`organization-definition-refused`. Today that makes it a no-op, so a change
|
||||
can't strip the protection `END_PROTECTED` gives the assistant.
|
||||
- `holder` and `credentials` aren't accepted; they stay hand edits.
|
||||
- After applying every key, `set` refuses `organization-definition-refused` if
|
||||
any instance's definition differs from the file's, so no key gives an
|
||||
instance an `END_PROTECTED` definition after a decision about it was resolved.
|
||||
- The whole next document still goes through `validateBusinessDocument` and the
|
||||
model check, the same as a hand edit.
|
||||
|
||||
`read` returns `assistants` as well: each assistant instance's `definition`,
|
||||
`vars` (`{}` when absent) and `tracker` (`null` when absent). The human sends
|
||||
the patch with `mosaic limits set --file` or the Console's Assistant section;
|
||||
the chief of staff sends it with `change_organization`. Until row 134 gives
|
||||
the chief of staff a revision read, the revision comes from the human.
|
||||
`changes.jsonl` logs the actor: the human's id, or `chief-of-staff.<instance>`.
|
||||
|
||||
For inherited selections, print-only migration reads the existing system config
|
||||
through its normal validator and requires an exact provider/model match. Missing
|
||||
or invalid config refuses; it never changes the system config. Explicit role
|
||||
|
||||
@@ -4,10 +4,15 @@ import { validateBusinessDocument, businessFilePath } from "./business.mjs";
|
||||
import { resolveInstance } from "./resolve.mjs";
|
||||
import { usageCounts } from "./launch.mjs";
|
||||
import { requireObject, rejectUnknownKeys } from "./util.mjs";
|
||||
import { THREAD_DEFINITION } from "./vocabulary.mjs";
|
||||
|
||||
// Row 155 (#1649, Jason's ruling Q169): the fields of an assistant instance's entry the human and
|
||||
// the chief of staff may change. holder and credentials stay hand edits.
|
||||
const ASSISTANT_FIELDS = ["vars", "tracker", "definition"];
|
||||
|
||||
// Every key a limits update takes. The broker's and the harness tool's
|
||||
// ORGANIZATION_KEYS must match it (packages/harness/tests/tools.test.mjs).
|
||||
export const SETTINGS_KEYS = Object.freeze(["revision", "limits", "models", "instances", "reset"]);
|
||||
export const SETTINGS_KEYS = Object.freeze(["revision", "limits", "models", "instances", "reset", "assistants"]);
|
||||
|
||||
const own = (value, key) => value !== null && typeof value === "object" && !Array.isArray(value) && Object.hasOwn(value, key);
|
||||
const plain = value => value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
@@ -84,10 +89,16 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
|
||||
...entry, modelKey: key,
|
||||
}];
|
||||
}));
|
||||
// Each assistant instance's entry fields as the file has them, so a change replaces what it shows.
|
||||
const assistants = Object.fromEntries(Object.entries(document.roles)
|
||||
.filter(([, entry]) => entry.definition === THREAD_DEFINITION)
|
||||
.map(([instance, entry]) => [instance, {
|
||||
definition: entry.definition, vars: entry.vars ?? {}, tracker: entry.tracker ?? null,
|
||||
}]));
|
||||
// model is the external id; modelKey is the table selection.
|
||||
return {
|
||||
business: id, revision, models: business.launch.models,
|
||||
limits: business.launch.limits, instances, usage: usageCounts(sessions(), id),
|
||||
limits: business.launch.limits, instances, assistants, usage: usageCounts(sessions(), id),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,7 +108,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
|
||||
if (typeof args.revision !== "string" || !/^[a-f0-9]{64}$/.test(args.revision)) {
|
||||
throw new OrganizationError("invalid-request");
|
||||
}
|
||||
if (!["limits", "models", "instances", "reset"].some(key => Object.hasOwn(args, key))) {
|
||||
if (!SETTINGS_KEYS.some(key => key !== "revision" && Object.hasOwn(args, key))) {
|
||||
throw new OrganizationError("invalid-request");
|
||||
}
|
||||
const old = writer.read(id);
|
||||
@@ -119,6 +130,31 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
|
||||
next.roles[instance].vars = { ...next.roles[instance].vars, model: key };
|
||||
}
|
||||
}
|
||||
// Each field given replaces that field; tracker null removes it. Only an assistant instance is a
|
||||
// target, and its definition stays one the broker treats as the assistant, so the change can't
|
||||
// strip END_PROTECTED's protection.
|
||||
if (Object.hasOwn(args, "assistants")) {
|
||||
requireObject(args.assistants, "assistant settings");
|
||||
for (const [instance, entry] of Object.entries(args.assistants)) {
|
||||
if (!Object.hasOwn(next.roles, instance)) throw new OrganizationError("invalid-request");
|
||||
if (next.roles[instance].definition !== THREAD_DEFINITION) throw new OrganizationError("organization-not-assistant");
|
||||
requireObject(entry, `assistant settings ${instance}`);
|
||||
rejectUnknownKeys(entry, ASSISTANT_FIELDS, `assistant settings ${instance}`);
|
||||
if (Object.keys(entry).length === 0) throw new OrganizationError("invalid-request");
|
||||
if (Object.hasOwn(entry, "definition")) {
|
||||
if (entry.definition !== THREAD_DEFINITION) throw new OrganizationError("organization-definition-refused");
|
||||
next.roles[instance].definition = entry.definition;
|
||||
}
|
||||
if (Object.hasOwn(entry, "vars")) next.roles[instance].vars = requireObject(entry.vars, `assistant settings ${instance}.vars`);
|
||||
if (entry.tracker === null) delete next.roles[instance].tracker;
|
||||
else if (Object.hasOwn(entry, "tracker")) next.roles[instance].tracker = entry.tracker;
|
||||
}
|
||||
}
|
||||
// No key may change another instance's definition, so none gives an instance an END_PROTECTED one
|
||||
// after a decision about it was resolved (dewey-0546).
|
||||
for (const [instance, entry] of Object.entries(old.document.roles)) {
|
||||
if (next.roles[instance]?.definition !== entry.definition) throw new OrganizationError("organization-definition-refused");
|
||||
}
|
||||
// Check effective inherited selections before publishing the complete document.
|
||||
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir });
|
||||
for (const instance of Object.keys(proposed.roles)) {
|
||||
|
||||
@@ -125,3 +125,11 @@ export const VIKUNJA_GRANTABLE = Object.freeze({
|
||||
});
|
||||
|
||||
export const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
|
||||
// Row 121 (#1615): an instance of this definition talks with the user in threads (REQ-THREAD-1).
|
||||
// Row 155 (#1649) moves it here from the broker, so the organization writer reads the same name.
|
||||
export const THREAD_DEFINITION = "assistant";
|
||||
// Row 139 (#1633), Jason's ruling Q169 (PRD 1.6): definitions only the chief of staff, or a decision
|
||||
// the human resolved through the CLI, may end or revoke. Row 155 moves the list here, and no
|
||||
// organization write may give an instance one of them.
|
||||
export const END_PROTECTED = Object.freeze([THREAD_DEFINITION]);
|
||||
|
||||
@@ -4,7 +4,7 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { ACTIONS, GATED_ONLY, ROUTED } from "../src/vocabulary.mjs";
|
||||
import { ACTIONS, GATED_ONLY, ROUTED, THREAD_DEFINITION, END_PROTECTED } from "../src/vocabulary.mjs";
|
||||
import { validateRoleDocument } from "../src/index.mjs";
|
||||
import { tmp } from "./helpers.mjs";
|
||||
|
||||
@@ -25,6 +25,14 @@ test("REQ-WAIT-1: GATED_ONLY is exactly the hard waits; the new actions are in t
|
||||
for (const action of LEFT) assert.ok(ACTIONS.includes(action) && !GATED_ONLY.includes(action), action);
|
||||
});
|
||||
|
||||
// Row 155 (#1649): the broker and the organization writer read both from here, so the assistant
|
||||
// can't lose its end protection by an edit to one list.
|
||||
test("REQ-ROLE-2: END_PROTECTED includes the assistant's thread definition", () => {
|
||||
assert.equal(THREAD_DEFINITION, "assistant");
|
||||
assert.ok(END_PROTECTED.includes(THREAD_DEFINITION));
|
||||
assert.ok(Object.isFrozen(END_PROTECTED));
|
||||
});
|
||||
|
||||
test("REQ-WAIT-3: every action is routed or a hard wait, never both, and the routed list names only actions", () => {
|
||||
for (const action of ROUTED) assert.ok(ACTIONS.includes(action), action);
|
||||
for (const action of ACTIONS) assert.notEqual(ROUTED.includes(action), GATED_ONLY.includes(action), action);
|
||||
|
||||
@@ -3,7 +3,7 @@ import test,{after} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync,statSync,writeFileSync,readdirSync,mkdirSync,symlinkSync,rmSync} from 'node:fs';
|
||||
import {join} from 'node:path';
|
||||
import {businessDoc,rolesCopy,tmp as makeRoot,writeJson} from './helpers.mjs';
|
||||
import {businessDoc,rolesCopy,tmp as makeRoot,writeJson,tokenFile,FAR_DATE} from './helpers.mjs';
|
||||
import {validateBusinessDocument} from '../src/business.mjs';
|
||||
const roots=[];after(()=>roots.forEach(root=>rmSync(root,{recursive:true,force:true})));
|
||||
function fixture(){const root=makeRoot();roots.push(root);const rolesDir=rolesCopy(root),doc=businessDoc(root,'acme');for(const r of Object.values(doc.roles))r.vars={...r.vars,model:'sonnet'};doc.roles.pm.vars.model='opus';doc.launch={by:'chief',instances:['coder','reviewer'],models:{opus:{provider:'anthropic',model:'claude-opus-5-5'},sonnet:{provider:'anthropic',model:'claude-sonnet-5-5'}},limits:{models:{anthropic:{'claude-opus-5-5':4,'claude-sonnet-5-5':4}}}};const file=join(root,'businesses/acme.json');writeJson(file,doc);return {root,rolesDir,doc,file};}
|
||||
@@ -55,3 +55,35 @@ test('REQ-ROLE-3 refuses live writer locks, symlink targets and uncertain recove
|
||||
const crash=organizationWriter({dir:f.root,rolesDir:f.rolesDir,barrier:()=>{throw Error('fixture interruption');}});assert.throws(()=>crash.write('acme',next,{revision,actor:'jason'}));const external=structuredClone(f.doc);external.launch.limits.total=12;writeJson(f.file,external);const bytes=readFileSync(f.file,'utf8');assert.throws(()=>w.recover('acme'),/match prepared/);assert.equal(readFileSync(f.file,'utf8'),bytes);
|
||||
const g=fixture(),gw=organizationWriter({dir:g.root,rolesDir:g.rolesDir}),gr=gw.read('acme').revision,lock=join(g.root,'businesses/.history/acme/write.lock');mkdirSync(lock,{recursive:true,mode:0o700});writeJson(join(lock,'owner.json'),{pid:process.pid});assert.throws(()=>gw.write('acme',g.doc,{revision:gr,actor:'jason'}),/busy/);rmSync(lock,{recursive:true});const target=join(g.root,'target.json');writeJson(target,g.doc);rmSync(g.file);symlinkSync(target,g.file);assert.throws(()=>gw.write('acme',g.doc,{revision:gr,actor:'jason'}));assert.equal(JSON.parse(readFileSync(target,'utf8')).id,'acme');
|
||||
});
|
||||
// Row 155 (#1649, Q169): the human and the chief of staff change an assistant instance's vars, tracker
|
||||
// and definition through the same writer; holder and credentials stay hand edits.
|
||||
function withAssistant(f,entry={}){const doc=structuredClone(f.doc);doc.roles.assistant={definition:'assistant',holder:'mos',vars:{model:'sonnet'},...entry};writeJson(f.file,doc);return doc;}
|
||||
async function assistantSettings(f){const {launchSettings}=await import('../src/launch-settings.mjs');return launchSettings({dir:f.root,rolesDir:f.rolesDir,system:{environment:'development',dataRoot:join(f.root,'data'),'execution.backend':'docker','execution.provider':'anthropic','execution.model':'sonnet','execution.adapter':'pi'}});}
|
||||
const code=c=>e=>e.code===c;
|
||||
test('Q169 assistants: vars and definition change and log the actor; tracker null leaves no tracker',async()=>{
|
||||
const f=fixture();withAssistant(f);const settings=await assistantSettings(f),view=settings.read('acme');
|
||||
assert.deepEqual(view.assistants,{assistant:{definition:'assistant',vars:{model:'sonnet'},tracker:null}});
|
||||
const done=settings.set('acme',{revision:view.revision,assistants:{assistant:{vars:{model:'opus',thinking:'high'},definition:'assistant',tracker:null}}},'jason');
|
||||
assert.deepEqual(done.assistants.assistant,{definition:'assistant',vars:{model:'opus',thinking:'high'},tracker:null});assert.equal(done.instances.assistant.modelKey,'opus');
|
||||
const e=JSON.parse(readFileSync(settings.writer.logFile('acme'),'utf8').trim().split('\n').at(-1));assert.equal(e.actor,'jason');assert.ok(e.changes.some(c=>c.path==='roles.assistant.vars.thinking'&&c.new==='high'));
|
||||
const chief=settings.set('acme',{revision:done.revision,assistants:{assistant:{vars:{}}}},'chief-of-staff.chief');assert.deepEqual(chief.assistants.assistant.vars,{});assert.equal(chief.instances.assistant.modelKey,'sonnet');
|
||||
const last=JSON.parse(readFileSync(settings.writer.logFile('acme'),'utf8').trim().split('\n').at(-1));assert.equal(last.actor,'chief-of-staff.chief');
|
||||
});
|
||||
test('Q169 assistants: refuses other instances, other definitions, holder and credentials, empty entries, a tracker the definition has no credential for and a stale revision',async()=>{
|
||||
const f=fixture();withAssistant(f);const settings=await assistantSettings(f),{revision}=settings.read('acme'),bytes=readFileSync(f.file,'utf8'),set=assistants=>settings.set('acme',{revision,assistants},'jason');
|
||||
for(const instance of ['coder','chief'])assert.throws(()=>set({[instance]:{definition:'assistant'}}),code('organization-not-assistant'),instance);
|
||||
for(const definition of ['coder','chief-of-staff',null])assert.throws(()=>set({assistant:{definition}}),code('organization-definition-refused'),String(definition));
|
||||
assert.throws(()=>set({ghost:{vars:{}}}),code('invalid-request'));assert.throws(()=>set({assistant:{}}),code('invalid-request'));
|
||||
assert.throws(()=>set({assistant:{holder:'rex'}}),/unsupported assistant settings assistant key: "holder"/);assert.throws(()=>set({assistant:{credentials:{}}}),/unsupported assistant settings assistant key: "credentials"/);
|
||||
assert.throws(()=>set({assistant:{vars:{harness:'cursor'}}}),/harness/);assert.throws(()=>set({assistant:{vars:{model:'ghost'}}}),/unknown model/);
|
||||
assert.throws(()=>set({assistant:{tracker:{bot:'bot-acme-assistant',botId:11}}}),/has "tracker" but assistant uses no vikunja credential/);
|
||||
const moved=settings.set('acme',{revision,assistants:{assistant:{vars:{model:'opus'}}}},'jason');assert.notEqual(moved.revision,revision);const after=readFileSync(f.file,'utf8');
|
||||
assert.throws(()=>set({assistant:{vars:{model:'sonnet'}}}),code('revision-conflict'));assert.equal(readFileSync(f.file,'utf8'),after);assert.notEqual(after,bytes);
|
||||
});
|
||||
test('Q169 assistants: a definition with a vikunja credential takes a tracker change and refuses its removal or a used bot',async()=>{
|
||||
const f=fixture(),role=JSON.parse(readFileSync(join(f.rolesDir,'assistant.json'),'utf8'));role.credentials=[{service:'vikunja',scopes:{tasks:['read_one']}}];writeJson(join(f.rolesDir,'assistant.json'),role);
|
||||
withAssistant(f,{tracker:{bot:'bot-acme-assistant',botId:11},credentials:{vikunja:{file:tokenFile(join(f.root,'secrets'),'assistant-vikunja.token'),expires:FAR_DATE}}});const settings=await assistantSettings(f),view=settings.read('acme'),set=assistants=>settings.set('acme',{revision:view.revision,assistants},'jason');
|
||||
assert.deepEqual(view.assistants.assistant.tracker,{bot:'bot-acme-assistant',botId:11});
|
||||
assert.throws(()=>set({assistant:{tracker:null}}),/needs "tracker"/);assert.throws(()=>set({assistant:{tracker:{bot:'bot-acme-pm',botId:12}}}),/used by another instance/);assert.throws(()=>set({assistant:{tracker:{bot:'bot-acme-x',botId:12,holder:'rex'}}}),/unsupported/);
|
||||
const done=set({assistant:{tracker:{bot:'bot-acme-helper',botId:12}}});assert.deepEqual(done.assistants.assistant.tracker,{bot:'bot-acme-helper',botId:12});assert.equal(JSON.parse(readFileSync(f.file,'utf8')).roles.assistant.credentials.vikunja.file,join(f.root,'secrets','assistant-vikunja.token'));
|
||||
});
|
||||
|
||||
@@ -795,7 +795,13 @@ The WebUI (S5).
|
||||
selection, limits and running counts. `mosaic limits set --business ID
|
||||
--file PATCH` submits JSON with the read's `revision` and any of `limits`,
|
||||
`models`, `instances`. `limits` and `models` replace those entire objects;
|
||||
`instances` maps role instance names to model-table keys. Both commands use
|
||||
`instances` maps role instance names to model-table keys. `assistants` maps
|
||||
an assistant instance to any of its `vars`, `tracker` and `definition`; each
|
||||
given field replaces that field, and `tracker: null` removes the tracker.
|
||||
The definition stays an assistant one, and `holder` and `credentials` stay
|
||||
hand edits (row 155, Q169). The chief of staff sends the same patch with
|
||||
`change_organization`; until row 134 gives it a read, the revision comes from
|
||||
the human. Both commands use
|
||||
the existing human proof and broker; neither is available to a managed role
|
||||
or tester. The Console Settings form uses the same write path and requires
|
||||
signed-in send mode, named Host, exact Origin and same-origin Fetch Metadata.
|
||||
|
||||
@@ -6,8 +6,10 @@ import { systemVars } from "../../business/src/index.mjs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
import { migrateLaunch } from "../../business/src/migrate-launch.mjs";
|
||||
|
||||
// Row 155 (#1649, Q169): a PATCH may carry `assistants`, the assistant's vars, tracker and definition.
|
||||
export const LIMITS_USAGE = "mosaic limits [--business ID] [--json] | set --file PATCH [--business ID] | " +
|
||||
"reset PATH... [--business ID] | migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]";
|
||||
"reset PATH... [--business ID] | migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]; " +
|
||||
"PATCH keys: revision, limits, models, instances, reset, assistants";
|
||||
|
||||
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): one line per token limit, its path in the words
|
||||
// `mosaic limits reset` takes. A limit no switched-on check counts reads "not enforced".
|
||||
|
||||
@@ -27,6 +27,15 @@ test('limits view/set use the human bus transport and pass only the explicit pat
|
||||
await assert.rejects(main(['limits','--business','acme'],io({CODEX_THREAD_ID:'fixture-agent'}),deps),/agent/i);assert.equal(calls.length,2);
|
||||
});
|
||||
|
||||
// Row 155 (#1649, Q169): the human configures the assistant with the same command.
|
||||
test('limits set passes an assistants patch through unchanged, and the usage names the key',async t=>{
|
||||
const root=tmp(t),patch={revision:'a'.repeat(64),assistants:{assistant:{vars:{model:'opus',thinking:'high'},tracker:null,definition:'assistant'}}},file=writeJson(join(root,'patch.json'),patch),calls=[];
|
||||
const deps={system:{dataRoot:root},transport:business=>(verb,args)=>{calls.push({business,verb,args});return {revision:'b'.repeat(64)};}};
|
||||
await main(['limits','set','--business','acme','--file',file],io(),deps);
|
||||
assert.deepEqual(calls,[{business:'acme',verb:'limits.set',args:patch}]);
|
||||
await assert.rejects(main(['limits','set','--business','acme'],io(),deps),/PATCH keys: revision, limits, models, instances, reset, assistants/);assert.equal(calls.length,1);
|
||||
});
|
||||
|
||||
test('post-119 example and Jason fixture migrate with checked inherited models; wrong or absent config refuses',async t=>{
|
||||
const {execFileSync}=await import('node:child_process');const {validateBusinessDocument}=await import('../../business/src/business.mjs');
|
||||
const root=tmp(t),f=fixture(root,'acme'),doc=JSON.parse(execFileSync('git',['show','94e41ae29:packages/business/examples/mosaic-stack.example.json'],{encoding:'utf8'}));
|
||||
|
||||
@@ -95,23 +95,29 @@ 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`. 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:
|
||||
and its `instance.name` grant (row 141, #1635) has `set_name` since row 155
|
||||
(#1649), so its manifest lists all three in `typed` and its `untyped` list is
|
||||
empty. The 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
|
||||
the caller's own run with `role-end-refused`. Since row 139 (Jason's
|
||||
ruling Q169) the chief of staff's end of the assistant gets past that
|
||||
check and refuses `not-held`, because the assistant's runs hold thread
|
||||
claims; row 155 (#1649) makes it stop them and updates the tool's text. A
|
||||
check, and since row 155 (#1649) it revokes every assistant thread run;
|
||||
the tool's text and the prompt line say so. A
|
||||
`role.end` holder's prompt names the tool in place of the "only the human
|
||||
stops a run" line; every other role keeps that line.
|
||||
- `change_organization` (organization.change holders) sends
|
||||
`organization.set` with `revision` and the keys that verb takes, `limits`,
|
||||
`models` and `instances`. `ORGANIZATION_KEYS` in `tools.mjs` is the
|
||||
broker's list; a test fails when the two differ.
|
||||
`models`, `instances` and `assistants` (row 155: an assistant instance's
|
||||
`vars`, `tracker` or `null`, and `definition`, whose schema enum is
|
||||
`THREAD_DEFINITION`). `ORGANIZATION_KEYS` in `tools.mjs` is the
|
||||
broker's list; a test fails when the two differ. No tool reads the
|
||||
revision until row 134, so the description tells the chief of staff to use
|
||||
the one the human gave it.
|
||||
- `set_name` (instance.name holders) sends `name.set` with `instance` and
|
||||
`name`. The broker checks the grant, the name pattern and the refusals
|
||||
`name-is-instance`, `name-is-role` and `name-taken`.
|
||||
|
||||
The review loop (row 93, #1587) adds four tools:
|
||||
|
||||
|
||||
@@ -108,8 +108,9 @@ function prompt({ contract, resolved, session, tools, untyped }) {
|
||||
];
|
||||
const named = new Set(tools.map((t) => t.name));
|
||||
// Row 90 (#1584): PM fault 10 (#228 c91). Row 132 (#1628): a role holding role.end has end_run.
|
||||
// Row 155 (#1649, Q169): the chief of staff's end_run ends the assistant too.
|
||||
lines.push(named.has("end_run")
|
||||
? "- You end another role's run with end_run, naming the role instance and a reason of 1 to 500 characters; the reason is required and goes into the log. You can't end your own run or the assistant's. For anything the broker refuses, raise a role.revoke decision with blocking false and the role instance as target, naming the run and saying why; the human decides, and `mosaic stop` is theirs."
|
||||
? "- You end another role's run with end_run, naming the role instance and a reason of 1 to 500 characters; the reason is required and goes into the log. Ending the assistant ends each of its thread runs. You can't end your own run. For anything the broker refuses, raise a role.revoke decision with blocking false and the role instance as target, naming the run and saying why; the human decides, and `mosaic stop` is theirs."
|
||||
: "- Only the human stops a run, with `mosaic stop`. If you think a run should stop, raise a role.revoke decision with blocking false and the role instance as target, naming the run and saying why.");
|
||||
// Row 93 (#1587): the review loop, for the roles that have its tools.
|
||||
if (named.has("push_working"))
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
// name against this list and forwards the call with its own capability.
|
||||
|
||||
import { connect } from "node:net";
|
||||
import { ACTIONS } from "../../business/src/vocabulary.mjs";
|
||||
import { ACTIONS, THREAD_DEFINITION } from "../../business/src/vocabulary.mjs";
|
||||
|
||||
const str = (description, extra = {}) => ({ type: "string", description, ...extra });
|
||||
const obj = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false });
|
||||
@@ -25,7 +25,7 @@ const TASK_PAGE_MAX = 200;
|
||||
// The keys the broker's `organization.set` accepts (ORGANIZATION_KEYS in packages/bus/src/broker.mjs;
|
||||
// a test holds both equal to SETTINGS_KEYS in packages/business/src/launch-settings.mjs). Kept here
|
||||
// so the session's tool server doesn't load the broker.
|
||||
export const ORGANIZATION_KEYS = Object.freeze(["revision", "limits", "models", "instances", "reset"]);
|
||||
export const ORGANIZATION_KEYS = Object.freeze(["revision", "limits", "models", "instances", "reset", "assistants"]);
|
||||
// The longest web_search query (SEARCH_MAX_QUERY_CHARS in packages/discord/src/web.mjs, the one
|
||||
// limit the broker's web service and the search modules apply; a test holds them equal). Kept here
|
||||
// so the session's tool server doesn't load the Discord package.
|
||||
@@ -162,22 +162,37 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
|
||||
}, ["task_ref", "request", "verdict", "body"]));
|
||||
}
|
||||
// Row 132 (#1628, REQ-ROLE-2, REQ-COS-1, REQ-LIMIT-1): the chief of staff's two grants from row 123.
|
||||
// end_run sends `reason` and never a decision; the broker refuses the caller's own run and the assistant's.
|
||||
// end_run sends `reason` and never a decision; the broker refuses the caller's own run. Row 155
|
||||
// (#1649, Q169): the chief of staff ends the assistant too, every one of its thread runs.
|
||||
if (held.has("role.end")) {
|
||||
add("end_run", "role.revoke", "role.end", "End another role's run. A reason is required, 1 to 500 characters, and goes into the log. You can't end your own run or the assistant's; the broker refuses with role-end-refused.", obj({
|
||||
add("end_run", "role.revoke", "role.end", "End another role's run. A reason is required, 1 to 500 characters, and goes into the log. Ending the assistant ends every run it has, one per thread; it starts again when the human writes to it. You can't end your own run; the broker refuses with role-end-refused.", obj({
|
||||
role: str("Role instance whose run to end"),
|
||||
reason: str("Why the run ends, 1 to 500 characters", { minLength: 1, maxLength: 500 }),
|
||||
}, ["role", "reason"]));
|
||||
}
|
||||
if (held.has("organization.change")) {
|
||||
add("change_organization", "organization.set", "organization.change", "Change the organization file: the launch limits, the model table, and the model each instance runs, or reset token limits so they count from now. revision is the file's current revision; the broker refuses a stale one with revision-conflict. Send only the keys you change; each replaces what the file holds for it.", obj({
|
||||
// Row 155 (#1649, Q169): assistants configures the assistant. Until row 134 gives the chief a
|
||||
// revision read, the revision is one the human gave it.
|
||||
add("change_organization", "organization.set", "organization.change", "Change the organization file: the launch limits, the model table, the model each instance runs, and the assistant's vars, tracker and definition, or reset token limits so they count from now. revision is the file's current revision, as the human gave it to you; the broker refuses a stale one with revision-conflict. Send only the keys you change; each replaces what the file holds for it.", obj({
|
||||
revision: str("The organization file's current revision", { pattern: "^[a-f0-9]{64}$" }),
|
||||
limits: { type: "object", description: "The launch limits, replacing the file's launch limits" },
|
||||
models: { type: "object", description: "The model table, replacing the file's model table" },
|
||||
instances: { type: "object", description: "Role instance to model key, for the instances that change", additionalProperties: { type: "string" } },
|
||||
reset: { type: "array", minItems: 1, description: "Token limits to restart counting from now, each a path such as [\"tokens\",\"providers\",\"anthropic\"]", items: { type: "array", minItems: 1, items: { type: "string" } } },
|
||||
assistants: { type: "object", description: "Assistant instance to the fields that change: vars (replacing its vars), tracker (replacing its Vikunja bot, or null to remove it) and definition (only assistant). holder and credentials can't change here", additionalProperties: obj({
|
||||
vars: { type: "object", description: "The instance's vars, replacing them" },
|
||||
tracker: { type: ["object", "null"], description: "The instance's tracker bot, or null to remove it" },
|
||||
definition: str("The instance's role definition", { enum: [THREAD_DEFINITION] }),
|
||||
}) },
|
||||
}, ["revision"]));
|
||||
}
|
||||
// Row 155 (#1649, REQ-MSG-2): row 141's instance.name grant, which the chief of staff holds within-role.
|
||||
if (held.has("instance.name")) {
|
||||
add("set_name", "name.set", "instance.name", "Give a role instance its seat name, the name messages may use as to. A name is unique in the business, and it can't be an instance key, a role definition, human or tester.", obj({
|
||||
instance: str("Role instance to name"),
|
||||
name: str("The seat name"),
|
||||
}, ["instance", "name"]));
|
||||
}
|
||||
// REQ-HARN-2: the broker-served web tools, only for a role whose file grants "web". The broker
|
||||
// checks the grant again on every call, trails it, and labels the reply untrusted.
|
||||
if (webTyped && resolved.limits.tools.includes("web")) {
|
||||
|
||||
@@ -77,23 +77,23 @@ test("a chief-of-staff bundle lists launch among the bus tools and the manifest'
|
||||
const b = build(t, r);
|
||||
const prompt = readFileSync(b.files.prompt, "utf8");
|
||||
assert.match(prompt, /Role instance: chief \(definition chief-of-staff\)\. Run: run-1\./);
|
||||
assert.match(prompt, /Bus tools: send_message, launch, end_run, change_organization, read_spec, write_record, raise_decision, /);
|
||||
assert.match(prompt, /Bus tools: send_message, launch, end_run, change_organization, set_name, read_spec, write_record, raise_decision, /);
|
||||
assert.ok(b.manifest.typed.includes("launch"));
|
||||
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.
|
||||
// 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) => {
|
||||
// Row 141 (#1635, REQ-MSG-2) granted instance.name, and row 155 (#1649) gives it set_name, so nothing
|
||||
// the chief holds is untyped and the prompt has no untyped line.
|
||||
test("a chief-of-staff bundle lists end_run, change_organization and set_name as typed and nothing 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, ["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.match(readFileSync(b.files.prompt, "utf8"), /Actions you hold that have no bus tool yet: instance\.name\. /);
|
||||
assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "change_organization", "set_name", "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.match(readFileSync(b.files.prompt, "utf8"), /Bus tools: send_message, launch, end_run, change_organization, set_name, read_spec, write_record, raise_decision, /);
|
||||
assert.doesNotMatch(readFileSync(b.files.prompt, "utf8"), /Actions you hold that have no bus tool yet/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -105,7 +105,9 @@ test("the stop line names end_run for a role.end holder, and every other role ke
|
||||
assert.equal(chief.length, 1);
|
||||
assert.notEqual(chief[0], human);
|
||||
assert.match(chief[0], /^- You end another role's run with end_run, naming the role instance and a reason of 1 to 500 characters; the reason is required/);
|
||||
assert.match(chief[0], /You can't end your own run or the assistant's\./);
|
||||
// Row 155 (#1649, Q169): the chief of staff ends the assistant's thread runs.
|
||||
assert.match(chief[0], /Ending the assistant ends each of its thread runs\. You can't end your own run\./);
|
||||
assert.doesNotMatch(chief[0], /assistant's/);
|
||||
assert.match(chief[0], /For anything the broker refuses, raise a role\.revoke decision with blocking false/);
|
||||
assert.doesNotMatch(chief[0], /Only the human stops a run/);
|
||||
for (const instance of ["pm", "coder"]) assert.deepEqual(line(build(t, resolvedFor(t, instance, (d) => (d.roles[instance].vars = { ...d.roles[instance].vars, harness: "pi", model: "claude-opus-5-5" })))), [human], instance);
|
||||
|
||||
@@ -29,8 +29,9 @@ const businesses = {
|
||||
pm: { authority: { withinRole: ["message.send", "role.launch"], crossRole: [] } },
|
||||
coder: { authority: { withinRole: ["message.send"], crossRole: [] } },
|
||||
// Row 132 (#1628): the chief of staff's grants and the assistant. Since row 139 (Q169) the
|
||||
// chief may end the assistant, and the end refuses not-held until row 155 (#1649).
|
||||
chief: { definition: "chief-of-staff", authority: { withinRole: ["message.send", "role.end", "organization.change"], crossRole: [] } },
|
||||
// chief may end the assistant; with no thread run holding a claim the end refuses not-held.
|
||||
// Row 155 (#1649) adds row 141's instance.name for set_name.
|
||||
chief: { definition: "chief-of-staff", authority: { withinRole: ["message.send", "role.end", "organization.change", "instance.name"], crossRole: [] } },
|
||||
assistant: { definition: "assistant", authority: { withinRole: ["message.send"], crossRole: [] } },
|
||||
},
|
||||
},
|
||||
@@ -415,6 +416,26 @@ test("end_run and change_organization reach the broker with the tool's arguments
|
||||
]);
|
||||
});
|
||||
|
||||
// Row 155 (#1649, REQ-MSG-2): set_name reaches name.set with the session's arguments, and the broker's
|
||||
// refusals come back with their codes.
|
||||
test("set_name reaches the broker's name.set with the tool's arguments, and refusals keep their codes", async (t) => {
|
||||
const tools = typedTools({ resolved: resolvedFor(t, "chief") }).filter((x) => x.name === "set_name");
|
||||
assert.deepEqual(tools.map((x) => x.name), ["set_name"]);
|
||||
const ctx = await setup(t, { as: "chief", tools, policy: { typed: tools.map((x) => x.name) } });
|
||||
ctx.inbox = [];
|
||||
const r = startRunner(ctx.runDir, ctx.cap);
|
||||
t.after(() => r.child.kill("SIGKILL"));
|
||||
const send = (args) => ctx.call(ctx.pm, "message.send", { to: "chief", body: `TOOL set_name ${JSON.stringify(args)}` });
|
||||
send({ instance: "coder", name: "rex" });
|
||||
send({ instance: "coder", name: "pm" });
|
||||
await until(() => replies(ctx)() && ctx.inbox.length >= 2);
|
||||
assert.deepEqual(ctx.inbox.map((m) => m.body), [
|
||||
'{"instance":"coder","name":"rex"}',
|
||||
"refused: name-is-instance",
|
||||
]);
|
||||
assert.deepEqual({ ...ctx.store.get("SELECT instance, name, by FROM instance_names ORDER BY seq DESC LIMIT 1") }, { instance: "coder", name: "rex", by: "chief-run" });
|
||||
});
|
||||
|
||||
test("a RESULT gets no automatic reply; failed turns reply with the reason", async (t) => {
|
||||
const ctx = await setup(t, { session: { turnTimeout: 1 } });
|
||||
ctx.inbox = [];
|
||||
|
||||
@@ -37,17 +37,17 @@ test("send_message offers the BLOCK class, beside the classes it already had", (
|
||||
});
|
||||
|
||||
// Row 119 (#1613): the chief of staff holds launch and message.send; row 123 adds role.end and organization.change, which row 132 gives tools.
|
||||
test("the chief of staff gets launch, send_message, end_run and change_organization, and no task verbs", (t) => {
|
||||
test("the chief of staff gets launch, send_message, end_run, change_organization and set_name, and no task verbs", (t) => {
|
||||
const chief = resolvedFor(t, "chief");
|
||||
const tools = typedTools({ resolved: chief, tracker: true });
|
||||
assert.deepEqual(names(tools), ["send_message", "launch", "end_run", "change_organization", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
||||
assert.deepEqual(names(tools), ["send_message", "launch", "end_run", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
||||
const launch = tools.find((x) => x.name === "launch");
|
||||
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. 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 132 (#1628): role.end and organization.change have their tools. Row 141 (#1635) granted
|
||||
// instance.name, and row 155 (#1649) gives it set_name, so no held action is untyped.
|
||||
assert.deepEqual(untypedActions(chief, tools), []);
|
||||
});
|
||||
|
||||
// Row 140 (#1634, REQ-COS-1): the pm asks for staff with request_staff, which spends message.send; the
|
||||
@@ -87,13 +87,39 @@ test("end_run sends role and reason, no decision; change_organization sends revi
|
||||
assert.deepEqual(end.parameters.required, ["role", "reason"]);
|
||||
assert.deepEqual([end.parameters.properties.reason.minLength, end.parameters.properties.reason.maxLength], [1, 500]);
|
||||
assert.equal(end.parameters.additionalProperties, false);
|
||||
assert.match(end.description, /can't end your own run or the assistant's/);
|
||||
// Row 155 (#1649, Q169): the chief of staff ends the assistant; only its own run stays refused.
|
||||
assert.match(end.description, /Ending the assistant ends every run it has, one per thread/);
|
||||
assert.match(end.description, /You can't end your own run;/);
|
||||
assert.doesNotMatch(end.description, /assistant's/);
|
||||
const change = tools.find((x) => x.name === "change_organization");
|
||||
assert.deepEqual([change.verb, change.action], ["organization.set", "organization.change"]);
|
||||
assert.deepEqual(Object.keys(change.parameters.properties), [...ORGANIZATION_KEYS]);
|
||||
assert.deepEqual(change.parameters.required, ["revision"]);
|
||||
assert.equal(change.parameters.additionalProperties, false);
|
||||
assert.equal(change.parameters.properties.revision.pattern, "^[a-f0-9]{64}$");
|
||||
// Row 155 (#1649, Q169): an assistant entry takes vars, tracker and definition, and the definition
|
||||
// only the thread definition; holder and credentials aren't offered.
|
||||
const assistant = change.parameters.properties.assistants.additionalProperties;
|
||||
assert.deepEqual(Object.keys(assistant.properties), ["vars", "tracker", "definition"]);
|
||||
assert.equal(assistant.additionalProperties, false);
|
||||
assert.deepEqual(assistant.properties.definition.enum, ["assistant"]);
|
||||
assert.deepEqual(assistant.properties.tracker.type, ["object", "null"]);
|
||||
assert.match(change.description, /as the human gave it to you/);
|
||||
});
|
||||
|
||||
// Row 155 (#1649, REQ-MSG-2): set_name is row 141's name.set under the instance.name grant.
|
||||
test("set_name sends instance and name to name.set under instance.name, only for a holder of that grant", (t) => {
|
||||
const set = typedTools({ resolved: resolvedFor(t, "chief"), tracker: true }).find((x) => x.name === "set_name");
|
||||
assert.deepEqual([set.verb, set.action], ["name.set", "instance.name"]);
|
||||
assert.deepEqual(Object.keys(set.parameters.properties), ["instance", "name"]);
|
||||
assert.deepEqual(set.parameters.required, ["instance", "name"]);
|
||||
assert.equal(set.parameters.additionalProperties, false);
|
||||
for (const instance of ["pm", "coder", "reviewer"])
|
||||
assert.equal(typedTools({ resolved: resolvedFor(t, instance), arbiter: true, tracker: true }).some((x) => x.name === "set_name"), false, instance);
|
||||
const assistant = resolvedFor(t, "assistant", (d) => (d.roles.assistant = { definition: "assistant", holder: "mos" }));
|
||||
assert.equal(typedTools({ resolved: assistant, tracker: true }).some((x) => x.name === "set_name"), false);
|
||||
const without = resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { ...d.roles.chief.vars, "limits.authority": ["message.send", "role.end"] }));
|
||||
assert.equal(typedTools({ resolved: without }).some((x) => x.name === "set_name"), false);
|
||||
});
|
||||
|
||||
// Row 132 (#1628): the key list is the broker's.
|
||||
|
||||
@@ -284,8 +284,9 @@ note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
|
||||
- Every view reads the four verbs of the Q1 module (`packages/bus/src/views.mjs`,
|
||||
lead decision 56): `inbox`, `tasks`, `agents` and `trail`. The CLI reads the
|
||||
same functions, so both show the same broker data.
|
||||
- Nothing writes but the "Message a role" box, the Settings update and the spec and docs
|
||||
editor's Save, all under `--send` (above and "Spec and docs editor"). A decision shows `mosaic decide <id> <key>` for each choice,
|
||||
- Nothing writes but the "Message a role" box, the Settings update, the Assistant
|
||||
section's three buttons and the spec and docs editor's Save, all under `--send` (above,
|
||||
"Assistant settings" and "Spec and docs editor"). A decision shows `mosaic decide <id> <key>` for each choice,
|
||||
with Copy, which only puts the command on the clipboard. It is answered in a
|
||||
terminal (REQ-DEC-3, Q4). Seen is not set from the browser either. The
|
||||
command has no `--business`, so `mosaic decide` uses the running bus host's
|
||||
@@ -507,6 +508,36 @@ check, each element, the scheme rules, the look-ahead budget) and `docs-browser.
|
||||
(markdown with `<script>`, `onerror` and `javascript:` runs nothing; the draft, each
|
||||
save receipt, the stale-base message, sign-out and read-only mode).
|
||||
|
||||
## Assistant settings (row 155, #1649)
|
||||
|
||||
Jason's ruling Q169 lets the human configure the assistant and its seat name from the
|
||||
Console. Settings shows an Assistant section with one form per assistant instance, the
|
||||
ones the Settings read lists under `assistants`:
|
||||
|
||||
- the seat name, read through `/api/bus/names`, with Save name. If the read fails the
|
||||
section says the current name could not be read, and the field starts empty;
|
||||
- `definition`, `vars` and `tracker` as text, the last two as JSON, with Save assistant
|
||||
settings. It sends only the fields that changed, under the revision the page read, as
|
||||
POST `/api/settings` with `assistants` (the business README's "Assistant settings"). An
|
||||
empty tracker sends `null`, which removes it. `vars` and `tracker` that aren't JSON
|
||||
objects are refused in the page and nothing is sent;
|
||||
- Stop its runs, which ends every run of that assistant in the threads the human made.
|
||||
|
||||
Without `--send` every control is off. A refusal shows the bus's message and keeps the
|
||||
draft; a lost answer says the change may have gone through. Nothing is sent again on its
|
||||
own.
|
||||
|
||||
Routes: POST `/api/name` with exactly the string keys `instance` and `name` calls
|
||||
`name.set`, and POST `/api/assistant-stop` with exactly `instance` calls `assistant.stop`.
|
||||
Both exist only under `--send`, go through the human transport, and have the Settings
|
||||
boundary: the signed-in user's header (401 without it), the exact `https://<public host>`
|
||||
Origin and `Sec-Fetch-Site: same-origin` (403), and a JSON object of at most 4 KiB (400).
|
||||
Any other method is 405. An answer is the bus's result with `at`. A bus refusal is
|
||||
`{error, message, sent:false}` with the code's status, `not-held` and `name-taken` 409 and
|
||||
`human-required` 403. A lost or unusable answer is 503 with `sent:"unknown"`. The log line
|
||||
names the route, the user and the instance, and the outcome: `done` or the code. A Console
|
||||
started inside an agent run gets `human-required` from the broker.
|
||||
|
||||
## Data and boundaries
|
||||
|
||||
GET `/api/board`, `/api/conversations` and `/api/conversation`, and POST
|
||||
@@ -519,7 +550,8 @@ session reader or transport is implemented here. The session reader is
|
||||
|
||||
GET `/api/bus/inbox`, `/api/bus/tasks`, `/api/bus/agents`,
|
||||
`/api/bus/trail?subject=<id>`, `/api/bus/decisions?list=roles|recent`,
|
||||
`/api/bus/credentials`, `/api/bus/replies?id=<message id>` and
|
||||
`/api/bus/credentials`, `/api/bus/names` (row 155, each instance's seat name),
|
||||
`/api/bus/replies?id=<message id>` and
|
||||
`/api/bus/messages?limit=50&after=<message id>` are the bus
|
||||
reads (`src/bus.mjs`). Any other method is 405. The subject and the id must
|
||||
match the broker's identifier rule, and the list must be `roles` or
|
||||
@@ -681,6 +713,15 @@ any response it read carries a seeded value, that a missing or invalid
|
||||
business file shows the module's text, and that the startup log names no
|
||||
config path.
|
||||
|
||||
`assistant.test.mjs` (row 155) checks both seat routes: the answer and the log line,
|
||||
`--send` off, the Host, user, Origin and Fetch Metadata checks, GET, strict keys, the size
|
||||
and content type, each refusal's status, the lost answer and the `names` read.
|
||||
`settings-write.test.mjs` checks that an `assistants` patch reaches the human operation
|
||||
unchanged and that the log names the user. `settings-form.test.mjs` walks the Assistant
|
||||
section in a browser: off without `--send`, Nothing changed, only the changed field sent,
|
||||
a client-side refusal, a refused post that keeps the draft with no retry, Save name and
|
||||
Stop.
|
||||
|
||||
No root CI workflow is configured for this package. These local tests are not a
|
||||
claim of CI, deployment, live-seat delivery or user acceptance.
|
||||
|
||||
|
||||
@@ -92,5 +92,9 @@ const STATUS = {
|
||||
'stale-base': 409, 'project-busy': 409, 'project-dirty': 409,
|
||||
'project-record-unchanged': 400, 'project-record-path': 400, 'project-record-invalid': 400,
|
||||
'project-read-path': 400, 'project-not-declared': 400, 'project-read-missing': 404, 'project-not-configured': 503,
|
||||
// Row 155 (#1649): the assistant's settings, seat names and stop. A name in use or no run to stop
|
||||
// is the user's to resolve by reading again.
|
||||
'organization-not-assistant': 400, 'organization-definition-refused': 400, 'unknown-model': 400,
|
||||
'name-taken': 409, 'name-is-instance': 400, 'name-is-role': 400, 'not-assistant': 400, 'not-held': 409,
|
||||
};
|
||||
export const busStatus = code => STATUS[code] ?? 502;
|
||||
|
||||
@@ -52,17 +52,18 @@ try {
|
||||
// the system config, Business can't resolve authority and Settings has
|
||||
// no notifier. A refusal printed here names no path (redactor).
|
||||
// --send without a bus refuses to start rather than serve a composer that can't send.
|
||||
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, busLine, system = null;
|
||||
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, busLine, system = null;
|
||||
try {
|
||||
system = loadSystem();
|
||||
const { dataRoot } = system;
|
||||
const call = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
|
||||
chat = createChat({ call });
|
||||
bus = views({ call });
|
||||
// Row 155 (#1649): `names` is a webui read only, so it sits beside the shared views.
|
||||
bus = { ...views({ call }), names: () => call('names') };
|
||||
// Row 148: the spec and docs editor reads like the other reads and saves only under --send.
|
||||
projectFiles = args => call('project.files', args);
|
||||
projectFile = args => call('project.file', args);
|
||||
if (sendOn) {send = args => call('message.send', args);settingsWrite=args=>call('limits.set',args);projectSave=args=>call('project.file.save',args);}
|
||||
if (sendOn) {send = args => call('message.send', args);settingsWrite=args=>call('limits.set',args);projectSave=args=>call('project.file.save',args);nameWrite=args=>call('name.set',args);assistantStop=args=>call('assistant.stop',args);}
|
||||
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
|
||||
} catch (err) {
|
||||
const why = redactor({ configDir: configDir() })(err.message);
|
||||
@@ -70,7 +71,7 @@ try {
|
||||
busLine = `Bus: not configured (${why})`;
|
||||
}
|
||||
const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
|
||||
const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, send, settingsWrite, projectFiles, projectFile, projectSave, chat });
|
||||
const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop });
|
||||
const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, ${send ? 'reads and human messages to roles (--send); one line per send below' : 'read-only'}` : '';
|
||||
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
|
||||
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}\nCtrl-C stops this server.`);
|
||||
|
||||
@@ -621,16 +621,67 @@
|
||||
}
|
||||
return `<section class="panel" aria-labelledby="token-usage-title"><h2 id="token-usage-title">Token usage</h2><p>Best reading of session-editable transcripts, not proof. Unknown turns may have used more tokens. Account is unassigned until account tracking is configured.</p><div class="table-wrap"><table id="token-usage-table"><thead><tr><th scope="col">Subject</th><th scope="col">Input</th><th scope="col">Output</th><th scope="col">Cache write</th><th scope="col">Cache read</th><th scope="col">Unknown turns</th></tr></thead><tbody>${rows.join('')}</tbody></table></div></section>`;
|
||||
}
|
||||
// Row 155 (#1649, Q169): each assistant instance's settings, its seat name and a stop. A save
|
||||
// sends only the fields that changed, with the read's revision. Drafts survive polling and
|
||||
// refusals as in the limits form, and nothing retries.
|
||||
let assistantSnapshot=null, assistantDrafts=Object.create(null), nameDrafts=Object.create(null), assistantBusy=false, assistantReceipts=Object.create(null);
|
||||
const asShown=(entry,field)=>field==='definition'?entry.definition??'':entry[field]==null?'':JSON.stringify(entry[field],null,2);
|
||||
function assistantSection(value,names){
|
||||
if(!value||value.refused||!value.assistants||!Object.keys(value.assistants).length)return '';
|
||||
if(!Object.keys(assistantDrafts).length)assistantSnapshot=value;
|
||||
const v=assistantSnapshot, disabled=!canSend||out()||assistantBusy?' disabled':'';
|
||||
const named=names?Object.fromEntries(names.map(r=>[r.instance,r.name])):null;
|
||||
const forms=Object.entries(v.assistants).map(([instance,entry])=>{
|
||||
const d=assistantDrafts[instance]||{}, val=f=>Object.hasOwn(d,f)?d[f]:asShown(entry,f), k=esc(instance);
|
||||
const name=Object.hasOwn(nameDrafts,instance)?nameDrafts[instance]:named?.[instance]??'';
|
||||
return `<form class="assistant-form" data-instance="${k}"><fieldset><legend>${txt(instance)}</legend><div class="filters"><label>Seat name <input data-assistant-field="name" value="${esc(name)}" maxlength="64"${disabled}></label> <button type="button" data-assistant-name="${k}"${disabled}>Save name</button>${named?'':' <span class="small muted">The current name could not be read.</span>'}</div><label>Definition <input data-assistant-field="definition" value="${esc(val('definition'))}" maxlength="64"${disabled}></label><label>Vars (JSON) <textarea data-assistant-field="vars" rows="4"${disabled}>${esc(val('vars'))}</textarea></label><label>Tracker (JSON, empty removes it) <textarea data-assistant-field="tracker" rows="4"${disabled}>${esc(val('tracker'))}</textarea></label><button type="submit"${disabled}>Save assistant settings</button> <button type="button" data-assistant-stop="${k}"${disabled}>Stop its runs</button><p role="status" data-assistant-receipt="${k}">${txt(assistantReceipts[instance]||'')}</p></fieldset></form>`;
|
||||
}).join('');
|
||||
return `<section class="panel" aria-labelledby="assistant-title"><h2 id="assistant-title">Assistant</h2><p>The definition stays an assistant one. Holder and credentials stay hand edits. Stop ends the runs of your threads; the assistant starts again when you next write to it.</p>${forms}<p class="small muted">${canSend?'Changes are checked and logged with your identity.':'Changes require signed-in send mode.'}</p></section>`;
|
||||
}
|
||||
function assistantPayload(instance,form){
|
||||
const entry=assistantSnapshot.assistants[instance], patch={};
|
||||
for(const f of ['definition','vars','tracker']){
|
||||
const text=form.querySelector(`[data-assistant-field="${f}"]`).value, what=f==='vars'?'Vars':'The tracker';
|
||||
if(text===asShown(entry,f))continue;
|
||||
if(f==='definition'){patch.definition=text.trim();continue;}
|
||||
if(f==='tracker'&&text.trim()===''){patch.tracker=null;continue;}
|
||||
let parsed;try{parsed=JSON.parse(text);}catch{throw Error(`${what} must be JSON.`);}
|
||||
if(!parsed||typeof parsed!=='object'||Array.isArray(parsed))throw Error(`${what} must be a JSON object.`);
|
||||
patch[f]=parsed;
|
||||
}
|
||||
if(!Object.keys(patch).length)throw Error('Nothing changed.');
|
||||
return {revision:assistantSnapshot.revision,assistants:{[instance]:patch}};
|
||||
}
|
||||
const unknownAssistant='Outcome unknown. Read current settings before trying again. Draft kept.';
|
||||
async function assistantPost(url,payload,instance,ok){
|
||||
assistantBusy=true;assistantReceipts[instance]='';render(false);
|
||||
try{const response=await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){assistantReceipts[instance]='Sign in again. Draft kept.';signOut();}else{const result=await response.json();assistantReceipts[instance]=response.ok?ok(result):result?.sent==='unknown'?unknownAssistant:`${result?.message||'Refused.'} Draft kept.`;}}
|
||||
catch{assistantReceipts[instance]=unknownAssistant;}finally{assistantBusy=false;if(!out())render(false);}
|
||||
}
|
||||
const assistantForm=el=>el?.closest?.('.assistant-form');
|
||||
for(const kind of ['input','change'])document.addEventListener(kind,e=>{const form=assistantForm(e.target);if(!form)return;const instance=form.dataset.instance,field=e.target.dataset.assistantField;if(field==='name')nameDrafts[instance]=e.target.value;else if(field)assistantDrafts[instance]={...Object.fromEntries(['definition','vars','tracker'].map(f=>[f,form.querySelector(`[data-assistant-field="${f}"]`).value]))};});
|
||||
document.addEventListener('submit',async e=>{
|
||||
const form=assistantForm(e.target);if(!form||e.target!==form)return;e.preventDefault();if(!canSend||out()||assistantBusy)return;const instance=form.dataset.instance;let payload;
|
||||
try{payload=assistantPayload(instance,form);}catch(err){assistantReceipts[instance]=err.message;render(false);return;}
|
||||
await assistantPost('/api/settings',payload,instance,result=>{if(!/^[a-f0-9]{64}$/.test(result?.revision??''))return unknownAssistant;delete assistantDrafts[instance];assistantSnapshot=result;const prior=last.get('api:settings');if(prior)last.set('api:settings',{...prior,launch:result});return 'Assistant settings saved.';});
|
||||
});
|
||||
document.addEventListener('click',async e=>{
|
||||
const form=assistantForm(e.target);if(!form||!canSend||out()||assistantBusy)return;const instance=form.dataset.instance;
|
||||
if(e.target.dataset.assistantName!==undefined){const name=form.querySelector('[data-assistant-field="name"]').value.trim();
|
||||
await assistantPost('/api/name',{instance,name},instance,result=>{if(typeof result?.name!=='string')return unknownAssistant;delete nameDrafts[instance];const prior=last.get('names');if(prior)last.set('names',{...prior,rows:[...prior.rows.filter(r=>r.instance!==result.instance),{instance:result.instance,name:result.name,at:result.at}]});return `Seat name set to ${result.name}.`;});}
|
||||
else if(e.target.dataset.assistantStop!==undefined)await assistantPost('/api/assistant-stop',{instance},instance,result=>Array.isArray(result?.stopped)?`Stopped ${plural(result.stopped.length,'run')}.`:unknownAssistant);
|
||||
});
|
||||
async function settingsView(get) {
|
||||
const s = await get.api('settings'), n = s.notifier || {};
|
||||
let usage;
|
||||
let usage, names = null;
|
||||
try { [usage] = await get('usage'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; }
|
||||
try { names = await get('names'); } catch { /* the section says the name could not be read */ }
|
||||
let limitRows = [], limitsRefused = false;
|
||||
try { limitRows = await get('limits'); } catch (err) { if (err.status === 403 || err.code === 'signed-out') throw err; limitsRefused = true; }
|
||||
const notify = typeof n.binding === 'string'
|
||||
? `<dl class="kv"><dt>Binding</dt><dd><code>${txt(n.binding)}</code></dd></dl><p class="small muted">The binding name only. Its channels, server and user ids stay in the binding file.</p>`
|
||||
: empty('No notifier binding to show.', `${n.refused ? `<code>${txt(n.refused)}</code>: ` : ''}${txt(n.message || 'the notifier settings could not be read')}`);
|
||||
return `${head('Settings', `${readLine()} Agent limits and appearance.`)}${appearance()}${launchForm(s.launch)}${tokenLimits(limitRows, limitsRefused)}${tokenUsage(usage)}
|
||||
return `${head('Settings', `${readLine()} Agent limits and appearance.`)}${appearance()}${launchForm(s.launch)}${assistantSection(s.launch, names)}${tokenLimits(limitRows, limitsRefused)}${tokenUsage(usage)}
|
||||
<section aria-labelledby="st-notify"><h2 id="st-notify">Notifications</h2>${notify}</section>
|
||||
<section aria-labelledby="st-about" class="panel"><h2 id="st-about">About</h2><dl class="kv"><dt>Address</dt><dd><code>${txt(s.address) || 'unknown'}</code></dd><dt>Release</dt><dd>${s.release ? `<code>${txt(s.release)}</code>` : '<span class="muted">unknown: no RELEASE file was read</span>'}</dd><dt>Business</dt><dd>${txt(s.business) || none}</dd><dt>Board</dt><dd><code>${txt(s.board) || 'unknown'}</code></dd></dl></section>`;
|
||||
}
|
||||
|
||||
@@ -50,8 +50,9 @@ async function body(req, max = 4096) {
|
||||
// Row 57 (#1546) adds `decisions`, also a read, for the Inbox tabs. Row 60
|
||||
// (#1549) adds `credentials`: what the bus host loaded, metadata only. Row 81
|
||||
// (#1575) adds `replies`: the messages answering one message, for the send receipt. Row 125
|
||||
// (#1619, REQ-LIMIT-3) adds `limits`: only the token limits' report, never the document.
|
||||
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'usage', 'limits'];
|
||||
// (#1619, REQ-LIMIT-3) adds `limits`: only the token limits' report, never the document. Row 155
|
||||
// (#1649) adds `names`: each instance's current seat name, for the Assistant section.
|
||||
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'usage', 'limits', 'names'];
|
||||
const DECISION_LISTS = ['roles', 'recent'];
|
||||
async function busRead(res, bus, verb, search) {
|
||||
let arg;
|
||||
@@ -196,12 +197,13 @@ async function settingsSend(req, res, { settingsWrite, preview, log }) {
|
||||
} catch {
|
||||
return refuse(400, 'invalid-request', 'settings need a JSON object within 16 KiB');
|
||||
}
|
||||
// Row 155 (#1649, Q169): `assistants` carries an assistant's vars, tracker and definition.
|
||||
// Row 125 (#1619, REQ-LIMIT-3): `reset` names token limit paths to count again from now.
|
||||
const writable = ['limits', 'models', 'instances', 'reset'];
|
||||
const writable = ['limits', 'models', 'instances', 'reset', 'assistants'];
|
||||
if (Object.keys(value).some(key => !['revision', ...writable].includes(key)) ||
|
||||
typeof value.revision !== 'string' || !/^[a-f0-9]{64}$/.test(value.revision) ||
|
||||
!writable.some(key => Object.hasOwn(value, key))) {
|
||||
return refuse(400, 'invalid-request', 'settings need a revision and limits, models, instance selections or a reset');
|
||||
return refuse(400, 'invalid-request', 'settings need a revision and limits, models, instance selections, a reset or assistant settings');
|
||||
}
|
||||
try {
|
||||
const result = await settingsWrite(value);
|
||||
@@ -218,6 +220,39 @@ async function settingsSend(req, res, { settingsWrite, preview, log }) {
|
||||
return refuse(busStatus(code), code, 'Settings refused; read the current values before trying again.');
|
||||
}
|
||||
}
|
||||
// Row 155 (#1649, Q169): the human names a seat and stops the assistant. Each route has the
|
||||
// Settings boundary (header user, exact Origin, same-origin Fetch Metadata, JSON within 4 KiB,
|
||||
// strict keys, all text) and one bus call through the human transport, `name.set` or
|
||||
// `assistant.stop`. The broker checks the human and the instance.
|
||||
const SEAT_ROUTES = {
|
||||
'/api/name': { kind: 'name', keys: ['instance', 'name'], done: r => typeof r?.instance === 'string' && typeof r.name === 'string' },
|
||||
'/api/assistant-stop': { kind: 'assistant-stop', keys: ['instance'], done: r => Array.isArray(r?.stopped) && r.stopped.every(x => typeof x?.thread === 'string' && typeof x.run === 'string') },
|
||||
};
|
||||
async function seatSend(req, res, { route, write, preview, log }) {
|
||||
let instance = null;
|
||||
// The instance is quoted and cut short so a crafted value can't forge a log line.
|
||||
const line = outcome => log(`${new Date().toISOString()} ${route.kind} user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`);
|
||||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||||
if (one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
|
||||
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
|
||||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
|
||||
let value;
|
||||
try { value = JSON.parse((await body(req)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||||
if (Object.keys(value).length !== route.keys.length || !route.keys.every(k => typeof value[k] === 'string')) {
|
||||
return refuse(400, 'invalid-request', `this takes ${route.keys.join(' and ')}, all text, nothing else`);
|
||||
}
|
||||
({ instance } = value);
|
||||
let result;
|
||||
try { result = await write(Object.fromEntries(route.keys.map(k => [k, value[k]]))); } catch (err) {
|
||||
const { code, sent } = sendOutcome(err);
|
||||
return sent === false
|
||||
? refuse(busStatus(code), code, `refused by the bus (${code}); nothing changed`)
|
||||
: refuse(503, 'outcome-unknown', 'the change may have been made; nothing was retried. Read the Settings again before trying', 'unknown');
|
||||
}
|
||||
if (!route.done(result)) return refuse(503, 'outcome-unknown', 'the bus answered in an unexpected shape; the change may have been made. Read the Settings again before trying', 'unknown');
|
||||
line('done');
|
||||
return json(res, 200, { ...result, at: new Date().toISOString() });
|
||||
}
|
||||
// Row 148 (#1642): the spec and docs editor. Two reads and one save, all through the human transport.
|
||||
// The save has the Settings boundary (header user, exact Origin, same-origin Fetch Metadata, JSON,
|
||||
// strict keys) with a 1 MiB body on this route only; every other POST keeps its own smaller limit.
|
||||
@@ -276,7 +311,7 @@ async function projectFileRead(res, read, path, search) {
|
||||
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode. `log`
|
||||
// takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
|
||||
// tests; the field table is otherwise the committed packages/tasks/fields.json.
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
|
||||
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
|
||||
if (host === 'localhost') host = '127.0.0.1';
|
||||
const upstream = boardURL(board);
|
||||
@@ -316,6 +351,12 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if(!send||!settingsWrite||!named)return json(res,403,{error:'read-only',sent:false});
|
||||
return settingsSend(req,res,{settingsWrite,preview,log});
|
||||
}
|
||||
if (Object.hasOwn(SEAT_ROUTES, path)) {
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||||
const write = path === '/api/name' ? nameWrite : assistantStop;
|
||||
if (!send || !write || !named) return json(res, 403, { error: 'read-only', sent: false });
|
||||
return seatSend(req, res, { route: SEAT_ROUTES[path], write, preview, log });
|
||||
}
|
||||
if (path === '/api/project-file' && req.method === 'POST') {
|
||||
if (!send || !projectSave || !named) return json(res, 403, { error: 'read-only', sent: false });
|
||||
return projectFileSend(req, res, { projectSave, preview, log });
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// Row 155 (#1649, Q169): the Console's seat name and assistant stop routes. Each has the Settings
|
||||
// boundary and makes one human-transport call; the broker's own checks are in the bus tests.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request } from 'node:http';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { BusReadError } from '../src/bus.mjs';
|
||||
|
||||
const preview = { host: 'console.example', header: 'x-user', user: 'jason' };
|
||||
const headers = { host: 'console.example', 'x-user': 'jason', origin: 'https://console.example', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
|
||||
|
||||
async function setup(t, { on = true, name = async args => ({ ...args }), stop = async () => ({ stopped: [{ thread: 't-1', run: 'a-run' }] }), bus = null } = {}) {
|
||||
const calls = [], lines = [];
|
||||
const server = await startServer({
|
||||
port: 0, preview, bus, log: line => lines.push(line), send: on ? async () => ({}) : null,
|
||||
nameWrite: on ? async args => { calls.push(['name.set', args]); return name(args); } : null,
|
||||
assistantStop: on ? async args => { calls.push(['assistant.stop', args]); return stop(args); } : null,
|
||||
});
|
||||
t.after(() => new Promise(r => server.close(r)));
|
||||
return { calls, lines, port: server.address().port };
|
||||
}
|
||||
const send = (s, path, data, hs = headers, method = 'POST') => new Promise((resolve, reject) => {
|
||||
const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => {
|
||||
let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, body: text ? JSON.parse(text) : null }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end(data === undefined ? undefined : typeof data === 'string' ? data : JSON.stringify(data));
|
||||
});
|
||||
|
||||
test('Q169 the signed-in human names a seat and stops the assistant, one bus call each, logged with the user', async t => {
|
||||
const s = await setup(t);
|
||||
const named = await send(s, '/api/name', { instance: 'assistant', name: 'piper' });
|
||||
assert.equal(named.status, 200);
|
||||
assert.equal(named.body.name, 'piper');
|
||||
const stopped = await send(s, '/api/assistant-stop', { instance: 'assistant' });
|
||||
assert.equal(stopped.status, 200);
|
||||
assert.deepEqual(stopped.body.stopped, [{ thread: 't-1', run: 'a-run' }]);
|
||||
assert.deepEqual(s.calls, [['name.set', { instance: 'assistant', name: 'piper' }], ['assistant.stop', { instance: 'assistant' }]]);
|
||||
assert.match(s.lines[0], / name user="jason" instance="assistant" done$/);
|
||||
assert.match(s.lines[1], / assistant-stop user="jason" instance="assistant" done$/);
|
||||
});
|
||||
|
||||
test('Q169 the name and stop routes need send on, the public name, the user, the exact Origin and same-origin Fetch Metadata', async t => {
|
||||
const off = await setup(t, { on: false });
|
||||
for (const path of ['/api/name', '/api/assistant-stop']) assert.equal((await send(off, path, { instance: 'assistant' })).status, 403);
|
||||
const s = await setup(t);
|
||||
for (const path of ['/api/name', '/api/assistant-stop']) {
|
||||
const data = path === '/api/name' ? { instance: 'assistant', name: 'piper' } : { instance: 'assistant' };
|
||||
for (const [key, value, status] of [['host', 'wrong.example', 403], ['x-user', 'other', 401], ['x-user', ['jason', 'jason'], 401], ['origin', 'https://else.example', 403], ['origin', undefined, 403], ['sec-fetch-site', 'cross-site', 403], ['sec-fetch-site', undefined, 403]]) {
|
||||
const hs = { ...headers };
|
||||
if (value === undefined) delete hs[key]; else hs[key] = value;
|
||||
assert.equal((await send(s, path, data, hs)).status, status, `${path} ${key}`);
|
||||
}
|
||||
assert.equal((await send(s, path, undefined, headers, 'GET')).status, 405);
|
||||
}
|
||||
assert.equal(s.calls.length, 0);
|
||||
});
|
||||
|
||||
test('Q169 the name and stop routes take JSON within 4 KiB and only their own keys, all text', async t => {
|
||||
const s = await setup(t);
|
||||
for (const value of [{ instance: 'assistant' }, { instance: 'assistant', name: 'piper', by: 'jason' }, { instance: 'assistant', name: 7 }, [], 'bad', { instance: 'assistant', name: 'x'.repeat(5000) }])
|
||||
assert.equal((await send(s, '/api/name', value)).status, 400, JSON.stringify(value).slice(0, 40));
|
||||
for (const value of [{}, { instance: 'assistant', thread: 't-1' }, { instance: null }])
|
||||
assert.equal((await send(s, '/api/assistant-stop', value)).status, 400, JSON.stringify(value));
|
||||
assert.equal((await send(s, '/api/name', { instance: 'assistant', name: 'piper' }, { ...headers, 'content-type': 'text/plain' })).status, 400);
|
||||
assert.equal(s.calls.length, 0);
|
||||
});
|
||||
|
||||
test('Q169 a bus refusal says nothing changed; a lost or odd answer says the change may have been made', async t => {
|
||||
const fail = code => async () => { throw new BusReadError(code); };
|
||||
for (const [stop, status, error, sent] of [
|
||||
[fail('not-held'), 409, 'not-held', false], [fail('human-required'), 403, 'human-required', false],
|
||||
[fail('not-assistant'), 400, 'not-assistant', false], [fail('outcome-unknown'), 503, 'outcome-unknown', 'unknown'],
|
||||
[async () => ({ stopped: 'all' }), 503, 'outcome-unknown', 'unknown'],
|
||||
]) {
|
||||
const s = await setup(t, { stop });
|
||||
const r = await send(s, '/api/assistant-stop', { instance: 'assistant' });
|
||||
assert.deepEqual([r.status, r.body.error, r.body.sent], [status, error, sent]);
|
||||
assert.equal(s.calls.length, 1);
|
||||
assert.match(s.lines[0], new RegExp(` error=${error}$`));
|
||||
}
|
||||
const s = await setup(t, { name: fail('name-taken') });
|
||||
const r = await send(s, '/api/name', { instance: 'assistant', name: 'piper' });
|
||||
assert.deepEqual([r.status, r.body.error, r.body.sent], [409, 'name-taken', false]);
|
||||
});
|
||||
|
||||
test('Q169 the names read is a bus read like the others', async t => {
|
||||
const rows = [{ instance: 'assistant', name: 'piper', at: '2026-10-18T00:00:00.000Z' }];
|
||||
const s = await setup(t, { bus: { names: async () => rows } });
|
||||
const r = await send(s, '/api/bus/names', undefined, { host: 'console.example', 'x-user': 'jason' }, 'GET');
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(r.body.rows, rows);
|
||||
});
|
||||
@@ -21,3 +21,32 @@ test('Settings form shows every scope and held role; gated save, exact payload,
|
||||
await b.evaluate('mode="refused"');await submit();await wait('document.querySelector("#launch-settings-receipt")?.textContent.includes("fixture conflict")');assert.equal(await b.evaluate('posts.length'),3);assert.equal(await b.evaluate('document.querySelector("#launch-settings-form input[type=number]").value'),'9');
|
||||
}finally{await b.close();await close(web);}
|
||||
});
|
||||
// Row 155 (#1649, Q169): the Assistant section. A save sends only the changed fields with the
|
||||
// read's revision; the name and stop buttons post their own routes; a refusal keeps the draft.
|
||||
// The page reads settings again after a save, so the mock serves the saved state from then on.
|
||||
test('Q169 Assistant section: gated, changed fields only, name and stop posts, retained draft and no automatic retry',{timeout:90000},async()=>{
|
||||
const withAssistant={...launch,assistants:{assistant:{definition:'assistant',vars:{model:'claude-sonnet-5-5'},tracker:null}}};
|
||||
const bus={inbox:async()=>[],tasks:async()=>[],agents:async()=>[],messages:async()=>({messages:[],unread:0}),names:async()=>[{instance:'assistant',name:'piper',at:'2026-10-18T00:00:00.000Z'}]};
|
||||
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads:{settings:()=>({business:'acme',release:'fixture',notifier:{binding:'fixture'},launch:withAssistant})}}),b=await browser();
|
||||
try{
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.posts=[];window.enabled=new URL(location.href).searchParams.has('send');window.mode='success';window.fixture=${JSON.stringify(withAssistant)};const original=window.fetch;window.fetch=async(url,options={})=>{if(url==='/api/config')return new Response(JSON.stringify({send:window.enabled}));if(options.method==='POST'){const body=JSON.parse(options.body);posts.push([url,body]);if(mode==='refused')return new Response(JSON.stringify({message:'fixture conflict',sent:false}),{status:409});if(url==='/api/name')return new Response(JSON.stringify({...body,at:'2026-10-18T01:00:00.000Z'}));if(url==='/api/assistant-stop')return new Response(JSON.stringify({stopped:[{thread:'t-1',run:'a-run'},{thread:'t-2',run:'b-run'}]}));const a=body.assistants.assistant;window.saved={...fixture,revision:'b'.repeat(64),assistants:{assistant:{...fixture.assistants.assistant,...a}}};return new Response(JSON.stringify(window.saved));}if(url==='/api/settings'&&window.saved){const read=await (await original(url,options)).json();return new Response(JSON.stringify({...read,launch:window.saved}));}return original(url,options);};`});
|
||||
const port=web.address().port, q=sel=>`document.querySelector(${JSON.stringify(sel)})`;
|
||||
const wait=expr=>b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expr})return true;await new Promise(r=>setTimeout(r,30));}throw Error('wait failed: '+${JSON.stringify(expr)});})()`);
|
||||
await b.navigate(`http://127.0.0.1:${port}/#/settings`);await wait(`!!${q('.assistant-form')}`);
|
||||
assert.equal(await b.evaluate(`${q('.assistant-form button[type=submit]')}.disabled&&${q('[data-assistant-stop]')}.disabled&&${q('[data-assistant-name]')}.disabled`),true);
|
||||
await b.navigate(`http://127.0.0.1:${port}/?send=1#/settings`);await wait(`${q('.assistant-form button[type=submit]')}?.disabled===false`);
|
||||
assert.equal(await b.evaluate(`${q('[data-assistant-field=name]')}.value`),'piper');assert.equal(await b.evaluate(`${q('[data-assistant-field=definition]')}.value`),'assistant');
|
||||
const change=async(field,value)=>b.evaluate(`(()=>{const e=${q('.assistant-form')}.querySelector('[data-assistant-field="${field}"]');e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));return true;})()`);
|
||||
const receipt=text=>wait(`${q('[data-assistant-receipt]')}?.textContent.includes(${JSON.stringify(text)})`);
|
||||
await b.evaluate(`${q('.assistant-form')}.requestSubmit();true`);await receipt('Nothing changed');assert.equal(await b.evaluate('posts.length'),0);
|
||||
await change('vars','{"model":"claude-opus-5-5","thinking":"high"}');await b.evaluate(`${q('.assistant-form')}.requestSubmit();true`);await receipt('Assistant settings saved');
|
||||
assert.deepEqual(await b.evaluate('posts[0]'),['/api/settings',{revision:launch.revision,assistants:{assistant:{vars:{model:'claude-opus-5-5',thinking:'high'}}}}]);
|
||||
await change('tracker','[1]');await b.evaluate(`${q('.assistant-form')}.requestSubmit();true`);await receipt('must be a JSON object');assert.equal(await b.evaluate('posts.length'),1);
|
||||
await b.evaluate('mode="refused"');await change('tracker','{"project":"P19"}');await b.evaluate(`${q('.assistant-form')}.requestSubmit();true`);await receipt('fixture conflict');
|
||||
assert.deepEqual(await b.evaluate('posts[1]'),['/api/settings',{revision:'b'.repeat(64),assistants:{assistant:{tracker:{project:'P19'}}}}]);assert.equal(await b.evaluate(`${q('[data-assistant-field=tracker]')}.value`),'{"project":"P19"}');
|
||||
await b.evaluate('new Promise(r=>setTimeout(r,300))');assert.equal(await b.evaluate('posts.length'),2);
|
||||
await b.evaluate('mode="success"');await change('name','wren');await b.evaluate(`${q('[data-assistant-name]')}.click();true`);await receipt('Seat name set to wren');
|
||||
assert.deepEqual(await b.evaluate('posts[2]'),['/api/name',{instance:'assistant',name:'wren'}]);
|
||||
await b.evaluate(`${q('[data-assistant-stop]')}.click();true`);await receipt('Stopped 2 runs');assert.deepEqual(await b.evaluate('posts[3]'),['/api/assistant-stop',{instance:'assistant'}]);assert.equal(await b.evaluate('posts.length'),4);
|
||||
}finally{await b.close();await close(web);}
|
||||
});
|
||||
|
||||
@@ -16,3 +16,9 @@ test('REQ-WEB-1 Settings writes require enabled send, named Host, identity, exac
|
||||
test('REQ-WEB-1 Settings validates JSON, size, revision and strict writable keys before broker call',async t=>{
|
||||
const s=await setup(t);for(const value of [{...payload,actor:'jason'},{limits:{}},[],null,'bad','x'.repeat(17000)])assert.equal((await post(s,headers,value)).status,400,String(value));assert.equal((await post(s,{...headers,'content-type':'text/plain'})).status,400);assert.equal(s.calls.length,0);
|
||||
});
|
||||
test('Q169 a Console change of the assistant reaches the human operation unchanged, and the log names the signed-in user',async t=>{
|
||||
const calls=[],lines=[];const server=await startServer({port:0,preview,send:async()=>({}),log:l=>lines.push(l),settingsWrite:async args=>{calls.push(args);return {...args,business:'acme'};}});t.after(()=>new Promise(r=>server.close(r)));const s={port:server.address().port};
|
||||
const change={revision:'a'.repeat(64),assistants:{assistant:{definition:'assistant',vars:{model:'claude-opus-5-5',thinking:'high'},tracker:null}}};
|
||||
const r=await post(s,headers,change);assert.equal(r.status,200,r.text);assert.deepEqual(calls,[change]);assert.equal(lines.length,1);assert.match(lines[0],/ settings user="jason" changed$/);
|
||||
for(const value of [{assistants:change.assistants},{...change,holder:'x'}])assert.equal((await post(s,headers,value)).status,400);assert.equal(calls.length,1);
|
||||
});
|
||||
|
||||
@@ -55,8 +55,10 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () =
|
||||
// to the same Settings route) and row 148's spec and docs save.
|
||||
assert.doesNotMatch(src, /<button[^>]*>[^<]*(Resolve|Decide|Approve)/i);
|
||||
const writes = read('bus.js').toString().match(/fetch\([^;]*method:\s*['"](POST|PUT|PATCH|DELETE)[^;]*;/g) ?? [];
|
||||
assert.deepEqual(writes, ["fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]);
|
||||
assert.equal(read('bus.js').toString().split(/method:/).length - 1, 4);
|
||||
assert.deepEqual(writes, ["fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]);
|
||||
assert.equal(read('bus.js').toString().split(/method:/).length - 1, 5);
|
||||
// Row 155 (#1649): the one fetch(url, ...) posts only to these three routes, each a literal.
|
||||
assert.deepEqual([...read('bus.js').toString().matchAll(/assistantPost\(([^,]*),/g)].map(m => m[1]), ['url', "'/api/settings'", "'/api/name'", "'/api/assistant-stop'"]);
|
||||
assert.equal(read('icons.svg').toString().includes('<script'), false);
|
||||
});
|
||||
|
||||
|
||||
@@ -33,6 +33,13 @@ product's code, you don't review it and you don't create tasks yourself.
|
||||
launch limits, the model table and the model each instance runs with
|
||||
the `change_organization` tool. To hire an instance or change what one
|
||||
may do, raise a `policy.change` decision.
|
||||
- You and the human are the ones who configure and stop the assistant.
|
||||
Change its vars, its tracker or its definition with
|
||||
`change_organization` and `assistants`; its holder and credentials
|
||||
stay hand edits. The revision comes from the human until
|
||||
you have a way to read it. End its runs with `end_run`, which ends one
|
||||
run per thread; it starts again when the human writes to it. Set an
|
||||
instance's seat name with `set_name`.
|
||||
- Report to the assistant what the roles answer, and every refusal with
|
||||
its exact text.
|
||||
- When the pm or the cto answers the bus's notice of a saved spec with a
|
||||
|
||||
Reference in New Issue
Block a user