bus, cli: tester reads and the W1-W7 tester writes, recorded as tester; scripts/mosaic sends plugins, name and names to the CLI (row 175, #1673)
TESTER_VERBS gains usage, names, limits, plugins, instances, project.list, thread.list, name.set and project.create. A tester reads limits, plugins and instances and sends plugins.set, instances.set and routes.set, each logged with actor tester. The project file verbs refuse a tester tester-refused. The broker and CLI drop the [mos test] prefix rule (mos-0872 Q1). The CLI takes --tester on the seven reads and the W1-W7 write forms and refuses it (exit 4) elsewhere, bus start and stop included. project init --tester checks the project area, refuses the area root itself, binds the tester with one agents read, then commits the layout as tester (mos-0872 Q2). scripts/mosaic sends plugins, name, names, follow, threads and thread to packages/cli (sage-1081). The row 164 pins are untouched. Built by Dewey on9e74925c2(three commits to 989699f7c, manifest 2e30f2a1...29d5f), approved by Filbert in round 3 (#1673 comment 29279), Sage's gate green on716ddcf1eplus the patch.
This commit is contained in:
+2
-1
@@ -277,7 +277,8 @@ scripts/bus-service.sh uninstall | status <business>
|
||||
|
||||
The human commands read and resolve through the broker's human transport
|
||||
(`packages/bus/src/human-cli.mjs`), never the SQLite file. They refuse inside
|
||||
an agent run. `bus start` is the host the systemd user unit
|
||||
an agent run, except the forms that take `--tester` (rows 110 and 175), which
|
||||
run as the business's tester; `packages/cli/README.md` lists them. `bus start` is the host the systemd user unit
|
||||
`mosaic-bus@<business>` runs. It boots the broker, starts the Discord
|
||||
notifier, and needs `<dataRoot>/notify/<business>/notify.json`. That
|
||||
directory must be 0700: it also holds the notifier's journal `sent.jsonl`
|
||||
|
||||
+12
-10
@@ -192,8 +192,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). 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. The chief of staff learns the revision from `organization.revision` (below); a stale one is `revision-conflict`. |
|
||||
| `organization.revision` | No args (row 134, #1669). Answers `{revision}`, the organization file's current revision, and nothing else the human's `limits` read returns: no limits, models, instances or tokens. Needs `organization.change` in the caller's role file, with the same refusals as `organization.set` (`not-holder`, `read-only`, `tester-refused`, `agent-required` for the human, `decision-required` for a role whose only classification is cross-role; no shipped role is). A read never spends the grant and writes no receipt. Any argument is `invalid-request`. `limits` stays human-only. |
|
||||
| `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.set` stays human-only, and a tester gets `tester-refused` from `organization.set` (row 175, #1673). `assistants` (row 155, Q169) is described in the business README; a refusal from it passes through as its `organization-*` code. The chief of staff learns the revision from `organization.revision` (below); a stale one is `revision-conflict`. |
|
||||
| `organization.revision` | No args (row 134, #1669). Answers `{revision}`, the organization file's current revision, and nothing else the human's `limits` read returns: no limits, models, instances or tokens. Needs `organization.change` in the caller's role file, with the same refusals as `organization.set` (`not-holder`, `read-only`, `tester-refused`, `agent-required` for the human, `decision-required` for a role whose only classification is cross-role; no shipped role is). A read never spends the grant and writes no receipt. Any argument is `invalid-request`. `limits` stays human-only, apart from the tester's read (row 175). |
|
||||
| `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. |
|
||||
@@ -201,7 +201,7 @@ an operator inspect content and delivery evidence after an uncertain result.
|
||||
| `message.send` | `{to,body,class?,in_reply_to?,corrects?,decision?}`; returns `{id,request}`, plus `waiting` when no run holds the target (Launch notices, below). Human instruction's `request` is its `human.input` event ID. `to` is a role instance, its name (row 141, #1635, REQ-MSG-2), `human` or `tester`. A name resolves to its instance before any check; the message stores the instance in `to_role` and the name in `to_name`, null for a send by instance. An instance key, `human`, `tester` and a role definition the business knows win over a name equal to them, so an instance added later with a key some instance holds as a name still gets its messages, and a send to a later definition's name refuses `unknown-role`. A name the business doesn't hold refuses `unknown-role`. |
|
||||
| `message.receive` | No args; up to 100 undelivered messages for current holder, including originating `request` for human instructions. A human decision close's message carries the close's `human.input` answer event as its `request`; a role close's has `null`. |
|
||||
| `message.read` | `{id}`; recipient holder's acknowledgement; no new holder can acknowledge old holder's delivery. |
|
||||
| `name.set` | `{instance,name}`; action `instance.name`, routed under REQ-MSG-2 (row 141, #1635). The outside-agent human CLI may; an agent only when its role file grants `instance.name` within-role, which the chief of staff's does, and the write logs `action.allowed` with the instance as target. Any other class refuses `not-authorized`, so no decision sets a name. `name` matches the business ID pattern. It refuses `name-is-instance` when it equals an instance key, `human` or `tester`, `name-is-role` when it equals a role definition the loaded business knows (one an instance uses or one it defines), and `name-taken` when another instance of the business holds it now. The `instance_names` trigger backs up the last. Naming an instance again replaces its name. Returns `{instance,name}`. |
|
||||
| `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. A tester may too (row 175, #1673): every name check runs, no authority is spent and `by` is `tester`. 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}`. |
|
||||
@@ -582,21 +582,23 @@ refuses `message-not-found`; malformed arguments refuse `invalid-request`.
|
||||
Roles get `human-required`. A successful read records nothing, so Console
|
||||
history reads never prevent a later `message.receive` from delivering it.
|
||||
|
||||
`instances` and `instances.set` (row 165, #1662, Q171, REQ-ROLE-3) are human-only.
|
||||
A role session, a reader and a tester each get `human-required`, and the chief of staff
|
||||
`instances` and `instances.set` (row 165, #1662, Q171, REQ-ROLE-3) are the human's and,
|
||||
since row 175 (#1673), the tester's. A role session and a reader get `human-required`, and the chief of staff
|
||||
does too: neither verb is in `ORGANIZATION_KEYS`, so `organization.set` refuses the instance
|
||||
keys with `invalid-request` before the writer runs (sage-0918). `instances` takes no args and
|
||||
answers the business's instances, their definitions, vars and launch membership, plus the
|
||||
definitions `instances.set` accepts. `instances.set` takes `{revision,instance,op,definition?,
|
||||
vars?,launch?}`. `op` is `add` or `change`, and every other key is `invalid-request`. The
|
||||
business package's `instance-settings.mjs` writes through the organization writer, which
|
||||
logs the human as the actor. Its refusals pass through as `organization-*` codes, among them
|
||||
logs the human as the actor, or `tester` for a tester's write. Its refusals pass through as `organization-*` codes, among them
|
||||
`organization-credentials-out-of-scope` for a definition that declares credentials. The answer
|
||||
carries `restart: true` after an add or a definition change: the broker keeps the roles it
|
||||
loaded at start, so the change counts only after a restart that needs Mos's go or Jason's
|
||||
hand. Tests: `packages/bus/tests/instances-verb.test.mjs`.
|
||||
|
||||
`routes` and `routes.set` (row 65, #1558, mos-0840) are human-only the same way, and outside
|
||||
`routes` and `routes.set` (row 65, #1558, mos-0840) are human-only the same way, except that a
|
||||
tester may send `routes.set` (row 175), logged with actor `tester`; its `routes` read refuses
|
||||
`tester-refused`. Both are outside
|
||||
`ORGANIZATION_KEYS`, so the chief of staff's `change_organization` can't send a `routes` key.
|
||||
The chief reaches a route only through the assistant's whole vars (Q169), and that write runs
|
||||
the same route checks. `routes` takes no args and answers the routing table;
|
||||
@@ -607,7 +609,7 @@ writes them with the human as actor (`packages/business/README.md`, "Routes"). `
|
||||
from `harnessLevels()`, which loads each harness module in the tree once at start, switched on
|
||||
or not, and reads its `levels()`. Tests: `packages/bus/tests/routes-verb.test.mjs`.
|
||||
|
||||
A tester (row 110, #1604) is a PM test's sender, never the human. `src/tester-cli.mjs <socket>` is human-cli.mjs with its own nonce variable, `MOSAIC_BUS_TESTER_NONCE`, so neither shim's nonce proves the other. `verifyTester` (`src/tester.mjs`) checks the same process proof but walks the ancestry for a managed run only: a launch record, `MOSAIC_RUN_ID`, `MOSAIC_BUS_CAP` or `MOSAIC_AGENT_RUN` refuses `tester-required`. Claude and Codex markers pass, since a tester runs from inside Mos or a T3 seat. A role session can't bind: its shim reports a pid from the sandbox's PID namespace, and the seat sandbox test runs that case under bwrap. `bindTester` then binds only when the business var `bus.tester` is exactly `true`, read at bus start, else `tester-off`. A request carries exactly one of `cap`, `human` or `tester`. The session may use `message.send`, `message.receive` and `message.read`, plus the reader's views of `inbox`, `agents`, `tasks`, `trail`, `decisions`, `messages` and `replies`; every other verb refuses `tester-refused`. It sends only to a role, with a body that starts `[mos test]` (`tester-prefix`) and no `corrects` or `decision`. Its message is stored `from_role 'tester'`, `from_run 'tester-cli'`, with no request and no event, so no `human.input` backs it and `task_create` can't cite it. A role answers at the `tester` address, and only with `in_reply_to` naming a tester's message (`tester-reply-required`); the human can't send there. Those replies never reach the human's `message.receive` or `messages`.
|
||||
A tester (row 110, #1604) is a PM test's sender, never the human. `src/tester-cli.mjs <socket>` is human-cli.mjs with its own nonce variable, `MOSAIC_BUS_TESTER_NONCE`, so neither shim's nonce proves the other. `verifyTester` (`src/tester.mjs`) checks the same process proof but walks the ancestry for a managed run only: a launch record, `MOSAIC_RUN_ID`, `MOSAIC_BUS_CAP` or `MOSAIC_AGENT_RUN` refuses `tester-required`. Claude and Codex markers pass, since a tester runs from inside Mos or a T3 seat. A role session can't bind: its shim reports a pid from the sandbox's PID namespace, and the seat sandbox test runs that case under bwrap. `bindTester` then binds only when the business var `bus.tester` is exactly `true`, read at bus start, else `tester-off`. A request carries exactly one of `cap`, `human` or `tester`. The session may use `message.send`, `message.receive` and `message.read`, plus the reader's views of `inbox`, `agents`, `tasks`, `trail`, `decisions`, `messages` and `replies`. Row 175 (#1673, Q173, mos-0870 N2) adds the reads `usage`, `names`, `limits`, `plugins`, `instances`, `project.list` and `thread.list`, each answering what the human's answers (a tester has no threads of its own, so `thread.list` lists the business human's), and the writes `name.set` (`by` `tester`), `project.create` (`created_by` `tester`), `plugins.set`, `instances.set` and `routes.set`, each with every check the human's write runs and the organization receipt's actor `tester`. `TESTER_VERBS` and `TESTER_ORGANIZATION_VERBS` in `src/broker.mjs` list them; every other verb refuses `tester-refused`, among them `limits.set`, `organization.set`, `organization.revision`, `routes`, `decision.resolve`, `launch.revoke`, `launch.restore`, the credential verbs, `project.file.save`, `assistant.stop` and the thread verbs other than `thread.list`. Tests: `tests/tester-writes.test.mjs`. It sends only to a role, with any body and no `corrects` or `decision`. Its message is stored `from_role 'tester'`, `from_run 'tester-cli'`, with no request and no event, so no `human.input` backs it and `task_create` can't cite it. A role answers at the `tester` address, and only with `in_reply_to` naming a tester's message (`tester-reply-required`); the human can't send there. Those replies never reach the human's `message.receive` or `messages`.
|
||||
|
||||
## Shared reads and adapter boundary
|
||||
|
||||
@@ -801,8 +803,8 @@ transient proof until completion. Other request paths stay synchronous.
|
||||
|
||||
Row 148 (#1642) adds three human-only verbs for the Console's spec and docs editor:
|
||||
`project.files {project}`, `project.file {project,path}` and `project.file.save
|
||||
{project,path,base,content}`. Each refuses an agent session, a reader and a tester
|
||||
(`human-required`) and takes exactly those keys; the two file verbs refuse a path
|
||||
{project,path,base,content}`. Each refuses an agent session and a reader
|
||||
(`human-required`), refuses a tester `tester-refused` (row 175, #1673), and takes exactly those keys; the two file verbs refuse a path
|
||||
other than spec/north-star.md or docs/NAME.md. `project.file` answers `{path,head,commit,content,
|
||||
author,at}`; `project.file.save` answers `{path,commit}` after the project package's
|
||||
`writeHumanFile` made its one commit, or a `stale-base` refusal that wrote nothing.
|
||||
|
||||
+32
-16
@@ -92,8 +92,13 @@ export const WEB_UNTRUSTED =
|
||||
export const TASK_VERBS = Object.freeze(ACTIONS.filter((a) => a.startsWith('task.')));
|
||||
// Row 110 (#1604): what a tester session may do. Send to a role, receive and read the replies
|
||||
// sent to `tester`, and the human's read views. Every other verb refuses tester-refused.
|
||||
const TESTER_VERBS = ['message.send', 'message.receive', 'message.read', 'inbox', 'agents', 'tasks', 'trail', 'decisions', 'messages', 'replies'];
|
||||
const TESTER_PREFIX = '[mos test]';
|
||||
// Row 175 (#1673, Q173): the test system's reads and two writes, name.set and project.create,
|
||||
// each recorded with `tester` as its actor. The organization branch keeps its own list below.
|
||||
export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'message.read', 'inbox', 'agents', 'tasks', 'trail', 'decisions', 'messages', 'replies',
|
||||
'usage', 'names', 'limits', 'plugins', 'instances', 'project.list', 'thread.list', 'name.set', 'project.create']);
|
||||
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
|
||||
// and log actor `tester`; limits.set, organization.set, routes and organization.revision refuse it.
|
||||
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set']);
|
||||
const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']);
|
||||
const POLL_KINDS = new Set([
|
||||
'task.changed.external',
|
||||
@@ -1230,7 +1235,9 @@ export class Broker {
|
||||
// to a role whose file lists the same grant. A read never spends the grant.
|
||||
const revisionRead = request.verb === "organization.revision";
|
||||
const role = request.verb === "organization.set" || revisionRead;
|
||||
if (!role && !s.human) fail("human-required");
|
||||
// Row 175 (#1673, Q173): the tester gate runs before any argument check, as at the store below.
|
||||
if (s.tester && !TESTER_ORGANIZATION_VERBS.includes(request.verb)) fail("tester-refused");
|
||||
if (!role && !s.human && !s.tester) fail("human-required");
|
||||
if (!this.#organization) fail("not-configured");
|
||||
if (["limits", "plugins", "instances", "routes", "organization.revision"].includes(request.verb)) keys(args, []);
|
||||
else if (request.verb === "plugins.set") keys(args, ["revision", "plugins"], ["revision", "plugins"]);
|
||||
@@ -1243,7 +1250,7 @@ export class Broker {
|
||||
// runs the same route checks.
|
||||
else if (request.verb === "routes.set") keys(args, ["revision", "routes"], ["revision", "routes"]);
|
||||
else keys(args, ORGANIZATION_KEYS, ["revision"]);
|
||||
let actor = s.human;
|
||||
let actor = s.tester ? "tester" : s.human;
|
||||
// This branch runs outside the store transaction below, so the grant is checked before the writer
|
||||
// and consumed only once the writer has committed: a refused change logs action.refused alone,
|
||||
// as a refused human limits.set does.
|
||||
@@ -1278,6 +1285,8 @@ export class Broker {
|
||||
fail('read-only');
|
||||
if (s.tester && !TESTER_VERBS.includes(request.verb)) fail('tester-refused');
|
||||
// A tester reads the human's views, as a reader does, and its message verbs are its own.
|
||||
// Since row 175 the view also carries the writes name.set and project.create. Neither
|
||||
// branch checks `reader`: each passes a tester by `s.tester` and records `tester`.
|
||||
const view = s.tester && !request.verb.startsWith('message.') ? { ...s, reader: true } : s;
|
||||
const result = this.#dispatch(view, request.verb, args);
|
||||
this.#secretCheck(result);
|
||||
@@ -1401,6 +1410,9 @@ export class Broker {
|
||||
const { verb, args } = request;
|
||||
let ProjectFailure;
|
||||
try {
|
||||
// Row 175 (#1673): these verbs dispatch before the store's tester gate, so they refuse a
|
||||
// tester here, tester-refused like every other human-only verb.
|
||||
if (s.tester) fail('tester-refused');
|
||||
this.#human(s);
|
||||
if (!this.#project?.listFiles) fail('project-not-configured');
|
||||
const { listFiles, readFile, writeFile, ProjectError } = this.#project;
|
||||
@@ -1805,11 +1817,10 @@ export class Broker {
|
||||
? this.#named(s.business, a.to) : null;
|
||||
const to = named ?? a.to;
|
||||
if (s.tester) {
|
||||
// A tester writes to a role only, says so in its first words, and cites no decision.
|
||||
// It consumes no authority and writes no human.input.
|
||||
// A tester writes to a role only and cites no decision. It consumes no authority and
|
||||
// writes no human.input. Row 175 (mos-0872 Q1): the test system needs no body prefix.
|
||||
keys(a, ['to', 'body', 'class', 'in_reply_to'], ['to', 'body']);
|
||||
if (!Object.hasOwn(b.roles, to)) fail('unknown-role');
|
||||
if (typeof a.body !== 'string' || !a.body.startsWith(TESTER_PREFIX)) fail('tester-prefix');
|
||||
} else if (!s.human) {
|
||||
const withinRole = this.#classification(s, 'message.send') === 'within-role';
|
||||
this.#consumeAuthority(s, 'message.send', {
|
||||
@@ -2207,9 +2218,11 @@ export class Broker {
|
||||
// refuses here, so neither a cross-role grant nor a decision can set a name. A name is unique
|
||||
// in the business (the store's trigger backs this up) and equals no instance key, no role
|
||||
// definition, "human" or "tester", so `to` never means two things.
|
||||
// Row 175 (#1673, Q173): the tester names an instance too, with every name check, no
|
||||
// authority consumed and `by` recorded as tester.
|
||||
case 'name.set': {
|
||||
keys(a, ['instance', 'name'], ['instance', 'name']);
|
||||
if (!s.human && this.#classification(s, 'instance.name', a.instance) !== 'within-role') {
|
||||
if (!s.human && !s.tester && this.#classification(s, 'instance.name', a.instance) !== 'within-role') {
|
||||
this.#agent(s);
|
||||
fail('not-authorized');
|
||||
}
|
||||
@@ -2220,14 +2233,14 @@ export class Broker {
|
||||
if (this.#definitions(b).has(a.name)) fail('name-is-role');
|
||||
const holder = this.#named(s.business, a.name);
|
||||
if (holder && holder !== a.instance) fail('name-taken');
|
||||
if (!s.human) this.#consumeAuthority(s, 'instance.name', { target: a.instance });
|
||||
if (!s.human && !s.tester) this.#consumeAuthority(s, 'instance.name', { target: a.instance });
|
||||
this.#store.run(
|
||||
'INSERT INTO instance_names(at,business,instance,name,by) VALUES(?,?,?,?,?)',
|
||||
this.#now(),
|
||||
s.business,
|
||||
a.instance,
|
||||
a.name,
|
||||
s.human || s.run,
|
||||
s.tester ? 'tester' : s.human || s.run,
|
||||
);
|
||||
return { instance: a.instance, name: a.name };
|
||||
}
|
||||
@@ -2246,7 +2259,7 @@ export class Broker {
|
||||
}
|
||||
case 'names': {
|
||||
keys(a, []);
|
||||
if (!s.human) this.#agent(s);
|
||||
if (!s.human && !s.tester) this.#agent(s);
|
||||
return this.#store.all(
|
||||
'SELECT instance,name,at FROM instance_names n WHERE business=? AND seq=(SELECT max(seq) FROM instance_names WHERE business=n.business AND instance=n.instance) ORDER BY instance',
|
||||
s.business,
|
||||
@@ -2254,8 +2267,9 @@ export class Broker {
|
||||
}
|
||||
// Row 121 (#1615): projects group the user's threads (REQ-ORG-2). `repo` names a
|
||||
// project from the business file; the rename to repos is a P19 follow-up.
|
||||
// Row 175 (#1673, Q173): the tester creates and lists projects too, created_by tester.
|
||||
case 'project.create': {
|
||||
this.#human(s);
|
||||
if (!s.tester) this.#human(s);
|
||||
keys(a, ['name', 'repo'], ['name']);
|
||||
if (!name(a.name)) fail('invalid-request');
|
||||
if (a.repo !== undefined && !Object.hasOwn(b.projects ?? {}, identifier(a.repo))) fail('unknown-repo');
|
||||
@@ -2269,12 +2283,12 @@ export class Broker {
|
||||
s.business,
|
||||
a.name,
|
||||
a.repo ?? null,
|
||||
s.human,
|
||||
s.tester ? 'tester' : s.human,
|
||||
);
|
||||
return { id: pid };
|
||||
}
|
||||
case 'project.list': {
|
||||
this.#human(s);
|
||||
if (!s.tester) this.#human(s);
|
||||
keys(a, []);
|
||||
return this.#store.all('SELECT id,at,name,repo FROM projects WHERE business=? ORDER BY seq', s.business);
|
||||
}
|
||||
@@ -2303,8 +2317,10 @@ export class Broker {
|
||||
);
|
||||
return { id: tid, instance: instances[0] };
|
||||
}
|
||||
// Row 175: a tester has no threads of its own (row 177 adds them), so it lists the
|
||||
// business human's, the list the human sees.
|
||||
case 'thread.list': {
|
||||
this.#human(s);
|
||||
if (!s.tester) this.#human(s);
|
||||
keys(a, ['project']);
|
||||
if (a.project !== undefined) identifier(a.project);
|
||||
return this.#store.all(
|
||||
@@ -2312,7 +2328,7 @@ export class Broker {
|
||||
(a.project === undefined ? '' : ' AND project=?') +
|
||||
' ORDER BY seq',
|
||||
s.business,
|
||||
s.human,
|
||||
s.tester ? b.human : s.human,
|
||||
...(a.project === undefined ? [] : [a.project]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,14 +21,15 @@ const biz = () => ({
|
||||
});
|
||||
const ADD = { revision: 'a'.repeat(64), instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true };
|
||||
|
||||
test('row 165: instances and instances.set refuse every non-human session, the chief of staff included, and pass the verified actor', (t) => {
|
||||
test('row 165: instances and instances.set refuse every non-human session but the tester, the chief of staff included, and pass the verified actor', (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'instances-verb-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const calls = [];
|
||||
const b = new Broker({ store, businesses: biz(), organization: (id, verb, args, actor) => (calls.push([id, verb, args, actor]), { ok: true }) });
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const caps = { reader: b.bindReader({ business: 'demo' }), tester: b.bindTester({ business: 'demo' }) };
|
||||
const tester = b.bindTester({ business: 'demo' });
|
||||
const caps = { reader: b.bindReader({ business: 'demo' }) };
|
||||
for (const role of ['chief', 'pm']) {
|
||||
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' });
|
||||
b.request(caps[role], { verb: 'role.claim' });
|
||||
@@ -50,7 +51,11 @@ test('row 165: instances and instances.set refuse every non-human session, the c
|
||||
assert.equal(calls.length, 0);
|
||||
b.request(human, { verb: 'instances' });
|
||||
b.request(human, { verb: 'instances.set', args: ADD });
|
||||
assert.deepEqual(calls, [['demo', 'instances', {}, 'jason'], ['demo', 'instances.set', ADD, 'jason']]);
|
||||
// Row 175 (#1673): the tester reads and writes instances through the same checks, as `tester`.
|
||||
assert.throws(() => b.request(tester, { verb: 'instances.set', args: { ...ADD, actor: 'forged' } }), /invalid-request/);
|
||||
b.request(tester, { verb: 'instances' });
|
||||
b.request(tester, { verb: 'instances.set', args: ADD });
|
||||
assert.deepEqual(calls, [['demo', 'instances', {}, 'jason'], ['demo', 'instances.set', ADD, 'jason'], ['demo', 'instances', {}, 'tester'], ['demo', 'instances.set', ADD, 'tester']]);
|
||||
});
|
||||
|
||||
test('row 165 runtime: an add and a change reach the organization file with the human as actor; each refusal keeps the bytes and logs nothing', async (t) => {
|
||||
|
||||
@@ -6,13 +6,15 @@ import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {Broker} from '../src/broker.mjs';
|
||||
import {Store} from '../src/store.mjs';
|
||||
test('limits binds actor from human proof and rejects role, reader, tester and forged actor',t=>{
|
||||
test('limits binds actor from human proof and rejects role, reader, a tester set and forged actor',t=>{
|
||||
const root=mkdtempSync(join(tmpdir(),'mosaic-limits-bus-')),store=new Store(root);t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});const calls=[];
|
||||
const b=new Broker({store,businesses:{demo:{id:'demo',human:'jason',arbiters:{technical:'coder',delivery:'coder'},vars:{'bus.tester':true},roles:{coder:{authority:{withinRole:[],crossRole:[]}}}}},organization:(...args)=>{calls.push(args);return {revision:'b'.repeat(64)};}});
|
||||
const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}),role=b.bindLaunch({business:'demo',role:'coder',run:'coder-run',harness:'pi',address:'coder-run'}),reader=b.bindReader({business:'demo'}),tester=b.bindTester({business:'demo'}),patch={revision:'a'.repeat(64),limits:{total:0}};
|
||||
for(const cap of [role,reader,tester])for(const verb of ['limits','limits.set'])assert.throws(()=>b.request(cap,{verb,args:verb==='limits'?{}:patch}),/human-required/);
|
||||
for(const cap of [role,reader])for(const verb of ['limits','limits.set'])assert.throws(()=>b.request(cap,{verb,args:verb==='limits'?{}:patch}),/human-required/);
|
||||
// Row 175 (#1673): the tester reads limits; limits.set stays the human's.
|
||||
assert.throws(()=>b.request(tester,{verb:'limits.set',args:patch}),/tester-refused/);
|
||||
assert.equal(calls.length,0);assert.throws(()=>b.request(human,{verb:'limits.set',args:{...patch,actor:'forged'}}),/invalid-request/);
|
||||
b.request(human,{verb:'limits'});b.request(human,{verb:'limits.set',args:patch});assert.deepEqual(calls,[['demo','limits',{},'jason'],['demo','limits.set',patch,'jason']]);
|
||||
b.request(human,{verb:'limits'});b.request(human,{verb:'limits.set',args:patch});b.request(tester,{verb:'limits'});assert.deepEqual(calls,[['demo','limits',{},'jason'],['demo','limits.set',patch,'jason'],['demo','limits',{},'tester']]);
|
||||
});
|
||||
test('runtime wires verified human settings to the actual shared file writer and actor receipt',async t=>{
|
||||
const {startBroker}=await import('../src/runtime.mjs');const {businessDoc,rolesCopy,systemFor,writeJson}=await import('../../business/tests/helpers.mjs');const {readFileSync}=await import('node:fs');
|
||||
|
||||
@@ -96,8 +96,10 @@ test('REQ-MSG-2: a role without the grant, or holding it cross-role, is refused
|
||||
/invalid-request/,
|
||||
);
|
||||
assert.throws(() => call('reader', 'name.set', { instance: 'dev', name: 'rex' }), /read-only/);
|
||||
assert.throws(() => call('tester', 'name.set', { instance: 'dev', name: 'rex' }), /tester-refused/);
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
|
||||
// Row 175 (#1673): the tester sets a name like the human, recorded by `tester`.
|
||||
call('tester', 'name.set', { instance: 'dev', name: 'rex' });
|
||||
assert.equal(store.get('SELECT by FROM instance_names').by, 'tester');
|
||||
});
|
||||
|
||||
test('REQ-MSG-2: a name is unique in the business and is never an instance key or a role name', (t) => {
|
||||
|
||||
@@ -30,17 +30,20 @@ function scratch(t, prefix) {
|
||||
return root;
|
||||
}
|
||||
|
||||
test('row 65: routes and routes.set refuse every non-human session, the chief of staff included, and pass the verified actor', (t) => {
|
||||
test('row 65: routes and routes.set refuse every non-human session, the chief of staff included, the tester may only set, and each passes the verified actor', (t) => {
|
||||
const store = new Store(scratch(t, 'routes-verb-'));
|
||||
t.after(() => store.close());
|
||||
const calls = [];
|
||||
const b = new Broker({ store, businesses: biz(), organization: (id, verb, args, actor) => (calls.push([id, verb, args, actor]), { ok: true }) });
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const caps = { reader: b.bindReader({ business: 'demo' }), tester: b.bindTester({ business: 'demo' }) };
|
||||
const tester = b.bindTester({ business: 'demo' });
|
||||
const caps = { reader: b.bindReader({ business: 'demo' }) };
|
||||
for (const role of ['chief', 'pm']) {
|
||||
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' });
|
||||
b.request(caps[role], { verb: 'role.claim' });
|
||||
}
|
||||
// Row 175 (#1673): the tester sets routes but never reads them (routes stays the human's).
|
||||
assert.throws(() => b.request(tester, { verb: 'routes', args: {} }), /tester-refused/);
|
||||
for (const [who, cap] of Object.entries(caps))
|
||||
for (const verb of ['routes', 'routes.set'])
|
||||
assert.throws(() => b.request(cap, { verb, args: verb === 'routes' ? {} : SET }), /human-required/, `${who} ${verb}`);
|
||||
@@ -57,7 +60,9 @@ test('row 65: routes and routes.set refuse every non-human session, the chief of
|
||||
assert.equal(calls.length, 0);
|
||||
b.request(human, { verb: 'routes' });
|
||||
b.request(human, { verb: 'routes.set', args: SET });
|
||||
assert.deepEqual(calls, [['demo', 'routes', {}, 'jason'], ['demo', 'routes.set', SET, 'jason']]);
|
||||
assert.throws(() => b.request(tester, { verb: 'routes.set', args: { ...SET, actor: 'forged' } }), /invalid-request/);
|
||||
b.request(tester, { verb: 'routes.set', args: SET });
|
||||
assert.deepEqual(calls, [['demo', 'routes', {}, 'jason'], ['demo', 'routes.set', SET, 'jason'], ['demo', 'routes.set', SET, 'tester']]);
|
||||
});
|
||||
|
||||
test('row 65: a new launch binds only on a switched-on harness with a module name, and a run an earlier broker bound binds again after its harness is switched off', (t) => {
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
// Row 175 (#1673, Q173, mos-0870 N2, mos-0872): the test system's tester. It reads what the human
|
||||
// reads, makes seven writes recorded with actor `tester`, and every other human action still
|
||||
// refuses it tester-refused.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker, TESTER_VERBS, TESTER_ORGANIZATION_VERBS } from '../src/broker.mjs';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
|
||||
|
||||
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
const READS = [['usage', {}], ['names', {}], ['limits', {}], ['plugins', {}], ['instances', {}], ['project.list', {}], ['thread.list', {}]];
|
||||
|
||||
test('row 175: TESTER_VERBS and the organization branch tester set are pinned', () => {
|
||||
assert.deepEqual([...TESTER_VERBS], [
|
||||
'message.send', 'message.receive', 'message.read', 'inbox', 'agents', 'tasks', 'trail', 'decisions', 'messages', 'replies',
|
||||
'usage', 'names', 'limits', 'plugins', 'instances', 'project.list', 'thread.list', 'name.set', 'project.create',
|
||||
]);
|
||||
assert.deepEqual([...TESTER_ORGANIZATION_VERBS], ['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set']);
|
||||
assert.ok(Object.isFrozen(TESTER_VERBS) && Object.isFrozen(TESTER_ORGANIZATION_VERBS));
|
||||
});
|
||||
|
||||
// A real organization file and writer behind the bus, as routes-verb.test.mjs's runtime test.
|
||||
async function runtime(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'tester-writes-'));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const doc = businessDoc(root, 'demo');
|
||||
doc.plugins = { ...doc.plugins, harness: ['pi'] };
|
||||
for (const r of Object.values(doc.roles)) r.vars = { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high', ...r.vars };
|
||||
const file = join(root, 'businesses/demo.json');
|
||||
writeJson(file, doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
const bus = await startBroker({
|
||||
dataRoot: join(root, 'data'),
|
||||
businesses: {
|
||||
demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, vars: { 'bus.tester': true },
|
||||
roles: { coder: { definition: 'coder', ...none }, assistant: { definition: 'assistant', ...none } },
|
||||
},
|
||||
},
|
||||
organization: { dir: root, rolesDir, system: systemFor(root) },
|
||||
});
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const tester = bus.broker.bindTester({ business: 'demo' });
|
||||
const call = (cap, verb, args = {}) => bus.broker.request(cap, { verb, args });
|
||||
const code = (cap, verb, args = {}) => { try { call(cap, verb, args); } catch (e) { return e.code; } return null; };
|
||||
const history = join(root, 'businesses/.history/demo/changes.jsonl');
|
||||
const log = () => (existsSync(history) ? readFileSync(history, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse) : []);
|
||||
const bytes = () => readFileSync(file, 'utf8');
|
||||
return { root, file, bus, human, tester, call, code, log, bytes };
|
||||
}
|
||||
|
||||
test('row 175: each of the seven reads as the tester deep-equals the human read', async (t) => {
|
||||
const r = await runtime(t);
|
||||
r.call(r.human, 'name.set', { instance: 'coder', name: 'rex' });
|
||||
const project = r.call(r.human, 'project.create', { name: 'alpha' }).id;
|
||||
r.call(r.human, 'thread.create', { project, name: 'one' });
|
||||
for (const [verb, args] of READS) assert.deepEqual(r.call(r.tester, verb, args), r.call(r.human, verb, args), verb);
|
||||
// Not empty, so the tester's thread.list owner is the business human's and names has a row.
|
||||
assert.equal(r.call(r.tester, 'thread.list').length, 1);
|
||||
assert.deepEqual(r.call(r.tester, 'thread.list', { project }).map((x) => x.name), ['one']);
|
||||
assert.equal(r.call(r.tester, 'names').length, 1);
|
||||
assert.equal(r.call(r.tester, 'project.list').length, 1);
|
||||
});
|
||||
|
||||
test('row 175: W1-W3 as the tester land with receipt actor tester; the human still logs the human', async (t) => {
|
||||
const r = await runtime(t);
|
||||
const p = r.call(r.tester, 'plugins.set', { revision: r.call(r.tester, 'plugins').revision, plugins: { 'search-source': 'searxng-url' } });
|
||||
assert.deepEqual(JSON.parse(r.bytes()).plugins['search-source'], 'searxng-url');
|
||||
assert.deepEqual([r.log().at(-1).actor, r.log().at(-1).state, r.log().at(-1).transaction], ['tester', 'committed', p.transaction]);
|
||||
const i = r.call(r.tester, 'instances.set', { revision: p.revision, instance: 'sec-1', op: 'add', definition: 'security-reviewer' });
|
||||
assert.deepEqual(JSON.parse(r.bytes()).roles['sec-1'].definition, 'security-reviewer');
|
||||
assert.deepEqual([r.log().at(-1).actor, r.log().at(-1).transaction], ['tester', i.transaction]);
|
||||
const route = { harness: 'pi', model: 'claude-opus-5-5', thinking: 'minimal', fallback: null };
|
||||
const s = r.call(r.tester, 'routes.set', { revision: i.revision, routes: { coder: route } });
|
||||
assert.deepEqual(s.instances.coder, route);
|
||||
assert.deepEqual([r.log().at(-1).actor, r.log().at(-1).transaction], ['tester', s.transaction]);
|
||||
const h = r.call(r.human, 'routes.set', { revision: s.revision, routes: { coder: { ...route, thinking: 'low' } } });
|
||||
assert.deepEqual([r.log().at(-1).actor, r.log().at(-1).transaction], ['jason', h.transaction]);
|
||||
});
|
||||
|
||||
test('row 175: W2 as the tester refuses credentials, protected definitions, human, tester and bus.tester, and changes nothing', async (t) => {
|
||||
const r = await runtime(t);
|
||||
const revision = r.call(r.tester, 'instances').revision;
|
||||
const before = r.bytes();
|
||||
const entries = r.log().length;
|
||||
const refused = [
|
||||
[{ instance: 'reviewer-2', op: 'add', definition: 'reviewer' }, 'organization-credentials-out-of-scope'],
|
||||
[{ instance: 'thread-2', op: 'add', definition: 'assistant' }, 'organization-definition-refused'],
|
||||
[{ instance: 'human', op: 'add', definition: 'merge-gate' }, 'invalid-request'],
|
||||
[{ instance: 'tester', op: 'add', definition: 'merge-gate' }, 'invalid-request'],
|
||||
// bus.tester is a business-only var (business/src/vars.mjs), so no instance carries it.
|
||||
[{ instance: 'gate-2', op: 'add', definition: 'merge-gate', vars: { 'bus.tester': true } }, 'invalid-request'],
|
||||
[{ instance: 'coder', op: 'change', vars: { 'bus.tester': true } }, 'invalid-request'],
|
||||
];
|
||||
for (const [args, want] of refused) {
|
||||
assert.equal(r.code(r.tester, 'instances.set', { revision, ...args }), want, JSON.stringify(args));
|
||||
assert.equal(r.bytes(), before, JSON.stringify(args));
|
||||
assert.equal(r.call(r.tester, 'instances').revision, revision);
|
||||
}
|
||||
assert.equal(r.log().length, entries, 'a refused write logs nothing');
|
||||
// The control: the same add with an instance var lands, so the refusal above is the var.
|
||||
r.call(r.tester, 'instances.set', { revision, instance: 'gate-2', op: 'add', definition: 'merge-gate', vars: { model: 'claude-sonnet-5-5' } });
|
||||
assert.ok(r.log().length > entries);
|
||||
assert.deepEqual([r.log().at(-1).actor, r.log().at(-1).state], ['tester', 'committed']);
|
||||
});
|
||||
|
||||
test('row 175: the human-only actions refuse a tester tester-refused, and the file and store stay as they were', async (t) => {
|
||||
const r = await runtime(t);
|
||||
const revision = r.call(r.human, 'limits').revision;
|
||||
const project = r.call(r.human, 'project.create', { name: 'alpha' }).id;
|
||||
const thread = r.call(r.human, 'thread.create', { project, name: 'one' }).id;
|
||||
const before = r.bytes();
|
||||
// The runtime keeps its store to itself, so the human's own reads stand in for it.
|
||||
const views = () => ['thread.list', 'project.list', 'names', 'messages', 'instances', 'limits', 'plugins', 'agents'].map((verb) => r.call(r.human, verb));
|
||||
const seen = views();
|
||||
const opened = r.call(r.human, 'thread.messages', { thread });
|
||||
for (const [verb, args] of [
|
||||
['limits.set', { revision, limits: { total: 0 } }],
|
||||
['organization.set', { revision, limits: { total: 0 } }],
|
||||
['routes', {}],
|
||||
['organization.revision', {}],
|
||||
// The gate runs before the argument checks: a bad key still refuses tester-refused.
|
||||
['organization.set', { revision, nope: 1 }],
|
||||
['organization.revision', { nope: 1 }],
|
||||
['decision.resolve', { id: 'd-1', choice: 'yes' }],
|
||||
['launch.revoke', {}],
|
||||
['launch.restore', {}],
|
||||
['credentials', {}],
|
||||
['assistant.stop', { thread }],
|
||||
['thread.create', { project, name: 'two' }],
|
||||
['thread.open', { thread }],
|
||||
['thread.send', { thread, body: 'hello' }],
|
||||
['thread.messages', { thread }],
|
||||
]) assert.equal(r.code(r.tester, verb, args), 'tester-refused', verb);
|
||||
// The project file verbs answer a promise.
|
||||
for (const [verb, args] of [['project.file.save', { project: 'stack', path: 'README.md', content: 'x', head: 'a'.repeat(40) }], ['project.files', { project: 'stack' }]])
|
||||
await assert.rejects(Promise.resolve().then(() => r.call(r.tester, verb, args)), { code: 'tester-refused' }, verb);
|
||||
assert.equal(r.bytes(), before);
|
||||
assert.equal(r.call(r.human, 'limits').revision, revision);
|
||||
assert.deepEqual(views(), seen);
|
||||
assert.deepEqual(r.call(r.human, 'thread.messages', { thread }), opened);
|
||||
});
|
||||
|
||||
// The plain broker, as names.test.mjs: the in-store writes W4-W6.
|
||||
function broker(t, vars = { 'bus.tester': true }) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'tester-writes-store-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const b = new Broker({
|
||||
store,
|
||||
businesses: {
|
||||
demo: {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'lead', delivery: 'lead' }, ...(vars ? { vars } : {}),
|
||||
roles: { dev: { definition: 'coder', ...none }, lead: { definition: 'pm', ...none } },
|
||||
},
|
||||
},
|
||||
});
|
||||
const call = (cap, verb, args = {}) => b.request(cap, { verb, args });
|
||||
return { b, store, call };
|
||||
}
|
||||
|
||||
test('row 175: W4 as the tester writes by tester and keeps every name check', (t) => {
|
||||
const { b, store, call } = broker(t);
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const tester = b.bindTester({ business: 'demo' });
|
||||
const events = store.get('SELECT count(*) n FROM events').n;
|
||||
assert.deepEqual(call(tester, 'name.set', { instance: 'dev', name: 'rex' }), { instance: 'dev', name: 'rex' });
|
||||
assert.deepEqual({ ...store.get('SELECT instance, name, by FROM instance_names') }, { instance: 'dev', name: 'rex', by: 'tester' });
|
||||
assert.equal(store.get('SELECT count(*) n FROM events').n, events, 'no authority consumed, no event');
|
||||
call(human, 'name.set', { instance: 'lead', name: 'patty' });
|
||||
assert.equal(store.get("SELECT by FROM instance_names WHERE instance='lead'").by, 'jason');
|
||||
assert.throws(() => call(tester, 'name.set', { instance: 'dev', name: 'patty' }), { code: 'name-taken' });
|
||||
for (const name of ['lead', 'human', 'tester']) assert.throws(() => call(tester, 'name.set', { instance: 'dev', name }), { code: 'name-is-instance' }, name);
|
||||
for (const name of ['coder', 'pm']) assert.throws(() => call(tester, 'name.set', { instance: 'dev', name }), { code: 'name-is-role' }, name);
|
||||
assert.equal(store.get('SELECT count(*) n FROM instance_names').n, 2);
|
||||
assert.deepEqual(call(tester, 'names').map(({ instance, name }) => [instance, name]), [['dev', 'rex'], ['lead', 'patty']]);
|
||||
});
|
||||
|
||||
test('row 175: W6 as the tester records created_by tester; the human records the human', (t) => {
|
||||
const { b, store, call } = broker(t);
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const tester = b.bindTester({ business: 'demo' });
|
||||
call(tester, 'project.create', { name: 'alpha' });
|
||||
call(human, 'project.create', { name: 'beta' });
|
||||
assert.deepEqual(store.all('SELECT name, created_by FROM projects ORDER BY seq').map((p) => [p.name, p.created_by]), [['alpha', 'tester'], ['beta', 'jason']]);
|
||||
assert.throws(() => call(tester, 'project.create', { name: 'alpha' }), { code: 'project-exists' });
|
||||
assert.deepEqual(call(tester, 'project.list'), call(human, 'project.list'));
|
||||
});
|
||||
|
||||
test('row 175: with bus.tester unset or not exactly true no tester binds, so no read or write runs', (t) => {
|
||||
for (const vars of [null, {}, { 'bus.tester': false }, { 'bus.tester': 'true' }, { 'bus.tester': 1 }]) {
|
||||
const { b, store } = broker(t, vars);
|
||||
assert.throws(() => b.bindTester({ business: 'demo' }), { code: 'tester-off' }, JSON.stringify(vars));
|
||||
for (const table of ['instance_names', 'projects', 'messages']) assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
|
||||
}
|
||||
});
|
||||
@@ -38,7 +38,8 @@ function setup(t, vars = { 'bus.tester': true }) {
|
||||
const call = (cap, verb, args = {}) => b.request(cap, { verb, args });
|
||||
return { b, store, pm, human, reader, call };
|
||||
}
|
||||
const say = (body = 'is the plan current?') => `[mos test] ${body}`;
|
||||
// Row 175 (#1673, mos-0872 Q1): the test system lifted the [mos test] prefix, so a body is plain text.
|
||||
const say = (body = 'is the plan current?') => body;
|
||||
|
||||
test('the tester binds only when the business sets bus.tester to exactly true', (t) => {
|
||||
for (const vars of [null, {}, { 'bus.tester': false }, { 'bus.tester': 'true' }, { 'bus.tester': 1 }]) {
|
||||
@@ -107,11 +108,9 @@ test('every verb outside the tester list refuses tester-refused and is recorded
|
||||
for (const [verb] of refused) assert.throws(() => call(tester, verb, { bogus: 1 }), { code: 'tester-refused' }, `${verb} bogus`);
|
||||
});
|
||||
|
||||
test('a tester message is from tester, starts [mos test], and writes no human.input or authority', (t) => {
|
||||
test('a tester message is from tester, needs no prefix, and writes no human.input or authority', (t) => {
|
||||
const { b, store, pm, call } = setup(t);
|
||||
const tester = b.bindTester({ business: 'demo' });
|
||||
assert.throws(() => call(tester, 'message.send', { to: 'pm', body: 'is the plan current?' }), { code: 'tester-prefix' });
|
||||
assert.throws(() => call(tester, 'message.send', { to: 'pm', body: ' [mos test] late prefix' }), { code: 'tester-prefix' });
|
||||
for (const to of ['human', 'tester', 'nobody'])
|
||||
assert.throws(() => call(tester, 'message.send', { to, body: say() }), { code: 'unknown-role' }, to);
|
||||
for (const extra of [{ corrects: 'm-1' }, { decision: 'd-1' }])
|
||||
@@ -123,6 +122,7 @@ test('a tester message is from tester, starts [mos test], and writes no human.in
|
||||
assert.equal(row.from_role, 'tester');
|
||||
assert.equal(row.from_run, 'tester-cli');
|
||||
assert.equal(row.to_role, 'pm');
|
||||
assert.equal(row.body, 'is the plan current?');
|
||||
assert.equal(store.get('SELECT count(*) n FROM events WHERE kind<>?', 'action.refused').n, events, 'no event at all');
|
||||
const got = call(pm, 'message.receive');
|
||||
assert.equal(got.length, 1);
|
||||
@@ -148,8 +148,6 @@ test('a tester message to a role no run holds refuses tester-target-not-running
|
||||
assert.throws(() => call(tester, 'message.send', { to: 'cto', body: say() }), { code: 'tester-target-not-running' });
|
||||
assert.deepEqual(counts(store), before);
|
||||
assert.equal(before.notices, 0);
|
||||
// The prefix check still comes first.
|
||||
assert.throws(() => call(tester, 'message.send', { to: 'cto', body: 'no prefix' }), { code: 'tester-prefix' });
|
||||
});
|
||||
|
||||
test('a tester message is refused while the run is only launched, and routes once it claims', (t) => {
|
||||
|
||||
+30
-16
@@ -32,16 +32,16 @@ mosaic agents [--business <id>] [--json]
|
||||
mosaic trail <task|decision> [--business <id>] [--json]
|
||||
mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>]
|
||||
mosaic follow [--business <id>]
|
||||
mosaic project create <name> [--repo <repo>] [--business <id>]
|
||||
mosaic project list [--business <id>] [--json]
|
||||
mosaic threads [--project <project>] [--business <id>] [--json]
|
||||
mosaic project create <name> [--repo <repo>] [--business <id>] [--tester]
|
||||
mosaic project list [--business <id>] [--json] [--tester]
|
||||
mosaic threads [--project <project>] [--business <id>] [--json] [--tester]
|
||||
mosaic thread create <project> <name> [--business <id>]
|
||||
mosaic thread open <thread> [--business <id>]
|
||||
mosaic thread read <thread> [--business <id>] [--json]
|
||||
mosaic talk --thread <thread> <text> [--wait <seconds> | --follow] [--business <id>]
|
||||
mosaic follow --thread <thread> [--business <id>]
|
||||
mosaic name <instance> <name> [--business <id>]
|
||||
mosaic names [--business <id>] [--json]
|
||||
mosaic name <instance> <name> [--business <id>] [--tester]
|
||||
mosaic names [--business <id>] [--json] [--tester]
|
||||
mosaic stop <run>
|
||||
mosaic launches off|on [--business <id>]
|
||||
mosaic launches list [--json]
|
||||
@@ -171,14 +171,28 @@ mosaic launches list [--json]
|
||||
(`tester-required`) or while the business var `bus.tester` isn't `true`
|
||||
(`tester-off`), each exit 3. Like the human commands, `--tester` works
|
||||
only from the tree the bus runs from ("One pinned copy" below).
|
||||
`talk --tester` text must start with
|
||||
`[mos test]`, else exit 2 before any call. When no run holds the role
|
||||
Row 175 (#1673, Q173, mos-0870 N2) adds the reads `usage`, `names`,
|
||||
`limits`, `plugins`, `threads`, `project list` and `business instances`,
|
||||
which print what the human's forms print, and the writes `plugins set`,
|
||||
`business instance add` and `set`, `name`, `project create` and
|
||||
`project init` (below), each recorded as `tester`.
|
||||
`routes.set` has no CLI form yet. `talk --tester` sends any text. When no run holds the role
|
||||
(row 170, #1667), the bus refuses the message with
|
||||
`tester-target-not-running` and stores nothing, and talk prints `pm isn't
|
||||
running, so the tester's message was refused and nothing was stored` and
|
||||
exits 3. A run launched but not yet claimed counts as not running. The
|
||||
role's reply comes back to the tester, not to the human's inbox. `decide`, `stop`, `launches` and
|
||||
`inbox --read` or `--read-all` take no `--tester` (exit 4).
|
||||
role's reply comes back to the tester, not to the human's inbox. `decide`, `stop`, `launches`,
|
||||
`inbox --read` or `--read-all`, `limits set`, `reset` and `migrate`,
|
||||
`project check`, the `thread` commands, `talk` or `follow --thread` and
|
||||
`bus start` or `stop` take no `--tester` (exit 4).
|
||||
- `project init --tester` (row 175, mos-0872 Q2) may run inside an agent.
|
||||
Before anything touches the disk it refuses a repository outside
|
||||
`<dataRoot>/projects/<business>/`, or that directory itself, since a
|
||||
repository there would hold the other projects' default ones
|
||||
(`tester-project-area`, exit 2), then
|
||||
binds the tester with one `agents` read, so `tester-off` and
|
||||
`tester-required` refuse (exit 3), then commits the layout with `tester`
|
||||
as author and committer. Tests: `tests/tester-reads.test.mjs`.
|
||||
|
||||
## The app
|
||||
|
||||
@@ -872,7 +886,7 @@ The WebUI (S5).
|
||||
|
||||
## Agent limits and models
|
||||
|
||||
`mosaic limits [--business ID] [--json]` reads the model table, each role's
|
||||
`mosaic limits [--business ID] [--json] [--tester]` reads the model table, each role's
|
||||
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;
|
||||
@@ -883,8 +897,8 @@ 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` and reads the revision itself with
|
||||
`organization_revision` (row 134). 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
|
||||
the existing human proof and broker; neither is available to a managed role.
|
||||
A tester may read (row 175), but `limits set` refuses `--tester` with exit 4. The Console Settings form uses the same write path and requires
|
||||
signed-in send mode, named Host, exact Origin and same-origin Fetch Metadata.
|
||||
|
||||
Unset limits mean unlimited; zero blocks new launches. Limits are checked
|
||||
@@ -940,9 +954,9 @@ the re-pin follows in the same window (decision 145).
|
||||
Row 165 (#1662, Q171, REQ-ROLE-3).
|
||||
|
||||
```
|
||||
mosaic business instances BUSINESS [--json]
|
||||
mosaic business instance add BUSINESS INSTANCE --definition DEF [--vars FILE] [--launch] --revision REV
|
||||
mosaic business instance set BUSINESS INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV
|
||||
mosaic business instances BUSINESS [--json] [--tester]
|
||||
mosaic business instance add BUSINESS INSTANCE --definition DEF [--vars FILE] [--launch] --revision REV [--tester]
|
||||
mosaic business instance set BUSINESS INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV [--tester]
|
||||
```
|
||||
|
||||
`scripts/mosaic` sends `business instances` and `business instance` here and
|
||||
@@ -988,7 +1002,7 @@ The Console Settings page has the same read and write (`packages/webui`).
|
||||
|
||||
## Token usage
|
||||
|
||||
`mosaic usage [--business ID] [--json]` reads the bus's accounting, without
|
||||
`mosaic usage [--business ID] [--json] [--tester]` reads the bus's accounting, without
|
||||
opening its database or transcripts. Text lists the total and run, instance,
|
||||
provider/model, provider and account groups, with input/output/cacheWrite/
|
||||
cacheRead and unknown turns beside every total. JSON retains the full view.
|
||||
|
||||
@@ -52,8 +52,8 @@ export const USAGE = `usage:
|
||||
mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>] [--tester]
|
||||
mosaic follow [--business <id>] [--tester]
|
||||
${THREAD_TALK_USAGE}
|
||||
mosaic name <instance> <name> [--business <id>]
|
||||
mosaic names [--business <id>] [--json]
|
||||
mosaic name <instance> <name> [--business <id>] [--tester]
|
||||
mosaic names [--business <id>] [--json] [--tester]
|
||||
mosaic stop <run>
|
||||
mosaic launches off|on [--business <id>]
|
||||
mosaic launches list [--json]
|
||||
@@ -307,9 +307,10 @@ async function human(verb, rest, io, deps) {
|
||||
}
|
||||
|
||||
// Row 141 (#1635, REQ-MSG-2): `mosaic name` sets the name `talk` and message.send accept for an
|
||||
// instance; `mosaic names` lists the current ones. Both are the human's.
|
||||
// instance; `mosaic names` lists the current ones. Both are the human's, and since row 175
|
||||
// (#1673, Q173) the tester's too.
|
||||
async function names(verb, rest, io, deps) {
|
||||
const parsed = parse(rest, { flags: verb === "names" ? ["--json"] : [], values: ["--business"] });
|
||||
const parsed = parse(rest, { flags: verb === "names" ? ["--json", "--tester"] : ["--tester"], values: ["--business"] });
|
||||
if (parsed.positional.length !== (verb === "name" ? 2 : 0)) throw usage(verb === "name" ? "name takes an instance and a name" : "names takes no arguments");
|
||||
const call = callFor(parsed, io, deps);
|
||||
if (verb === "name") {
|
||||
@@ -322,7 +323,6 @@ async function names(verb, rest, io, deps) {
|
||||
}
|
||||
|
||||
const TALK_WAIT_S = 900;
|
||||
const TESTER_PREFIX = "[mos test]";
|
||||
const TALK_POLL_MS = 2000;
|
||||
|
||||
function formatMessage(m) {
|
||||
@@ -365,8 +365,6 @@ async function talk(rest, io, deps, receiveOnly = false) {
|
||||
if (!Number.isInteger(wait) || wait < 0) throw usage("--wait takes whole seconds, 0 or more");
|
||||
let [to, body] = parsed.positional;
|
||||
if (threadRef !== undefined) [to, body] = [threadRef, to];
|
||||
// The broker refuses it too (tester-prefix); this is the clear early message.
|
||||
if (!receiveOnly && parsed.flags.has("--tester") && !body.startsWith(TESTER_PREFIX)) throw new CliError(`a tester message starts with "${TESTER_PREFIX}"`, 2);
|
||||
const call = threadRef === undefined ? callFor(parsed, io, deps) : channelCallFor(parsed, io, deps);
|
||||
let sent;
|
||||
let thread = null;
|
||||
@@ -568,7 +566,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
if (argv.length === 0) return app(io, deps);
|
||||
const [verb, ...rest] = argv;
|
||||
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
|
||||
if (verb === "project") return ["create", "list"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps);
|
||||
if (verb === "project") return ["create", "list"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps));
|
||||
if (verb === "threads") return threadsCommand(rest, io, (p) => channelCallFor(p, io, deps));
|
||||
if (verb === "thread") return threadCommand(rest, io, (p) => channelCallFor(p, io, deps), formatMessage);
|
||||
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// `mosaic business instances` and `mosaic business instance add|set` (row 165, #1662, Q171,
|
||||
// REQ-ROLE-3): the human reads, adds and changes role instances through the broker's human-only
|
||||
// instances and instances.set verbs. The broker refuses an agent, a reader and a tester; the
|
||||
// business package's instance writer refuses a stale revision and every document
|
||||
// `mosaic business validate` would refuse, and logs the change with its actor.
|
||||
// REQ-ROLE-3): the human reads, adds and changes role instances through the broker's instances
|
||||
// and instances.set verbs, and with --tester the tester does (row 175, #1673, Q173). The broker
|
||||
// refuses an agent and a reader; the business package's instance writer refuses a stale revision
|
||||
// and every document `mosaic business validate` would refuse, and logs the change with its actor.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
|
||||
export const INSTANCES_USAGE = "mosaic business instances BUSINESS [--json] | " +
|
||||
"business instance add BUSINESS INSTANCE --definition DEF [--vars FILE] [--launch] --revision REV | " +
|
||||
"business instance set BUSINESS INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV";
|
||||
export const INSTANCES_USAGE = "mosaic business instances BUSINESS [--json] [--tester] | " +
|
||||
"business instance add BUSINESS INSTANCE --definition DEF [--vars FILE] [--launch] --revision REV [--tester] | " +
|
||||
"business instance set BUSINESS INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV [--tester]";
|
||||
|
||||
// The bus answers a refusal with its code alone, so the reason a human reads is here.
|
||||
export const REASONS = {
|
||||
@@ -32,7 +32,7 @@ function parse(args) {
|
||||
const out = { positional: [], flags: new Set(), values: {} };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (["--json", "--launch", "--no-launch"].includes(arg)) {
|
||||
if (["--json", "--launch", "--no-launch", "--tester"].includes(arg)) {
|
||||
if (out.flags.has(arg)) throw new CliError(INSTANCES_USAGE, 4);
|
||||
out.flags.add(arg);
|
||||
} else if (["--definition", "--vars", "--revision"].includes(arg)) {
|
||||
|
||||
@@ -7,7 +7,7 @@ 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] | " +
|
||||
export const LIMITS_USAGE = "mosaic limits [--business ID] [--json] [--tester] | set --file PATCH [--business ID] | " +
|
||||
"reset PATH... [--business ID] | migrate --file ORGANIZATION --models MAPPING [--roles-dir DIR]; " +
|
||||
"PATCH keys: revision, limits, models, instances, reset, assistants";
|
||||
|
||||
@@ -35,7 +35,7 @@ export function parseLimits(args) {
|
||||
const out = { positional: [], flags: new Set(), values: {} };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === "--json") out.flags.add(arg);
|
||||
if (arg === "--json" || arg === "--tester") out.flags.add(arg);
|
||||
else if (["--file", "--models", "--roles-dir", "--business"].includes(arg)) {
|
||||
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(LIMITS_USAGE, 4);
|
||||
out.values[arg] = args[++i];
|
||||
@@ -50,6 +50,8 @@ export async function limitsCommand(args, io, callFor) {
|
||||
const verb = parsed.positional[0] ?? "view";
|
||||
const values = parsed.values;
|
||||
if (parsed.positional.length > 1 && verb !== "reset") throw new CliError(LIMITS_USAGE, 4);
|
||||
// Row 175 (#1673, Q173): the tester reads limits; set, reset and migrate stay the human's.
|
||||
if (parsed.flags.has("--tester") && verb !== "view") throw new CliError(`--tester takes only the read\n${LIMITS_USAGE}`, 4);
|
||||
try {
|
||||
if (verb === "migrate") {
|
||||
if (!values["--file"] || !values["--models"] || values["--business"]) {
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
// `mosaic plugins` (vikunja:19/305, REQ-PLUG-1): read and switch the organization file's plug-in
|
||||
// modules through the broker's human-only plugins and plugins.set verbs. The broker refuses an
|
||||
// agent, a reader and a tester, a stale revision, a bad shape and a module that doesn't exist, and
|
||||
// the change is logged with its actor in the organization's changes.jsonl.
|
||||
// modules through the broker's plugins and plugins.set verbs, the human's and, with --tester, the
|
||||
// tester's (row 175, #1673, Q173). The broker refuses an agent and a reader, a stale revision, a bad
|
||||
// shape and a module that doesn't exist, and the change is logged with its actor (the human or
|
||||
// `tester`) in the organization's changes.jsonl.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
|
||||
export const PLUGINS_USAGE = "mosaic plugins [--business ID] [--json] | set --file PATCH [--business ID]";
|
||||
export const PLUGINS_USAGE = "mosaic plugins [--business ID] [--json] [--tester] | set --file PATCH [--business ID] [--tester]";
|
||||
|
||||
function parse(args) {
|
||||
const out = { positional: [], flags: new Set(), values: {} };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === "--json") out.flags.add(arg);
|
||||
if (arg === "--json" || arg === "--tester") out.flags.add(arg);
|
||||
else if (["--file", "--business"].includes(arg)) {
|
||||
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(PLUGINS_USAGE, 4);
|
||||
out.values[arg] = args[++i];
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import { configDir, loadBusiness } from '../../business/src/index.mjs';
|
||||
import { checkProject, initProject, ProjectError } from '../../project/src/index.mjs';
|
||||
import { checkProject, initProject, ProjectError, testerProjectArea } from '../../project/src/index.mjs';
|
||||
import { loadSystem, rolesDir } from './config.mjs';
|
||||
import { CliError } from './errors.mjs';
|
||||
import { shortId } from './format.mjs';
|
||||
import { parseArgs } from './thread.mjs';
|
||||
import { refuseInsideAgent } from './transport.mjs';
|
||||
|
||||
export const PROJECT_USAGE = 'mosaic project init|check <business> <project>';
|
||||
export const PROJECT_USAGE = 'mosaic project init <business> <project> [--tester] | check <business> <project>';
|
||||
// Row 128 (#1622): create and list work on the bus record (project.create, project.list); init
|
||||
// and check above work on the repository on disk and never contact the bus.
|
||||
// and check above work on the repository on disk and never contact the bus, except that
|
||||
// init --tester (row 175, #1673) binds the tester once before it writes.
|
||||
export const PROJECT_BUS_USAGE = [
|
||||
'mosaic project create <name> [--repo <repo>] [--business <id>]',
|
||||
'mosaic project list [--business <id>] [--json]',
|
||||
'mosaic project create <name> [--repo <repo>] [--business <id>] [--tester]',
|
||||
'mosaic project list [--business <id>] [--json] [--tester]',
|
||||
'A thread project named for a business repository must carry that repo: --repo <name>.',
|
||||
].join('\n ');
|
||||
|
||||
export async function projectBusCommand(args, io, callFor) {
|
||||
const [sub, ...rest] = args;
|
||||
const parsed = parseArgs(rest, PROJECT_BUS_USAGE, sub === 'create'
|
||||
? { values: ['--repo', '--business'] }
|
||||
: { flags: ['--json'], values: ['--business'] });
|
||||
? { flags: ['--tester'], values: ['--repo', '--business'] }
|
||||
: { flags: ['--json', '--tester'], values: ['--business'] });
|
||||
if (parsed.positional.length !== (sub === 'create' ? 1 : 0))
|
||||
throw new CliError(`project ${sub} takes ${sub === 'create' ? 'one name' : 'no arguments'}\n${PROJECT_BUS_USAGE}`, 4);
|
||||
const call = callFor(parsed);
|
||||
@@ -40,17 +41,27 @@ export async function projectBusCommand(args, io, callFor) {
|
||||
io.stdout.write(projects.length === 0 ? 'no projects\n' : projects.map(p => `${p.name} ${p.repo ?? '-'} ${shortId(p.id)}\n`).join(''));
|
||||
}
|
||||
|
||||
export async function projectCommand(args, io, deps = {}) {
|
||||
if (args.length !== 3 || !['init', 'check'].includes(args[0]) || args.some(arg => arg.startsWith('--')))
|
||||
// Row 175 (#1673, Q173, mos-0872 Q2): `init --tester` may run inside an agent. Before anything
|
||||
// touches the disk it refuses a repository outside <dataRoot>/projects/<business>/
|
||||
// (tester-project-area, exit 2), then binds the tester with one agents read, so tester-off and
|
||||
// tester-required refuse (exit 3), then commits the layout as `tester`.
|
||||
export async function projectCommand(args, io, deps = {}, callFor) {
|
||||
const tester = args[0] === 'init' && args.filter(arg => arg === '--tester').length === 1;
|
||||
const words = tester ? args.filter(arg => arg !== '--tester') : args;
|
||||
if (words.length !== 3 || !['init', 'check'].includes(words[0]) || words.some(arg => arg.startsWith('--')))
|
||||
throw new CliError(PROJECT_USAGE, 4);
|
||||
const [verb, id, project] = args;
|
||||
if (verb === 'init') refuseInsideAgent(io.env);
|
||||
const [verb, id, project] = words;
|
||||
if (verb === 'init' && !tester) refuseInsideAgent(io.env);
|
||||
try {
|
||||
const system = deps.system ?? loadSystem({ env: io.env });
|
||||
const business = loadBusiness(id, { dir: configDir(io.env), rolesDir: rolesDir(io.env) });
|
||||
const context = { business, project, dataRoot: system.dataRoot };
|
||||
if (tester) {
|
||||
testerProjectArea(context);
|
||||
await callFor({ positional: [], flags: new Set(['--tester']), values: { '--business': id } })('agents', {});
|
||||
}
|
||||
if (verb === 'init') {
|
||||
const result = await initProject(context);
|
||||
const result = await initProject(context, tester ? { author: 'tester' } : {});
|
||||
io.stdout.write(`initialized ${result.repo}\ncommit ${result.commit}\n`);
|
||||
} else {
|
||||
const result = await checkProject(context);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { shortId } from "./format.mjs";
|
||||
import { configDir, loadBusiness } from "../../business/src/index.mjs";
|
||||
import { rolesDir } from "./config.mjs";
|
||||
|
||||
export const THREADS_USAGE = "mosaic threads [--project <project>] [--business <id>] [--json]";
|
||||
export const THREADS_USAGE = "mosaic threads [--project <project>] [--business <id>] [--json] [--tester]";
|
||||
export const THREAD_USAGE = [
|
||||
"mosaic thread create <project> <name> [--business <id>]",
|
||||
"mosaic thread open <thread> [--business <id>]",
|
||||
@@ -107,8 +107,9 @@ export function threadMembers(call, thread) {
|
||||
|
||||
const json = (io, value) => io.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
||||
|
||||
// Row 175 (#1673, Q173): with --tester the tester lists the business human's threads.
|
||||
export async function threadsCommand(args, io, callFor) {
|
||||
const parsed = parseArgs(args, THREADS_USAGE, { flags: ["--json"], values: ["--business", "--project"] });
|
||||
const parsed = parseArgs(args, THREADS_USAGE, { flags: ["--json", "--tester"], values: ["--business", "--project"] });
|
||||
if (parsed.positional.length !== 0) throw new CliError(`threads takes no arguments\n${THREADS_USAGE}`, 4);
|
||||
const call = callFor(parsed);
|
||||
const project = parsed.values["--project"] === undefined ? null : await resolveProject(call, parsed.values["--project"], { business: call.business, env: io.env });
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { CliError } from './errors.mjs';
|
||||
export const USAGE_USAGE = 'mosaic usage [--business ID] [--json]';
|
||||
export const USAGE_USAGE = 'mosaic usage [--business ID] [--json] [--tester]';
|
||||
export async function usageCommand(args, io, callFor) {
|
||||
const parsed = { flags: new Set(), values: {} };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === '--json' && !parsed.flags.has(arg)) parsed.flags.add(arg);
|
||||
if ((arg === '--json' || arg === '--tester') && !parsed.flags.has(arg)) parsed.flags.add(arg);
|
||||
else if (arg === '--business' && args[i + 1] && !args[i + 1].startsWith('--') && !parsed.values[arg]) parsed.values[arg] = args[++i];
|
||||
else throw new CliError(USAGE_USAGE, 4);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,6 @@ test("name and names refuse the wrong number of arguments with the usage", async
|
||||
assert.ok(r.error.endsWith(USAGE), r.error);
|
||||
}
|
||||
assert.equal(s.calls.length, 0);
|
||||
assert.match(USAGE, /^ {2}mosaic name <instance> <name> \[--business <id>\]$/m);
|
||||
assert.match(USAGE, /^ {2}mosaic names \[--business <id>\] \[--json\]$/m);
|
||||
assert.match(USAGE, /^ {2}mosaic name <instance> <name> \[--business <id>\] \[--tester\]$/m);
|
||||
assert.match(USAGE, /^ {2}mosaic names \[--business <id>\] \[--json\] \[--tester\]$/m);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
// Row 175 (#1673, Q173, mos-0870 N2, mos-0872): the CLI's tester forms. The seven reads print what
|
||||
// the human's forms print, every write form outside W1-W7 refuses --tester before any call, and
|
||||
// `project init --tester` checks the project area, binds the tester, then commits as `tester`.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { main } from "../src/cli.mjs";
|
||||
import { BusError } from "../../bus/src/broker.mjs";
|
||||
import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { systemFor, writeJson } from "../../business/tests/helpers.mjs";
|
||||
import { fixture, humanEnv, io, tmp } from "./helpers.mjs";
|
||||
|
||||
const none = { authority: { withinRole: ["message.send"], crossRole: [] } };
|
||||
const inAgent = (env) => io({ ...env, CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" });
|
||||
const bus = (vars = { "bus.tester": true }) => ({
|
||||
acme: {
|
||||
id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, ...(vars ? { vars } : {}),
|
||||
roles: { coder: { definition: "coder", ...none }, assistant: { definition: "assistant", ...none } },
|
||||
},
|
||||
});
|
||||
|
||||
// The runtime in this process behind stand-in transports, as instances.test.mjs.
|
||||
async function setup(t) {
|
||||
const root = tmp(t, "mosaic-cli-tester-reads-");
|
||||
const f = fixture(root, "acme");
|
||||
const rt = await startBroker({ dataRoot: f.dataRoot, businesses: bus(), organization: { dir: join(root, "config"), rolesDir: f.roles, system: systemFor(root) } });
|
||||
t.after(() => rt.close());
|
||||
const human = rt.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true });
|
||||
const tester = rt.broker.bindTester({ business: "acme" });
|
||||
const calls = { human: [], tester: [] };
|
||||
const via = (cap, list) => (business) => async (verb, args = {}) => {
|
||||
list.push(verb);
|
||||
try {
|
||||
return structuredClone(rt.broker.request(cap, { verb, args }));
|
||||
} catch (e) {
|
||||
if (!(e instanceof BusError)) throw e;
|
||||
const error = new Error(e.code);
|
||||
error.code = e.code;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const deps = { system: { dataRoot: f.dataRoot }, transport: via(human, calls.human), testerTransport: via(tester, calls.tester), talkPollMs: 5 };
|
||||
const call = (verb, args = {}) => rt.broker.request(human, { verb, args });
|
||||
return { root, f, rt, human, tester, calls, deps, call };
|
||||
}
|
||||
|
||||
const READS = [
|
||||
["usage"],
|
||||
["names"],
|
||||
["limits"],
|
||||
["plugins"],
|
||||
["threads"],
|
||||
["project", "list"],
|
||||
];
|
||||
|
||||
test("row 175: each read with --tester inside an agent prints the human form's output, and refuses without --tester", async (t) => {
|
||||
const s = await setup(t);
|
||||
s.call("plugins.set", { revision: s.call("plugins").revision, plugins: { channel: ["cli", "webui"] } });
|
||||
s.call("name.set", { instance: "coder", name: "rex" });
|
||||
const project = s.call("project.create", { name: "alpha" }).id;
|
||||
s.call("thread.create", { project, name: "one" });
|
||||
const forms = [...READS.map((argv) => [...argv, "--business", "acme"]), ["business", "instances", "acme"]];
|
||||
for (const argv of forms) {
|
||||
const name = argv.slice(0, 2).join(" ");
|
||||
const h = io(s.f.env);
|
||||
await main(argv, h, s.deps);
|
||||
const humanCalls = s.calls.human.length;
|
||||
const testerCalls = s.calls.tester.length;
|
||||
const x = inAgent(s.f.env);
|
||||
await main([...argv, "--tester"], x, s.deps);
|
||||
assert.equal(x.out.text, h.out.text, name);
|
||||
assert.ok(x.out.text.length > 0, name);
|
||||
assert.equal(s.calls.human.length, humanCalls, `${name}: the tester form made no human call`);
|
||||
assert.ok(s.calls.tester.length > testerCalls, `${name}: the tester form called the tester transport`);
|
||||
await assert.rejects(main(argv, inAgent(s.f.env), s.deps), (e) => e.exitCode === 3, `${name} without --tester`);
|
||||
assert.equal(s.calls.human.length, humanCalls, `${name}: the refusal came before any call`);
|
||||
}
|
||||
// Not empty: the names, the thread and the project the human made are in the tester's view.
|
||||
const x = inAgent(s.f.env);
|
||||
await main(["threads", "--business", "acme", "--json", "--tester"], x, s.deps);
|
||||
assert.deepEqual(JSON.parse(x.out.text).map((r) => r.name), ["one"]);
|
||||
});
|
||||
|
||||
test("row 175: --tester on limits set, reset and migrate and every write form outside W1-W7 exits 4 before any call", async (t) => {
|
||||
const s = await setup(t);
|
||||
for (const argv of [
|
||||
["limits", "set", "--file", "patch.json", "--business", "acme"],
|
||||
["limits", "reset", "total", "--business", "acme"],
|
||||
["limits", "migrate", "--file", "org.json", "--models", "map.json"],
|
||||
["decide", "d1", "yes", "--business", "acme"],
|
||||
["inbox", "--read", "abcdefgh", "--business", "acme"],
|
||||
["inbox", "--read-all", "--business", "acme"],
|
||||
["thread", "create", "alpha", "one", "--business", "acme"],
|
||||
["thread", "open", "t-1", "--business", "acme"],
|
||||
["thread", "read", "t-1", "--business", "acme"],
|
||||
["talk", "--thread", "t-1", "hello", "--business", "acme"],
|
||||
["follow", "--thread", "t-1", "--business", "acme"],
|
||||
["stop", "run-1"],
|
||||
["launches", "off", "--business", "acme"],
|
||||
["launches", "on", "--business", "acme"],
|
||||
["launches", "list"],
|
||||
["bus", "start", "acme"],
|
||||
["bus", "stop"],
|
||||
["project", "check", "acme", "stack"],
|
||||
["project", "init", "acme", "stack", "--tester"],
|
||||
]) {
|
||||
const x = inAgent(s.f.env);
|
||||
await assert.rejects(main([...argv, "--tester"], x, s.deps), (e) => e.exitCode === 4, argv.join(" "));
|
||||
}
|
||||
assert.deepEqual(s.calls, { human: [], tester: [] });
|
||||
assert.equal(existsSync(join(s.f.dataRoot, "projects")), false);
|
||||
});
|
||||
|
||||
// W7 through the real tester transport: the CLI runs as its own process, so the tester-cli.mjs it
|
||||
// spawns can reach the runtime in this one.
|
||||
const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url));
|
||||
function mosaic(args, env) {
|
||||
const clean = Object.fromEntries(Object.entries(env).filter(([k]) => !/^MOSAIC_BUS_(TESTER|CLI)_NONCE$/.test(k)));
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(MOSAIC, args, { env: clean, timeout: 60000 });
|
||||
let out = "";
|
||||
let err = "";
|
||||
child.stdout.on("data", (d) => (out += d));
|
||||
child.stderr.on("data", (d) => (err += d));
|
||||
child.on("error", reject);
|
||||
child.on("close", (status) => resolve({ status, out, err }));
|
||||
});
|
||||
}
|
||||
// sage-1081: these six fell through to the seat CLI, which refused with its own usage, so W1
|
||||
// (`plugins set`) and W4 (`name`) failed through the wrapper. Each now reaches packages/cli.
|
||||
test("row 175: scripts/mosaic sends plugins, name, names, follow, threads and thread to packages/cli, not the seat CLI", (t) => {
|
||||
const root = tmp(t, "mosaic-cli-route-");
|
||||
const env = { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: join(root, "none.json") };
|
||||
for (const [args, message] of [
|
||||
[["plugins", "x"], /^mosaic: mosaic plugins \[--business ID\]/],
|
||||
[["name"], /^mosaic: name takes an instance and a name\n/],
|
||||
[["names", "x"], /^mosaic: names takes no arguments\n/],
|
||||
[["follow", "x"], /^mosaic: follow takes no arguments\n/],
|
||||
[["threads", "x"], /^mosaic: threads takes no arguments\n/],
|
||||
[["thread"], /^mosaic: usage:\n mosaic thread create /],
|
||||
]) {
|
||||
const r = spawnSync(MOSAIC, args, { env, encoding: "utf8" });
|
||||
assert.equal(r.status, 4, args.join(" "));
|
||||
assert.match(r.stderr, message, args.join(" "));
|
||||
assert.doesNotMatch(r.stderr, /mosaic launch </, args.join(" "));
|
||||
}
|
||||
});
|
||||
|
||||
const listing = (dir) => readdirSync(dir, { recursive: true }).map(String).sort();
|
||||
async function w7(t, vars, edit) {
|
||||
const root = tmp(t, "mosaic-cli-tester-init-");
|
||||
const f = fixture(root, "acme", edit);
|
||||
const rt = await startBroker({ dataRoot: f.dataRoot, businesses: bus(vars) });
|
||||
t.after(() => rt.close());
|
||||
return { root, f, agent: { ...f.env, CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" } };
|
||||
}
|
||||
|
||||
test("row 175 W7: project init --tester makes the repository under the project area, committed by tester", async (t) => {
|
||||
const { f, agent } = await w7(t);
|
||||
const r = await mosaic(["project", "init", "acme", "stack", "--tester"], agent);
|
||||
assert.equal(r.status, 0, r.err);
|
||||
const repo = join(f.dataRoot, "projects", "acme", "stack");
|
||||
assert.match(r.out, new RegExp(`^initialized ${repo}\ncommit [0-9a-f]{40}\n$`));
|
||||
const log = spawnSync("git", ["-C", repo, "log", "--format=%an|%cn"], { encoding: "utf8" });
|
||||
assert.equal(log.stdout, "tester|tester\n");
|
||||
// Without --tester the same init inside an agent refuses, as before.
|
||||
const again = await mosaic(["project", "init", "acme", "stack"], agent);
|
||||
assert.equal(again.status, 3);
|
||||
assert.match(again.err, /outside any agent run/);
|
||||
});
|
||||
|
||||
test("row 175 W7: a repo outside the project area refuses tester-project-area before any bus call, and nothing changes", async (t) => {
|
||||
// The switch is off: a bind would refuse tester-off (exit 3), so exit 2 shows the area check ran first.
|
||||
const { f, agent } = await w7(t, null, (doc) => {
|
||||
doc.projects.stack.repo = join(doc.projects.stack.root, "..", "records");
|
||||
return doc;
|
||||
});
|
||||
const records = f.doc.projects.stack.repo;
|
||||
const data = listing(f.dataRoot);
|
||||
const r = await mosaic(["project", "init", "acme", "stack", "--tester"], agent);
|
||||
assert.equal(r.status, 2, r.err);
|
||||
assert.match(r.err, /tester-project-area/);
|
||||
assert.equal(existsSync(records), false);
|
||||
assert.deepEqual(listing(f.dataRoot), data);
|
||||
});
|
||||
|
||||
test("row 175 W7: the tester bind refuses tester-off and tester-required before any directory", async (t) => {
|
||||
const off = await w7(t, null);
|
||||
const data = listing(off.f.dataRoot);
|
||||
const r = await mosaic(["project", "init", "acme", "stack", "--tester"], off.agent);
|
||||
assert.equal(r.status, 3, r.err);
|
||||
assert.match(r.err, /tester-off/);
|
||||
assert.deepEqual(listing(off.f.dataRoot), data);
|
||||
assert.equal(existsSync(join(off.f.dataRoot, "projects")), false);
|
||||
const on = await w7(t);
|
||||
const before = listing(on.f.dataRoot);
|
||||
for (const marker of ["MOSAIC_RUN_ID", "MOSAIC_BUS_CAP", "MOSAIC_AGENT_RUN"]) {
|
||||
const under = await mosaic(["project", "init", "acme", "stack", "--tester"], { ...on.agent, [marker]: "x" });
|
||||
assert.equal(under.status, 3, `${marker}: ${under.err}`);
|
||||
assert.match(under.err, /tester-required/, marker);
|
||||
}
|
||||
assert.deepEqual(listing(on.f.dataRoot), before);
|
||||
assert.equal(existsSync(join(on.f.dataRoot, "projects")), false);
|
||||
});
|
||||
@@ -38,7 +38,7 @@ function setup(t, extra = {}) {
|
||||
// The tester runs inside an agent: a T3 seat or Mos.
|
||||
const inAgent = () => io({ ...humanEnv(), CLAUDECODE: "1", CLAUDE_CODE_ENTRYPOINT: "sdk-ts" });
|
||||
|
||||
test("talk --tester sends a [mos test] message as tester from inside an agent and prints the role's reply", async (t) => {
|
||||
test("talk --tester sends a message as tester from inside an agent and prints the role's reply", async (t) => {
|
||||
const s = setup(t);
|
||||
const x = inAgent();
|
||||
const deps = {
|
||||
@@ -57,7 +57,7 @@ test("talk --tester sends a [mos test] message as tester from inside an agent an
|
||||
};
|
||||
},
|
||||
};
|
||||
await main(["talk", "coder", "[mos test] is the plan current?", "--tester", "--business", "demo"], x, deps);
|
||||
await main(["talk", "coder", "is the plan current?", "--tester", "--business", "demo"], x, deps);
|
||||
assert.match(x.out.text, /^sent [0-9a-f-]{8} to coder\n/);
|
||||
assert.match(x.out.text, /coder \(INFO, re [0-9a-f-]{8}\) [0-9a-f-]{8}:\nthe plan is current\n/);
|
||||
assert.deepEqual(s.calls.human, []);
|
||||
@@ -69,11 +69,13 @@ test("talk --tester sends a [mos test] message as tester from inside an agent an
|
||||
assert.equal(s.store.get("SELECT count(*) n FROM events WHERE kind='human.input'").n, 0);
|
||||
});
|
||||
|
||||
test("talk --tester refuses text without the [mos test] prefix before any call", async (t) => {
|
||||
// Row 175 (#1673, mos-0872 Q1): the test system needs no "[mos test]" prefix; the body goes as typed.
|
||||
test("talk --tester sends text with no prefix, and follow --tester reads", async (t) => {
|
||||
const s = setup(t);
|
||||
await assert.rejects(main(["talk", "coder", "is the plan current?", "--tester", "--business", "demo"], inAgent(), s.deps), (e) => e.exitCode === 2 && /starts with "\[mos test\]"/.test(e.message));
|
||||
assert.deepEqual(s.calls.tester, []);
|
||||
// follow sends nothing, so it takes no prefix.
|
||||
await main(["talk", "coder", "is the plan current?", "--tester", "--wait", "0", "--business", "demo"], inAgent(), s.deps);
|
||||
assert.deepEqual(s.calls.tester.map((c) => c.verb), ["message.send"]);
|
||||
assert.equal(s.store.get("SELECT body FROM messages WHERE from_role='tester'").body, "is the plan current?");
|
||||
s.calls.tester.length = 0;
|
||||
const controller = new AbortController();
|
||||
const deps = { ...s.deps, signal: controller.signal, testerTransport: (b) => async (verb, args) => (controller.abort(), s.deps.testerTransport(b)(verb, args)) };
|
||||
await main(["follow", "--tester", "--business", "demo"], inAgent(), deps);
|
||||
@@ -116,7 +118,6 @@ test("the tester transport runs tester-cli.mjs, and the tester's refusals exit 3
|
||||
assert.deepEqual(seen.argv, [TESTER_CLI, "/run/fake.sock"]);
|
||||
assert.deepEqual(JSON.parse(seen.input), { business: "acme", verb: "agents", args: {} });
|
||||
for (const code of ["tester-required", "tester-off", "tester-refused"]) assert.equal(busExit(code), 3, code);
|
||||
assert.equal(busExit("tester-prefix"), 2);
|
||||
});
|
||||
|
||||
test("with no stand-in transport, --tester runs tester-cli.mjs on the data root's socket", async (t) => {
|
||||
|
||||
@@ -53,8 +53,9 @@ calls the kind.
|
||||
`mosaic plugins set --file PATCH` (`{ "revision": ..., "plugins": {...} }`)
|
||||
changes it, one kind at a time: the patch names the kinds it changes, a kind
|
||||
with `null` is removed, and a kind it doesn't name keeps its setting. The
|
||||
change goes through the broker's human-only `plugins` and `plugins.set`
|
||||
verbs. The setter refuses a bad shape, a name with no module in this tree
|
||||
change goes through the broker's `plugins` and `plugins.set` verbs, the
|
||||
human's and, since row 175 (#1673), the tester's (`--tester`, actor
|
||||
`tester`). The setter refuses a bad shape, a name with no module in this tree
|
||||
(`unknown-plugin`), an unknown kind, an empty patch and a stale revision, and logs actor and old and new
|
||||
names in `changes.jsonl`. A running broker reads the setting at bus start
|
||||
(REQ-WEB-3's reload will change that), so restart the bus after a switch.
|
||||
|
||||
@@ -4,10 +4,10 @@ import {
|
||||
} from 'node:fs';
|
||||
import { dirname, join, normalize } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { ProjectError, checkedId, documentPath, noLinks, projectRepo } from './paths.mjs';
|
||||
import { ProjectError, checkedId, documentPath, noLinks, projectRepo, testerProjectArea } from './paths.mjs';
|
||||
import { git, identity, inspectGit, requireClean } from './git.mjs';
|
||||
import { validateRecord } from './validate.mjs';
|
||||
export { ProjectError, projectRepo, validateRecord };
|
||||
export { ProjectError, projectRepo, testerProjectArea, validateRecord };
|
||||
|
||||
const DIRECTORIES = ['spec', 'docs', 'requirements', 'decisions', 'templates'];
|
||||
const TEMPLATES = ['project.json', 'requirement.json', 'decision.json', 'document.md', 'north-star.md'];
|
||||
@@ -92,8 +92,10 @@ export async function checkProject(context) {
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function initProject(context) {
|
||||
const repo = projectRepo(context), human = checkedId(context.business.human);
|
||||
// Row 175 (#1673): `author` names the layout commit's author and committer; without it the
|
||||
// business human commits, as before. The tester's init passes "tester".
|
||||
export async function initProject(context, { author } = {}) {
|
||||
const repo = projectRepo(context), committer = checkedId(author ?? context.business.human);
|
||||
if (existsSync(repo) && readdirSync(repo).length) throw new ProjectError('project-not-empty', 'refusing existing non-empty directory');
|
||||
const parents = [];
|
||||
for (let path = repo; !existsSync(path); path = dirname(path)) parents.unshift(path);
|
||||
@@ -130,7 +132,7 @@ export async function initProject(context) {
|
||||
const result = await checkLayout(context);
|
||||
if (!result.ok) throw new ProjectError('project-invalid', result.problems.join('\n'));
|
||||
await git(repo, ['add', '--', 'project.json', ...DIRECTORIES]);
|
||||
await git(repo, ['commit', '-m', 'Initialize project layout v1'], identity(human));
|
||||
await git(repo, ['commit', '-m', 'Initialize project layout v1'], identity(committer));
|
||||
return { repo, commit: await git(repo, ['rev-parse', 'HEAD']) };
|
||||
} catch (error) {
|
||||
for (const entry of created.reverse()) {
|
||||
|
||||
@@ -27,6 +27,10 @@ export function noLinks(path) {
|
||||
if (part === dirname(part)) break;
|
||||
}
|
||||
}
|
||||
const contains = (base, child) => {
|
||||
const rel = relative(base, child);
|
||||
return rel === '' || (rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel));
|
||||
};
|
||||
export function projectRepo({ business, project, dataRoot }) {
|
||||
checkedId(business?.id); checkedId(project);
|
||||
if (!Object.hasOwn(business?.projects ?? {}, project))
|
||||
@@ -34,10 +38,6 @@ export function projectRepo({ business, project, dataRoot }) {
|
||||
absolute(dataRoot);
|
||||
const entry = business.projects[project];
|
||||
const repo = absolute(entry.repo ?? join(dataRoot, 'projects', business.id, project));
|
||||
const contains = (base, child) => {
|
||||
const rel = relative(base, child);
|
||||
return rel === '' || (rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel));
|
||||
};
|
||||
for (const project of Object.values(business.projects)) {
|
||||
const code = absolute(project.root);
|
||||
if (contains(code, repo) || contains(repo, code))
|
||||
@@ -46,3 +46,13 @@ export function projectRepo({ business, project, dataRoot }) {
|
||||
noLinks(repo);
|
||||
return repo;
|
||||
}
|
||||
// Row 175 (#1673, mos-0872 Q2): the tester initializes only a repository in the data root's
|
||||
// project area, <dataRoot>/projects/<business id>/. It writes nothing; projectRepo only lstats the path.
|
||||
// The area itself is refused too: a repository there would hold every other project's default one.
|
||||
export function testerProjectArea(context) {
|
||||
const repo = projectRepo(context);
|
||||
const area = join(context.dataRoot, 'projects', context.business.id);
|
||||
if (relative(area, repo) === '' || !contains(area, repo))
|
||||
throw new ProjectError('tester-project-area', `the tester initializes only a repository under ${area}`);
|
||||
return repo;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Row 175 (#1673, mos-0872 Q2): `project init --tester` initializes only a repository under
|
||||
// <dataRoot>/projects/<business>/, and its layout commit names `tester` as author and committer.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { initProject, testerProjectArea } from '../src/index.mjs';
|
||||
|
||||
const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-C', repo, ...args], { encoding: 'utf8' }).trim();
|
||||
function context(t, repo) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'project-tester-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const dataRoot = join(root, 'data');
|
||||
return { root, business: { id: 'acme', human: 'jason', projects: { stack: { root: join(root, 'code'), ...(repo ? { repo: repo(root, dataRoot) } : {}) } } }, project: 'stack', dataRoot };
|
||||
}
|
||||
|
||||
test('row 175: init with author tester commits the layout as tester; without it, as the human', async (t) => {
|
||||
const c = context(t);
|
||||
const { repo, commit } = await initProject(c, { author: 'tester' });
|
||||
assert.equal(repo, join(c.dataRoot, 'projects/acme/stack'));
|
||||
assert.equal(git(repo, 'show', '-s', '--format=%an|%cn', commit), 'tester|tester');
|
||||
assert.equal(git(repo, 'rev-list', '--count', 'HEAD'), '1');
|
||||
const h = context(t);
|
||||
const human = await initProject(h);
|
||||
assert.equal(git(human.repo, 'show', '-s', '--format=%an|%cn', human.commit), 'jason|jason');
|
||||
});
|
||||
|
||||
test('row 175: init refuses an author that is no id before it makes any directory', async (t) => {
|
||||
const c = context(t);
|
||||
for (const author of ['', 'two words', '../x', 7])
|
||||
await assert.rejects(initProject(c, { author }), { code: 'project-id' }, String(author));
|
||||
assert.equal(existsSync(c.dataRoot), false);
|
||||
});
|
||||
|
||||
test('row 175: testerProjectArea passes a repository inside the business area and refuses the area itself and any other, touching nothing', (t) => {
|
||||
const inside = [
|
||||
null,
|
||||
(root, dataRoot) => join(dataRoot, 'projects', 'acme', 'records'),
|
||||
(root, dataRoot) => join(dataRoot, 'projects', 'acme', 'deep', 'records'),
|
||||
];
|
||||
for (const repo of inside) {
|
||||
const c = context(t, repo);
|
||||
assert.equal(testerProjectArea(c), repo ? repo(c.root, c.dataRoot) : join(c.dataRoot, 'projects', 'acme', 'stack'));
|
||||
}
|
||||
const outside = [
|
||||
(root) => join(root, 'records'),
|
||||
(root, dataRoot) => join(dataRoot, 'records'),
|
||||
(root, dataRoot) => join(dataRoot, 'projects', 'other', 'stack'),
|
||||
// A sibling whose name starts with the business id is not inside it.
|
||||
(root, dataRoot) => join(dataRoot, 'projects', 'acme-2', 'stack'),
|
||||
// The area itself: a repository there would hold the other projects' default repositories.
|
||||
(root, dataRoot) => join(dataRoot, 'projects', 'acme'),
|
||||
];
|
||||
for (const repo of outside) {
|
||||
const c = context(t, repo);
|
||||
assert.throws(() => testerProjectArea(c), { code: 'tester-project-area' }, repo(c.root, c.dataRoot));
|
||||
assert.equal(existsSync(c.dataRoot), false);
|
||||
assert.equal(existsSync(repo(c.root, c.dataRoot)), false);
|
||||
}
|
||||
// projectRepo's own checks still come first.
|
||||
assert.throws(() => testerProjectArea({ ...context(t), project: 'missing' }), { code: 'project-not-declared' });
|
||||
});
|
||||
+1
-1
@@ -35,6 +35,6 @@ if [ "${1:-}" = business ]; then
|
||||
exec node "$REPO/packages/business/src/cli.mjs" "$@"
|
||||
fi
|
||||
case "${1:-}" in
|
||||
usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
|
||||
usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus|plugins|name|names|follow|threads|thread) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
|
||||
esac
|
||||
exec node "$REPO/packages/seat/src/cli.mjs" "$@"
|
||||
|
||||
Reference in New Issue
Block a user