Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
666f4d6864 | ||
|
|
226e28d1e6 | ||
|
|
550bd084d0 |
No files matched your search
+12
-1
@@ -313,6 +313,15 @@ scripts/mosaic bus start <business> [--pm | --no-launch] [--dev-local-owner] [--
|
||||
scripts/mosaic console start [--dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--detach] [--port auto|N]
|
||||
scripts/mosaic console stop | status | restart
|
||||
scripts/mosaic org reload <org>
|
||||
scripts/mosaic org role-templates <org> [--json]
|
||||
scripts/mosaic org hire|change <org> --name <name> --template <template> --harness <harness> --model <model> [--thinking <level>] [--level org|project|root] [--project <project>] --reason <text> [--json]
|
||||
scripts/mosaic org fire <org> --name <name> [--level org|project|root] [--project <project>] --reason <text> [--json]
|
||||
scripts/mosaic org autonomy <org> [--json]
|
||||
scripts/mosaic org autonomy <org> set --revision <rev> [--cross-role|--merge|--policy|--authority in|near|out] [--notices on|off] [--reverse-window-hours 1-168] [--authority-resolver <name>|""] [--json]
|
||||
scripts/mosaic org autonomy <org> review cross-role|merge|policy|authority [--json]
|
||||
scripts/mosaic tasks create --title <text> --requirement <REQ-X-N> [--project <name>] [--state <state>] [--assignees <role,...>] [--priority 0-5] [--due <iso>|none] --business <id> [--json]
|
||||
scripts/mosaic tasks edit task:<n> --expect <digest> [--title ...] [--state ...] [--assignees ...] [--priority ...] [--due ...] --business <id> [--json]
|
||||
scripts/mosaic tasks archive task:<n> --expect <digest> --business <id> [--json]
|
||||
scripts/mosaic talk <role> <text> [--wait SECONDS] [--business <id>]
|
||||
scripts/mosaic project create <name> [--repo <repo>] | list [--json]
|
||||
scripts/mosaic threads [--project <project>] [--json] [--tester]
|
||||
@@ -335,7 +344,9 @@ For local development on a named instance only, `mosaic bus start BUSINESS --dev
|
||||
|
||||
`mosaic console restart` and `mosaic org reload ORG` are the Console's Settings controls (F3) from a terminal (U1, #1873). Each sends one request to the running Console named in the instance's console state, as the page sends it (the Console's own Host and Origin, `Sec-Fetch-Site: same-origin`, a JSON `{}` body), so the Console applies the same owner, `--send` and origin checks and writes the same action trail, `<dataRoot>/console/actions.jsonl`. Both refuse inside an agent run (exit 3) before reading anything, and refuse with exit 3 when no Console runs. `console restart` works only for a Console started in the foreground by `mosaic console start`, or by `mosaic console start --dev-local-owner --detach`, which leaves a detached supervisor (`packages/cli/src/console-supervisor.mjs`, its own session) running the Console as the foreground start does; its receipt adds the supervisor's `supervisor` pid, and its later warnings go to `console/supervisor-*.log` under the data root. `mosaic console stop` stops that Console, and its supervisor then exits. `mosaic console start` refuses inside an agent run (exit 3), with or without `--dev-local-owner` or `--detach`, checking the starting process's own environment as well as the one given for the Console (agent-guard, #1873). A detached start whose supervisor does not reply within 60 seconds signals the supervisor's process group, then kills it, and clears `console/start.lock` only when the lock names that supervisor (pid and start time) and it is gone. Any other Console answers `not-supervised`. `console restart` waits up to 30 seconds for the relaunched Console to answer and prints `{"outcome":"restarted","from":OLD,"boot":NEW}`. `org reload ORG` restarts a running bus or starts a stopped one and prints the Console's done receipt. A request that left but got no answer is reported as outcome unknown (exit 1) and is never retried; check `mosaic console status`, `mosaic bus status ORG` and the trail. `mosaic bus restart ORG` remains the trail-less host verb.
|
||||
|
||||
The bus and Console lifecycle verbs refuse inside an agent run (agent-guard, #1873): `mosaic bus start`, `bus stop`, `bus restart` and `bus recover`, and `mosaic console start`, `console stop` and `console restart`, and `mosaic org archive` and `org delete`, which stop the organization's bus, and `mosaic org restore`, which starts it (`org restore --no-bus` starts nothing and is not refused). Each exits 3 with `refused: this runs only from a human shell, outside any agent run (found MARKER in the environment)` before it reads anything. It checks the environment it is given and its own process environment, so a sanitized environment does not hide a marker. A usage error (a missing or extra argument, `--pm` with `--no-launch`, or a bad `--dev-insecure-bind`/`--dev-allow-host`) still comes first (exit 4). `mosaic bus status` and `mosaic console status` stay open to agents. The Console's Restart bus and Restart Console controls keep working: the Console starts its bus host with its own environment, which carries no marker, and Restart Console relaunches the Console under its supervisor without going through the CLI. The markers are the ones `packages/cli/src/transport.mjs` lists. The check is cooperative: a process that drops the markers from its environment is not stopped by it.
|
||||
The hire form and the autonomy panel from a terminal (E2, #1873, P1-3). `mosaic org role-templates|hire|fire|change ORG` and `mosaic org autonomy ORG [set|review]` send the page's own request to the running Console's org route (`/api/orgs/ORG/decisions/hire/templates|start`, `/api/orgs/ORG/autonomy[/review]`), as `org reload` does, so the Console applies the same owner, `--send`, origin, body and live-session checks and the same bus verbs run (`hire.templates.console`, `hire.start.console`, `autonomy.get.console`, `autonomy.set.console`, `autonomy.review.console`). `hire` and `change` read the templates first and send the chosen template's name and sha256, as the form does. `autonomy set` needs the 64-hex `--revision` from `mosaic org autonomy ORG` and sends every setting the panel sends, with only the named ones changed; a stale revision is refused `revision-conflict`; an empty `--authority-resolver ""` clears the resolver, as emptying the panel's field does. These verbs need a named instance (`MOSAIC_CONFIG` set to `~/.config/mosaic-NAME/config.json`) whose Console runs with `--dev-local-owner`; the default instance is refused with exit 3. A Console refusal exits 3 with the Console's code and message; `outcome-unknown` exits 1; an unknown template exits 2. Every one of these verbs, reads included, refuses inside an agent run. `mosaic tasks create|edit|archive` are the task board's three writes: the arguments pass the board's own check (`workspace-tasks.mjs parseTaskWrite`) and go to the bus as the person (`tasks.create|edit|archive`, human transport), so the bus runs its human, field, digest and archive checks. A stale `--expect` is refused `task-conflict`; read the digest with `mosaic tasks --json`.
|
||||
|
||||
The bus and Console lifecycle verbs refuse inside an agent run (agent-guard, #1873): `mosaic bus start`, `bus stop`, `bus restart` and `bus recover`, and `mosaic console start`, `console stop` and `console restart`, and `mosaic org archive` and `org delete`, which stop the organization's bus, and `mosaic org restore`, which starts it (`org restore --no-bus` starts nothing and is not refused), and `mosaic org role-templates`, `hire`, `fire`, `change` and `autonomy` (E2). Each exits 3 with `refused: this runs only from a human shell, outside any agent run (found MARKER in the environment)` before it reads anything. It checks the environment it is given and its own process environment, so a sanitized environment does not hide a marker. A usage error (a missing or extra argument, `--pm` with `--no-launch`, or a bad `--dev-insecure-bind`/`--dev-allow-host`) still comes first (exit 4). `mosaic bus status` and `mosaic console status` stay open to agents. The Console's Restart bus and Restart Console controls keep working: the Console starts its bus host with its own environment, which carries no marker, and Restart Console relaunches the Console under its supervisor without going through the CLI. The markers are the ones `packages/cli/src/transport.mjs` lists. The check is cooperative: a process that drops the markers from its environment is not stopped by it.
|
||||
|
||||
The instance processing switch is separate from per-business launches off/on.
|
||||
`mosaic halt` durably stops managed processing across every business in the
|
||||
|
||||
@@ -28,6 +28,9 @@ mosaic inbox [--business <id>] [--json]
|
||||
mosaic inbox --read <id>... | --read-all [--business <id>]
|
||||
mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
|
||||
mosaic tasks [--business <id>] [--json]
|
||||
mosaic tasks create --title <text> --requirement <REQ-X-N> [--project <name>] [--state <state>] [--assignees <role,...>] [--priority 0-5] [--due <iso>|none] --business <id> [--json]
|
||||
mosaic tasks edit task:<n> --expect <digest> [--title <text>] [--state <state>] [--assignees <role,...>] [--priority 0-5] [--due <iso>|none] --business <id> [--json]
|
||||
mosaic tasks archive task:<n> --expect <digest> --business <id> [--json]
|
||||
mosaic agents [--business <id>] [--json] [--tester]
|
||||
mosaic trail <task|decision> [--business <id>] [--json]
|
||||
mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>]
|
||||
@@ -1313,6 +1316,38 @@ stale lock). A bus that does not start leaves the restore in place;
|
||||
the command prints the receipt and exits 3. `org restore ID --if-revision N
|
||||
--no-bus` restores without starting the bus, as before, and stays open to agents.
|
||||
|
||||
The hire form and the autonomy panel from a terminal (E2, #1873, P1-3):
|
||||
|
||||
```
|
||||
mosaic org role-templates ORG [--json]
|
||||
mosaic org hire|change ORG --name NAME --template TEMPLATE --harness H --model M [--thinking T] [--level org|project|root] [--project P] --reason TEXT [--json]
|
||||
mosaic org fire ORG --name NAME [--level org|project|root] [--project P] --reason TEXT [--json]
|
||||
mosaic org autonomy ORG [--json]
|
||||
mosaic org autonomy ORG set --revision REV [--cross-role|--merge|--policy|--authority in|near|out] [--notices on|off] [--reverse-window-hours 1-168] [--authority-resolver NAME|""] [--json]
|
||||
mosaic org autonomy ORG review cross-role|merge|policy|authority [--json]
|
||||
```
|
||||
|
||||
Each sends the page's own request to the running Console's org route, as
|
||||
`org reload` does, so the Console runs the same owner, `--send`, origin, body
|
||||
and session checks and the same bus verbs. `hire` and `change` read the role
|
||||
templates first and send the chosen template's name and sha256. `autonomy set`
|
||||
needs the 64-hex `--revision` that `mosaic org autonomy ORG` prints and sends every
|
||||
setting the panel sends, changing only the named ones; a stale revision is
|
||||
refused `revision-conflict`. `--authority-resolver ""` clears the resolver, as
|
||||
emptying the panel's field does, and `--reverse-window-hours` takes 1 to 168.
|
||||
These verbs need a named instance whose Console runs with `--dev-local-owner`;
|
||||
the default instance is refused with exit 3. A Console refusal exits 3 with its code and
|
||||
message, an answer that never came exits 1, an unknown template exits 2. All of
|
||||
these verbs, reads included, refuse inside an agent run (exit 3) after their
|
||||
usage checks (exit 4) and before any config is read.
|
||||
|
||||
`mosaic tasks create|edit|archive` are the task board's three writes. The
|
||||
arguments pass the board's own check, then go to the bus as the person
|
||||
(`tasks.create`, `tasks.edit`, `tasks.archive`), so the bus runs its human,
|
||||
field, digest and archive checks. Edit and archive need the task's digest from
|
||||
`mosaic tasks --json` as `--expect`; a stale one is refused `task-conflict`
|
||||
(exit 2) and nothing is written. They refuse inside an agent run.
|
||||
|
||||
System types are installed plug-ins. `mosaic org system-types installed` prints
|
||||
module metadata; `mosaic org system-types enabled ID` prints an organization's enabled
|
||||
ids. The owner changes the enabled list through the existing organization file
|
||||
|
||||
@@ -55,6 +55,7 @@ import { agentsRootCommand, AGENTS_ROOT_USAGE, projectAgentsCommand, PROJECT_AGE
|
||||
import { ORG_CONFIG, runConfigCommand } from "../../organization/src/config-command.mjs";
|
||||
import { devCommand, DEV_USAGE, fixtureLaunchEnv, fixtureLauncherOptions } from "./dev-fixture.mjs";
|
||||
import { runCommand, RUN_USAGE } from "./run-evidence.mjs";
|
||||
import { tasksWriteCommand, TASKS_WRITE_OPS, TASKS_WRITE_USAGE } from "./tasks-write.mjs";
|
||||
|
||||
export const USAGE = `usage:
|
||||
${ACCOUNTS_USAGE}
|
||||
@@ -82,6 +83,7 @@ export const USAGE = `usage:
|
||||
mosaic inbox --read <id>... | --read-all [--business <id>]
|
||||
mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
|
||||
mosaic tasks [--business <id>] [--json] [--tester]
|
||||
${TASKS_WRITE_USAGE}
|
||||
mosaic agents [--business <id>] [--json] [--tester]
|
||||
mosaic trail <task|decision> [--business <id>] [--json] [--tester]
|
||||
mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>] [--tester]
|
||||
@@ -771,6 +773,8 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
// A2 (#1873): the owner's root and project agent levels. `mosaic agents` without `root` stays the bus read.
|
||||
if (verb === "agents" && rest[0] === "root") return agentsRootCommand(rest.slice(1), io);
|
||||
if (verb === "project" && rest[0] === "agents") return projectAgentsCommand(rest.slice(1), io);
|
||||
// E2 (#1873, P1-3): the task board's create, edit and archive, as the person (tasks-write.mjs).
|
||||
if (verb === "tasks" && TASKS_WRITE_OPS.includes(rest[0])) return tasksWriteCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
|
||||
if (verb === "project" && rest[0] === "north-star") return northStarCommand(rest.slice(1), io, (p) => callFor(p, io, deps));
|
||||
if (verb === "project" && rest[0] === "interview") return interviewCommand(rest.slice(1), io);
|
||||
|
||||
@@ -318,9 +318,10 @@ function consoleTarget(ctx) {
|
||||
const address = s.bind && s.bind !== '0.0.0.0' ? s.bind : '127.0.0.1';
|
||||
return { address, port, host: `${address}:${port}` };
|
||||
}
|
||||
function consoleCall(target, { method = 'GET', path, timeoutMs }) {
|
||||
function consoleCall(target, { method = 'GET', path, timeoutMs, body = {} }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers = { host: target.host, ...(method === 'POST' ? { origin: `http://${target.host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': 2 } : {}) };
|
||||
const payload = method === 'POST' ? JSON.stringify(body) : undefined;
|
||||
const headers = { host: target.host, ...(method === 'POST' ? { origin: `http://${target.host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } : {}) };
|
||||
let sent = false;
|
||||
const req = httpRequest({ host: target.address, port: target.port, method, path, headers, timeout: timeoutMs }, res => {
|
||||
let text = '';
|
||||
@@ -332,14 +333,14 @@ function consoleCall(target, { method = 'GET', path, timeoutMs }) {
|
||||
req.on('timeout', () => req.destroy(Object.assign(new Error('timed out'), { code: 'timeout' })));
|
||||
req.on('finish', () => { sent = true; });
|
||||
req.on('error', e => reject(Object.assign(e, { sent })));
|
||||
req.end(method === 'POST' ? '{}' : undefined);
|
||||
req.end(payload);
|
||||
});
|
||||
}
|
||||
// A refused connection never reached the Console. Anything else after the request left may have
|
||||
// changed something, so it is outcome-unknown and nothing is retried.
|
||||
async function consolePost(target, path, { timeoutMs, unknown }) {
|
||||
async function consolePost(target, path, { timeoutMs, unknown, body }) {
|
||||
let answer;
|
||||
try { answer = await consoleCall(target, { method: 'POST', path, timeoutMs }); }
|
||||
try { answer = await consoleCall(target, { method: 'POST', path, timeoutMs, body }); }
|
||||
catch (e) {
|
||||
if (e.code === 'ECONNREFUSED' && !e.sent) fail('the Console refused the connection; see mosaic console status');
|
||||
throw new CliError(`outcome unknown: ${unknown}`, 1);
|
||||
@@ -374,3 +375,19 @@ export async function consoleBusRestart(ctx, id, { timeoutMs = 120000 } = {}) {
|
||||
if (answer.status !== 200 || answer.body?.outcome !== 'done' || answer.body.step !== 'bus-restart') throw refusal(answer, 'org reload');
|
||||
return answer.body;
|
||||
}
|
||||
// E2 (#1873, P1-3): the owner's org page actions from a terminal (org-owner.mjs): the hire form and
|
||||
// the autonomy page, each through the running Console's own org route, sent as the page sends it.
|
||||
// The route applies its owner, --send, origin, body and live-session checks and asks the org's own
|
||||
// bus; nothing here decides or writes anything. A GET that fails changed nothing, so it is exit 1
|
||||
// with no outcome-unknown wording; a POST that fails after it left is outcome-unknown.
|
||||
export async function consoleOrgRequest(ctx, id, { method = 'GET', path, body, timeoutMs = 30000, unknown }) {
|
||||
if (typeof id !== 'string' || !ORG_ID.test(id)) throw new CliError('invalid organization id', 4);
|
||||
const target = consoleTarget(ctx);
|
||||
const route = `/api/orgs/${id}/${path}`;
|
||||
if (method === 'POST') return consolePost(target, route, { timeoutMs, unknown, body });
|
||||
try { return await consoleCall(target, { path: route, timeoutMs }); }
|
||||
catch (e) {
|
||||
if (e.code === 'ECONNREFUSED' && !e.sent) fail('the Console refused the connection; see mosaic console status');
|
||||
throw new CliError('the Console did not answer; see mosaic console status', 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// E2 (#1873, P1-3, org ruling 6): the owner's org page actions from a terminal. Each verb sends the
|
||||
// request the WebUI page sends, to the running Console's own org route (org-console.mjs
|
||||
// consoleOrgRequest), so the same route checks the owner, --send, the origin, the body and the live
|
||||
// session, and the same bus verb runs on the organization's own bus:
|
||||
// - role-templates, hire, fire, change: the inbox hire form (public/bus.js hireSubmit), the routes
|
||||
// /api/orgs/ID/decisions/hire/templates and /start (decision-http.mjs), the bus verbs
|
||||
// hire.templates.console and hire.start.console.
|
||||
// - autonomy, autonomy set, autonomy review: the org Autonomy panel (public/autonomy.js), the
|
||||
// route /api/orgs/ID/autonomy[/review] (autonomy-http.mjs), the bus verbs autonomy.get.console,
|
||||
// autonomy.set.console (with its revision check) and autonomy.review.console.
|
||||
// No new authority: a Console that is not the owner's, not started with --send, or not signed in
|
||||
// refuses exactly as it refuses the page. Usage errors (exit 4) come first; then every verb refuses
|
||||
// inside an agent run, before any config is read, as `org archive|delete` do (agent guard #1942).
|
||||
import { configDir } from '../../organization/src/organization-config.mjs';
|
||||
import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
|
||||
import { loadSystem } from './config.mjs';
|
||||
import { CliError } from './errors.mjs';
|
||||
import { consoleOrgRequest } from './org-console.mjs';
|
||||
import { refuseInsideAgent } from './transport.mjs';
|
||||
|
||||
export const ORG_OWNER_VERBS = Object.freeze(['role-templates', 'hire', 'fire', 'change', 'autonomy']);
|
||||
export const ORG_OWNER_USAGE = 'mosaic org role-templates ID [--json] | hire|change ID --name NAME --template TEMPLATE --harness HARNESS --model MODEL [--thinking LEVEL] [--level org|project|root] [--project PROJECT] --reason TEXT [--json] | fire ID --name NAME [--level org|project|root] [--project PROJECT] --reason TEXT [--json] | autonomy ID [--json] | autonomy ID set --revision REV [--cross-role|--merge|--policy|--authority in|near|out] [--notices on|off] [--reverse-window-hours N] [--authority-resolver NAME|""] [--json] | autonomy ID review cross-role|merge|policy|authority [--json]';
|
||||
|
||||
const usage = why => new CliError(`${why}\nusage: ${ORG_OWNER_USAGE}`, 4);
|
||||
const ORG_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
const CLASSES = Object.freeze(['cross-role', 'merge', 'policy', 'authority']);
|
||||
const LEVELS = Object.freeze({ in: 'in the loop', near: 'near the loop', out: 'out of the loop' });
|
||||
const HIRE_VALUES = ['--name', '--template', '--harness', '--model', '--thinking', '--level', '--project', '--reason'];
|
||||
const AUTONOMY_VALUES = ['--revision', ...CLASSES.map(c => `--${c}`), '--notices', '--reverse-window-hours', '--authority-resolver'];
|
||||
|
||||
function parse(args, values) {
|
||||
const pos = [], got = {}, flags = new Set();
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === '--json') { if (flags.has(a)) throw usage('duplicate --json'); flags.add(a); }
|
||||
else if (values.includes(a)) {
|
||||
if (Object.hasOwn(got, a)) throw usage(`duplicate ${a}`);
|
||||
if (i + 1 >= args.length || args[i + 1].startsWith('--')) throw usage(`${a} needs one value`);
|
||||
got[a] = args[++i];
|
||||
} else if (a.startsWith('--')) throw usage(`unknown option ${a}`);
|
||||
else pos.push(a);
|
||||
}
|
||||
return { pos, values: got, json: flags.has('--json') };
|
||||
}
|
||||
|
||||
// The hire form's body (bus.js hireSubmit), from flags. The bus's proposal checks (hire-decisions.mjs
|
||||
// prepare) run on it unchanged; these checks only refuse what the form itself would not send.
|
||||
function hireBody(op, v) {
|
||||
const need = key => { if (!v[key]?.trim()) throw usage(`${op} needs ${key}`); return v[key]; };
|
||||
const level = v['--level'] ?? 'org';
|
||||
if (!['org', 'project', 'root'].includes(level)) throw usage('--level takes org, project or root');
|
||||
if (level === 'project' ? !v['--project']?.trim() : v['--project'] !== undefined) throw usage(level === 'project' ? '--level project needs --project' : '--project goes with --level project');
|
||||
const reason = need('--reason');
|
||||
if (reason.length > 500) throw usage('--reason takes at most 500 characters');
|
||||
if (op === 'fire') for (const k of ['--template', '--harness', '--model', '--thinking']) if (v[k] !== undefined) throw usage(`fire takes no ${k}`);
|
||||
const hire = { op, level, name: need('--name'), reason, ...(level === 'project' ? { project: v['--project'] } : {}) };
|
||||
if (op === 'fire') return { hire, template: null };
|
||||
const template = need('--template');
|
||||
const thinking = v['--thinking']?.trim();
|
||||
return { hire: { ...hire, route: { harness: need('--harness'), model: need('--model'), ...(thinking ? { thinking } : {}) } }, template };
|
||||
}
|
||||
|
||||
function autonomyChanges(v) {
|
||||
const revision = v['--revision'];
|
||||
if (revision === undefined) throw usage('autonomy set needs --revision, the revision mosaic org autonomy ID shows');
|
||||
// The panel's revision is the bus's 64-hex digest of the organization (autonomy.set.console).
|
||||
if (!/^[a-f0-9]{64}$/.test(revision)) throw usage('--revision takes the 64-hex revision mosaic org autonomy ID shows');
|
||||
const changes = {};
|
||||
for (const c of CLASSES) {
|
||||
const value = v[`--${c}`];
|
||||
if (value === undefined) continue;
|
||||
if (!Object.hasOwn(LEVELS, value)) throw usage(`--${c} takes in, near or out`);
|
||||
changes[c] = LEVELS[value];
|
||||
}
|
||||
if (v['--notices'] !== undefined) {
|
||||
if (!['on', 'off'].includes(v['--notices'])) throw usage('--notices takes on or off');
|
||||
changes.notices = v['--notices'] === 'on';
|
||||
}
|
||||
if (v['--reverse-window-hours'] !== undefined) {
|
||||
if (!/^[1-9][0-9]{0,2}$/.test(v['--reverse-window-hours']) || Number(v['--reverse-window-hours']) > 168) throw usage('--reverse-window-hours takes a whole number of hours from 1 to 168');
|
||||
changes.reverseWindowHours = Number(v['--reverse-window-hours']);
|
||||
}
|
||||
if (v['--authority-resolver'] !== undefined) changes.authorityResolver = v['--authority-resolver'];
|
||||
if (!Object.keys(changes).length) throw usage('autonomy set needs a setting to change');
|
||||
return { revision, changes };
|
||||
}
|
||||
|
||||
// The panel's save sends every class with the level it shows, notices, the reverse window (24 when
|
||||
// none is set) and the resolver when one is set (autonomy.js). The terminal sends the same document
|
||||
// with the named settings changed, so an unchanged field keeps the value the page would send.
|
||||
export function autonomyDocument(current, changes) {
|
||||
const shown = current?.autonomy ?? {};
|
||||
const doc = Object.fromEntries(CLASSES.map(c => [c, current?.levels?.[c]]));
|
||||
doc.notices = shown.notices === true;
|
||||
doc.reverseWindowHours = shown.reverseWindowHours ?? 24;
|
||||
if (shown.authorityResolver) doc.authorityResolver = shown.authorityResolver;
|
||||
const out = { ...doc, ...changes };
|
||||
// An empty --authority-resolver clears it: the panel leaves an emptied field out of its save.
|
||||
if (out.authorityResolver === '') delete out.authorityResolver;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Autonomy codes carry no message from the route; these are the panel's own words (autonomy.js).
|
||||
const AUTONOMY_GUIDANCE = {
|
||||
'revision-conflict': 'the organization changed; read it again with mosaic org autonomy ID',
|
||||
'autonomy-precondition': 'this move needs a separate resolver, a tested reverse, owner notices and an enforced budget covering new agents for authority; out of the loop needs a near-loop decision followed by your review of its trail',
|
||||
'policy-reverse-unavailable': 'policy has no near-loop setting until a reverse exists',
|
||||
'authority-limits-unavailable': 'authority cannot move out of the loop',
|
||||
'owner-only': 'only the owner changes autonomy',
|
||||
'read-only': 'start the Console with --send to make changes',
|
||||
'sign-in-required': 'sign in to the Console',
|
||||
};
|
||||
// A refusal is exit 3 and changed nothing. outcome-unknown (and a 5xx the route did not name) may
|
||||
// have changed something, so it is exit 1 and nothing is retried.
|
||||
function refused(answer, what, unknown, guidance = {}) {
|
||||
const raw = answer.body?.error;
|
||||
const code = typeof raw === 'string' && /^[a-z-]{1,64}$/.test(raw) ? raw : `http ${answer.status}`;
|
||||
if (code === 'outcome-unknown' || (answer.status >= 500 && !raw)) return new CliError(`outcome unknown: ${unknown}`, 1);
|
||||
const said = typeof answer.body?.message === 'string' ? answer.body.message.slice(0, 500) : guidance[code];
|
||||
return new CliError(`${what} refused (${code})${said ? `: ${said}` : ''}`, 3);
|
||||
}
|
||||
|
||||
function ok(answer) { return answer.status === 200 && answer.body && typeof answer.body === 'object'; }
|
||||
|
||||
export async function orgOwnerCommand(args, io, deps = {}) {
|
||||
const verb = args[0];
|
||||
const autonomy = verb === 'autonomy';
|
||||
const p = parse(args.slice(1), autonomy ? AUTONOMY_VALUES : verb === 'role-templates' ? [] : HIRE_VALUES);
|
||||
const id = p.pos[0];
|
||||
if (id === undefined) throw usage(`org ${verb} takes an organization id`);
|
||||
if (!ORG_ID.test(id)) throw usage('invalid organization id');
|
||||
let run;
|
||||
if (verb === 'role-templates') {
|
||||
if (p.pos.length !== 1) throw usage('role-templates takes one organization id');
|
||||
run = async ctx => {
|
||||
const answer = await consoleOrgRequest(ctx, id, { method: 'POST', path: 'decisions/hire/templates', body: { business: id }, unknown: 'the templates read did not finish; nothing was changed' });
|
||||
if (!ok(answer) || !Array.isArray(answer.body.templates)) throw answer.status === 200 ? new CliError('the Console answered without templates', 1) : refused(answer, 'role-templates', 'the templates read did not finish; nothing was changed');
|
||||
return { value: answer.body, text: b => b.templates.length ? b.templates.map(t => `${t.name} ${String(t.sha256 ?? '').slice(0, 12)}\n`).join('') : 'role templates: none\n' };
|
||||
};
|
||||
} else if (['hire', 'fire', 'change'].includes(verb)) {
|
||||
if (p.pos.length !== 1) throw usage(`${verb} takes one organization id`);
|
||||
const { hire, template } = hireBody(verb, p.values);
|
||||
run = async ctx => {
|
||||
let body = hire;
|
||||
// The form chooses a template from the ones it read and sends its name and sha256.
|
||||
if (template !== null) {
|
||||
const read = await consoleOrgRequest(ctx, id, { method: 'POST', path: 'decisions/hire/templates', body: { business: id }, unknown: 'the templates read did not finish; nothing was changed' });
|
||||
if (!ok(read) || !Array.isArray(read.body.templates)) throw refused(read, verb, 'the templates read did not finish; nothing was changed');
|
||||
const chosen = read.body.templates.find(t => t?.name === template);
|
||||
if (!chosen) throw new CliError(`${verb}: no role template ${JSON.stringify(template)}; see mosaic org role-templates ${id}`, 2);
|
||||
const { route, ...rest } = hire;
|
||||
body = { ...rest, role: { template: chosen.name, sha256: chosen.sha256 }, route };
|
||||
}
|
||||
const unknown = `the ${verb} may have been recorded; check mosaic inbox --business ${id} and mosaic trail before trying again`;
|
||||
const answer = await consoleOrgRequest(ctx, id, { method: 'POST', path: 'decisions/hire/start', body: { business: id, hire: body }, timeoutMs: 120000, unknown });
|
||||
if (!ok(answer)) throw refused(answer, verb, unknown);
|
||||
if (typeof answer.body.id !== 'string' || answer.body.op !== 'resolved') throw new CliError(`outcome unknown: ${unknown}`, 1);
|
||||
return { value: answer.body, text: b => `recorded decision ${b.id}: ${verb} ${hire.name} (${b.op} ${b.choice})\n` };
|
||||
};
|
||||
} else if (autonomy) {
|
||||
const step = p.pos[1] ?? null;
|
||||
if (step === null) {
|
||||
if (p.pos.length !== 1 || Object.keys(p.values).length) throw usage('autonomy ID reads; use autonomy ID set to change it');
|
||||
run = async ctx => {
|
||||
const answer = await consoleOrgRequest(ctx, id, { path: 'autonomy' });
|
||||
if (!ok(answer)) throw refused(answer, 'autonomy', 'the autonomy read did not finish; nothing was changed', AUTONOMY_GUIDANCE);
|
||||
return { value: answer.body, text: formatAutonomy };
|
||||
};
|
||||
} else if (step === 'set') {
|
||||
if (p.pos.length !== 2) throw usage('autonomy ID set takes options only');
|
||||
const { revision, changes } = autonomyChanges(p.values);
|
||||
run = async ctx => {
|
||||
const read = await consoleOrgRequest(ctx, id, { path: 'autonomy' });
|
||||
if (!ok(read)) throw refused(read, 'autonomy set', 'the autonomy read did not finish; nothing was changed', AUTONOMY_GUIDANCE);
|
||||
const unknown = `the setting may have been saved; check mosaic org autonomy ${id} before trying again`;
|
||||
const answer = await consoleOrgRequest(ctx, id, { method: 'POST', path: 'autonomy', body: { revision, autonomy: autonomyDocument(read.body, changes) }, unknown });
|
||||
if (!ok(answer)) throw refused(answer, 'autonomy set', unknown, AUTONOMY_GUIDANCE);
|
||||
return { value: answer.body, text: b => `saved autonomy for ${id}: revision ${b.revision ?? 'unknown'}${b.transaction ? `, receipt ${b.transaction}` : ''}\n` };
|
||||
};
|
||||
} else if (step === 'review') {
|
||||
if (p.pos.length !== 3 || !CLASSES.includes(p.pos[2])) throw usage(`autonomy ID review takes one class: ${CLASSES.join(', ')}`);
|
||||
if (Object.keys(p.values).length) throw usage('autonomy ID review takes no options but --json');
|
||||
run = async ctx => {
|
||||
const unknown = `the review may have been recorded; check mosaic trail before trying again`;
|
||||
const answer = await consoleOrgRequest(ctx, id, { method: 'POST', path: 'autonomy/review', body: { decisionClass: p.pos[2] }, unknown });
|
||||
if (!ok(answer)) throw refused(answer, 'autonomy review', unknown, AUTONOMY_GUIDANCE);
|
||||
return { value: answer.body, text: () => `recorded your trail review for ${p.pos[2]} on ${id}\n` };
|
||||
};
|
||||
} else throw usage(`autonomy takes set or review, not ${JSON.stringify(step)}`);
|
||||
} else throw usage(`unknown org verb ${verb}`);
|
||||
// agent-guard (#1873): after the usage checks, before any config is read; this process's own
|
||||
// environment as well as io.env, as org archive|delete check.
|
||||
refuseInsideAgent(process.env);
|
||||
refuseInsideAgent(io.env);
|
||||
let instance;
|
||||
try { instance = devOwnerInstance(io.env); }
|
||||
catch (e) { throw new CliError(`${e.message}; these verbs need a named instance whose Console runs with --dev-local-owner`, 3); }
|
||||
const system = deps.system ?? loadSystem({ env: io.env, instance });
|
||||
const ctx = { configDir: configDir(io.env), dataRoot: system.dataRoot, instance, principal: DEV_OWNER };
|
||||
const { value, text } = await run(ctx);
|
||||
io.stdout.write(p.json ? `${JSON.stringify(value, null, 2)}\n` : text(value));
|
||||
}
|
||||
|
||||
function formatAutonomy(b) {
|
||||
const a = b.autonomy ?? {};
|
||||
return [
|
||||
`revision ${b.revision}${b.writable ? '' : ' (read-only: this Console takes no changes)'}`,
|
||||
...CLASSES.map(c => `${c}: ${b.levels?.[c] ?? 'unknown'}`),
|
||||
`notices: ${a.notices === true ? 'on' : 'off'}`,
|
||||
`reverse window: ${a.reverseWindowHours === undefined ? 'not set (the page offers 24 hours)' : `${a.reverseWindowHours} hours`}`,
|
||||
`authority resolver: ${a.authorityResolver ?? 'none'}`,
|
||||
].join('\n') + '\n';
|
||||
}
|
||||
@@ -11,7 +11,8 @@ import { assertBuildStart, assertDevInstance } from './deployment.mjs';
|
||||
import { consoleBusRestart } from './org-console.mjs';
|
||||
import { refuseInsideAgent } from './transport.mjs';
|
||||
import { definitionsCommand, DEFINITIONS_USAGE } from './org-definitions.mjs';
|
||||
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--no-bus] [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | ' + DEFINITIONS_USAGE.slice('mosaic org '.length) + ' | config validate ID | config resolve ID INSTANCE [--project ID]';
|
||||
import { orgOwnerCommand, ORG_OWNER_USAGE, ORG_OWNER_VERBS } from './org-owner.mjs';
|
||||
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--no-bus] [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | ' + DEFINITIONS_USAGE.slice('mosaic org '.length) + ' | ' + ORG_OWNER_USAGE.slice('mosaic org '.length) + ' | config validate ID | config resolve ID INSTANCE [--project ID]';
|
||||
function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk', '--no-bus'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; }
|
||||
// A refusal names the document by label and the error by its code, never by host path or bytes (PR #1916).
|
||||
const errorCode = e => typeof e?.code === 'string' && /^E[A-Z0-9]+$/.test(e.code) ? e.code : 'unreadable';
|
||||
@@ -32,6 +33,8 @@ export async function orgCommand(args, io, deps = {}) {
|
||||
try {
|
||||
// #1873 T1: the definitions verbs take their own options.
|
||||
if (args[0] === 'definitions') return await definitionsCommand(args.slice(1), io, deps);
|
||||
// E2 (#1873, P1-3): the hire form and the autonomy panel, through the running Console (org-owner.mjs).
|
||||
if (ORG_OWNER_VERBS.includes(args[0])) return await orgOwnerCommand(args, io, deps);
|
||||
const p = parse(args); const words = p.pos; let result; let accepted = [];
|
||||
const allowed = (flags, values) => { for (const f of p.flags) if (!flags.includes(f)) throw new CliError(`unexpected ${f}`, 4); for (const f of Object.keys(p.values)) if (!values.includes(f)) throw new CliError(`unexpected ${f}`, 4); };
|
||||
const need = key => { if (!p.values[key]) throw new CliError(`${key} is required`, 4); return p.values[key]; };
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// E2 (#1873, P1-3, org ruling 6): the task board's writes from a terminal. `mosaic tasks create`,
|
||||
// `edit` and `archive` send the board's three ops (workspace-tasks.mjs) to the organization's bus as
|
||||
// the person, through the human transport (U4a: the bus's tasks.create, tasks.edit and tasks.archive
|
||||
// are the person's writes "from the terminal (human-cli)"). The arguments pass the board's own
|
||||
// check, parseTaskWrite, before anything is sent, so the terminal takes exactly what the board
|
||||
// takes; the bus then runs its own human, field, digest and archive checks, as it does for the
|
||||
// board. Writes take no --tester and refuse inside an agent run (callFor), and the bus refuses any
|
||||
// caller that is not the person (human-required).
|
||||
import { parseTaskWrite } from '../../webui/src/workspace-tasks.mjs';
|
||||
import { CliError } from './errors.mjs';
|
||||
import { orgSelector } from './org-selector.mjs';
|
||||
import { refuseInsideAgent } from './transport.mjs';
|
||||
|
||||
export const TASKS_WRITE_OPS = Object.freeze(['create', 'edit', 'archive']);
|
||||
export const TASKS_WRITE_USAGE = 'mosaic tasks create --title TEXT --requirement REQ-X-N [--project NAME] [--state STATE] [--assignees ROLE,...] [--priority 0-5] [--due ISO|none] --business <id> [--json]\n' +
|
||||
' mosaic tasks edit task:N --expect DIGEST [--title TEXT] [--state STATE] [--assignees ROLE,...] [--priority 0-5] [--due ISO|none] --business <id> [--json]\n' +
|
||||
' mosaic tasks archive task:N --expect DIGEST --business <id> [--json]';
|
||||
|
||||
const usage = why => new CliError(`${why}\nusage: ${TASKS_WRITE_USAGE}`, 4);
|
||||
// Each op's options, as the board's op takes its keys (workspace-tasks.mjs OPS).
|
||||
const OPTIONS = {
|
||||
create: ['--title', '--requirement', '--project', '--state', '--assignees', '--priority', '--due'],
|
||||
edit: ['--expect', '--title', '--state', '--assignees', '--priority', '--due'],
|
||||
archive: ['--expect'],
|
||||
};
|
||||
const KEY = { '--title': 'title', '--requirement': 'requirement', '--project': 'group', '--state': 'state', '--assignees': 'assignees', '--priority': 'priority', '--due': 'due_date', '--expect': 'expect' };
|
||||
// The board's answer when the bus refuses, by code (workspace-tasks.mjs REFUSALS), so the terminal
|
||||
// reads the same next step.
|
||||
const GUIDANCE = {
|
||||
'human-required': 'the bus takes task changes only from a person; nothing was written',
|
||||
'tracker-not-store': 'this organization keeps its tasks in Vikunja; nothing was written',
|
||||
'task-conflict': 'the task changed after you read it; nothing was written. Read it again with mosaic tasks --json',
|
||||
'task-archived': 'the task is archived; nothing was written',
|
||||
'task-already-archived': 'the task was already archived; nothing was written',
|
||||
'task-not-found': 'the task is not on this organization\'s bus; nothing was written',
|
||||
'task-position-full': 'this project has no room for another task position; nothing was written',
|
||||
'invalid-state': 'the bus does not have that state; nothing was written',
|
||||
'unknown-role': 'an assignee is not a role in this organization; nothing was written',
|
||||
'invalid-request': 'the bus refused the change as invalid; nothing was written',
|
||||
'credential-leak': 'the change holds part of a stored credential, so the bus refused it; nothing was written',
|
||||
};
|
||||
|
||||
export function parseTasksWrite(args) {
|
||||
const op = args[0];
|
||||
if (!TASKS_WRITE_OPS.includes(op)) throw usage(`tasks takes create, edit or archive, not ${JSON.stringify(op)}`);
|
||||
const selector = orgSelector(usage);
|
||||
const values = {}, positional = [];
|
||||
let json = false;
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (selector.owns(a)) { selector.take(a, args[i + 1]); i++; }
|
||||
else if (a === '--json') { if (json) throw usage('duplicate --json'); json = true; }
|
||||
else if (OPTIONS[op].includes(a)) {
|
||||
if (Object.hasOwn(values, a)) throw usage(`duplicate ${a}`);
|
||||
if (i + 1 >= args.length || args[i + 1].startsWith('--')) throw usage(`${a} needs one value`);
|
||||
values[a] = args[++i];
|
||||
} else if (a.startsWith('--')) throw usage(`tasks ${op} does not take ${a}`);
|
||||
else positional.push(a);
|
||||
}
|
||||
// The organization is named before anything else is checked, so a missing --business is a usage
|
||||
// error (exit 4) inside an agent run too, as for the org owner verbs.
|
||||
if (!selector.given) throw usage('name the organization with --business');
|
||||
if (positional.length !== (op === 'create' ? 0 : 1)) throw usage(op === 'create' ? 'tasks create takes no task' : `tasks ${op} takes one task, task:N`);
|
||||
const body = op === 'create' ? {} : { task_ref: positional[0] };
|
||||
for (const [flag, raw] of Object.entries(values)) {
|
||||
const key = KEY[flag];
|
||||
if (key === 'assignees') body[key] = raw === '' ? [] : raw.split(',').map(r => r.trim());
|
||||
else if (key === 'priority') body[key] = /^[0-9]$/.test(raw) ? Number(raw) : raw;
|
||||
else if (key === 'due_date') body[key] = raw === 'none' ? null : raw;
|
||||
else body[key] = raw;
|
||||
}
|
||||
// The board's own check. Its org is the page's; here the organization is checked by the selector
|
||||
// and the bus, so a fixed valid id stands in for it.
|
||||
let write;
|
||||
try { write = parseTaskWrite({ op, org: 'terminal', ...body }); }
|
||||
catch (e) { if (typeof e?.status === 'number') throw usage(`tasks ${op}: ${e.message}`); throw e; }
|
||||
return { op, verb: write.verb, args: write.args, json, business: selector.given ? selector.value : undefined };
|
||||
}
|
||||
|
||||
export async function tasksWriteCommand(args, io, callFor) {
|
||||
const write = parseTasksWrite(args);
|
||||
// agent-guard (#1873): this process's own environment as well as io.env (callFor checks io.env),
|
||||
// as the org owner verbs check.
|
||||
refuseInsideAgent(process.env);
|
||||
const parsed = { positional: [], flags: new Set(), values: write.business === undefined ? {} : { '--business': write.business } };
|
||||
const call = callFor(parsed);
|
||||
let result;
|
||||
try { result = await call(write.verb, write.args); }
|
||||
catch (e) {
|
||||
if (e?.code === 'outcome-unknown' || e?.exitCode === 1) throw new CliError(`outcome unknown: the task change may or may not have been written; read it with mosaic tasks --json before trying again`, 1);
|
||||
if (typeof e?.code === 'string' && Object.hasOwn(GUIDANCE, e.code)) {
|
||||
const refused = new CliError(`tasks ${write.op} refused (${e.code}): ${GUIDANCE[e.code]}`, e.exitCode ?? 2);
|
||||
refused.code = e.code;
|
||||
throw refused;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
const taskRef = typeof result?.task_ref === 'string' ? result.task_ref : write.args.task_ref ?? null;
|
||||
const digest = typeof result?.digest === 'string' ? result.digest : null;
|
||||
// The board's answer shape (workspace-tasks.mjs), with the organization the call went to.
|
||||
const answer = { ok: true, org: parsed.business ?? write.business ?? null, op: write.op, task_ref: taskRef, digest };
|
||||
const done = { create: 'created', edit: 'changed', archive: 'archived' }[write.op];
|
||||
io.stdout.write(write.json ? `${JSON.stringify(answer, null, 2)}\n` : `${done} ${taskRef}${digest ? `, digest ${digest}` : ''}\n`);
|
||||
}
|
||||
@@ -42,6 +42,14 @@ const GUARDED = [
|
||||
['org', 'delete', 'alpha', '--confirm', 'alpha'],
|
||||
// E1 (P1-4): restore starts the organization's bus unless --no-bus is given.
|
||||
['org', 'restore', 'alpha', '--if-revision', '1'],
|
||||
// E2 (#1873, P1-3): the hire form and the autonomy panel from a terminal, reads included.
|
||||
['org', 'role-templates', 'alpha'],
|
||||
['org', 'hire', 'alpha', '--name', 'ada', '--template', 'worker', '--harness', 'pi', '--model', 'glm', '--reason', 'r'],
|
||||
['org', 'change', 'alpha', '--name', 'ada', '--template', 'worker', '--harness', 'pi', '--model', 'glm', '--reason', 'r'],
|
||||
['org', 'fire', 'alpha', '--name', 'ada', '--reason', 'r'],
|
||||
['org', 'autonomy', 'alpha'],
|
||||
['org', 'autonomy', 'alpha', 'set', '--revision', 'a'.repeat(64), '--merge', 'near'],
|
||||
['org', 'autonomy', 'alpha', 'review', 'merge'],
|
||||
];
|
||||
|
||||
// The CLI in this process, with the given io.env; stdout and stderr are collected.
|
||||
@@ -131,7 +139,7 @@ test('consoleStart refuses a marker in its own process whatever environment it i
|
||||
|
||||
test('usage errors still come first, so an agent sees the usage line, not the refusal', async () => {
|
||||
const env = { ...NOWHERE(), MOSAIC_AGENT_RUN: '1' };
|
||||
for (const args of [['bus', 'stop'], ['bus', 'start'], ['bus', 'start', 'alpha', '--pm', '--no-launch'], ['console', 'stop', 'extra'], ['console', 'restart', 'extra'], ['console', 'start', '--dev-insecure-bind', '0.0.0.0'], ['console', 'start', '--dev-allow-host', 'x'], ['console', 'start', 'extra'], ['org', 'archive', 'alpha'], ['org', 'restore', 'alpha'], ['org', 'restore', 'alpha', '--if-revision', '1', '--no-bus', '--no-bus'], ['org', 'delete', 'alpha'], ['org', 'delete', 'alpha', '--confirm', 'alpha', '--if-revision', 'x']]) {
|
||||
for (const args of [['bus', 'stop'], ['bus', 'start'], ['bus', 'start', 'alpha', '--pm', '--no-launch'], ['console', 'stop', 'extra'], ['console', 'restart', 'extra'], ['console', 'start', '--dev-insecure-bind', '0.0.0.0'], ['console', 'start', '--dev-allow-host', 'x'], ['console', 'start', 'extra'], ['org', 'archive', 'alpha'], ['org', 'restore', 'alpha'], ['org', 'restore', 'alpha', '--if-revision', '1', '--no-bus', '--no-bus'], ['org', 'delete', 'alpha'], ['org', 'delete', 'alpha', '--confirm', 'alpha', '--if-revision', 'x'], ['org', 'hire', 'alpha', '--name', 'ada'], ['org', 'fire', 'alpha'], ['org', 'autonomy', 'alpha', 'set', '--merge', 'near'], ['org', 'autonomy', 'alpha', 'review'], ['org', 'role-templates']]) {
|
||||
const r = await mosaic(env, args, 30000);
|
||||
assert.equal(r.status, 4, `${args.join(' ')}: ${r.stderr}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
// E2 (#1873, P1-3, org ruling 6): the WebUI's task board, hire form and autonomy panel from a
|
||||
// terminal. Each test sends the CLI verb and the page's own request for the same input, and checks
|
||||
// that both reach the same bus verb with the same arguments and get the same answer, a refusal
|
||||
// included. Hire and autonomy run the real scripts/mosaic against an in-process dev-owner Console
|
||||
// whose organization transports are fakes, so no bus host starts; tasks run against a real broker
|
||||
// bound to the person, as the human transport binds it.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { request } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { reserveOrg, activateOrg } from '../../organization/src/org-registry.mjs';
|
||||
import { processState } from '../../seat/src/process-state.mjs';
|
||||
import { startServer } from '../../webui/src/serve.mjs';
|
||||
import { createDevOwner } from '../../webui/src/dev-owner.mjs';
|
||||
import { BusReadError } from '../../webui/src/bus.mjs';
|
||||
import { parseTaskWrite } from '../../webui/src/workspace-tasks.mjs';
|
||||
import { close } from '../../webui/tests/fixture.mjs';
|
||||
import { main } from '../src/cli.mjs';
|
||||
import { CliError } from '../src/errors.mjs';
|
||||
import { orgCommand } from '../src/org.mjs';
|
||||
import { broker, humanEnv, io } from './helpers.mjs';
|
||||
|
||||
const REPO = new URL('../../../', import.meta.url).pathname;
|
||||
const MOSAIC = join(REPO, 'scripts/mosaic');
|
||||
const FORBIDDEN = [7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350];
|
||||
const SUBJECT = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' };
|
||||
const SHA = 'a'.repeat(64);
|
||||
|
||||
async function instance(t) {
|
||||
const home = mkdtempSync(join(tmpdir(), 'e2-'));
|
||||
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-e2test/config.json') });
|
||||
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'e2test'], { env, encoding: 'utf8' });
|
||||
assert.equal(setup.status, 0, setup.stderr);
|
||||
const ctx = { configDir: join(home, '.config/mosaic-e2test'), dataRoot: join(home, '.mosaic-e2test'), instance: 'e2test', env };
|
||||
await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha');
|
||||
t.after(() => rmSync(home, { recursive: true, force: true }));
|
||||
return { ctx, env };
|
||||
}
|
||||
function mosaic(env, args, timeout = 60000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(MOSAIC, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '', stderr = '';
|
||||
child.stdout.setEncoding('utf8').on('data', c => { stdout += c; });
|
||||
child.stderr.setEncoding('utf8').on('data', c => { stderr += c; });
|
||||
const timer = setTimeout(() => child.kill('SIGKILL'), timeout);
|
||||
child.on('error', reject);
|
||||
child.on('close', status => { clearTimeout(timer); resolve({ status, stdout, stderr }); });
|
||||
});
|
||||
}
|
||||
function recordConsole(ctx, url) {
|
||||
const dir = join(ctx.dataRoot, 'console');
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 }); chmodSync(dir, 0o700);
|
||||
const file = join(dir, 'console.json');
|
||||
writeFileSync(file, JSON.stringify({ pid: process.pid, startTime: processState(process.pid).startTime, url }), { mode: 0o600 }); chmodSync(file, 0o600);
|
||||
}
|
||||
// The page's own request: a same-origin fetch from the Console's page.
|
||||
function page(port, method, path, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = body === undefined ? undefined : JSON.stringify(body);
|
||||
const headers = { host: `127.0.0.1:${port}`, ...(payload === undefined ? {} : { origin: `http://127.0.0.1:${port}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) }) };
|
||||
request({ host: '127.0.0.1', port, method, path, headers }, res => {
|
||||
let text = ''; res.setEncoding('utf8'); res.on('data', c => { text += c; });
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(text) }));
|
||||
}).on('error', reject).end(payload);
|
||||
});
|
||||
}
|
||||
// The bus's revision is a 64-hex digest; REV(n) stands in for the nth.
|
||||
const REV = n => n.toString(16).padStart(64, '0');
|
||||
const LOOP = { 'cross-role': 'in the loop', merge: 'in the loop', policy: 'in the loop', authority: 'in the loop' };
|
||||
// One organization, alpha, whose bus transports record each call. `send` false is a Console
|
||||
// started without --send.
|
||||
function organizations({ send = true } = {}) {
|
||||
const calls = [];
|
||||
let state = { revision: REV(3), levels: { ...LOOP }, autonomy: { notices: false } };
|
||||
const org = {
|
||||
busHealthy: true,
|
||||
send: send ? async () => ({}) : null,
|
||||
decisionWrite: async (verb, args, opts) => {
|
||||
calls.push({ verb, args, opts });
|
||||
if (verb === 'hire.templates.console') return { templates: [{ name: 'worker', sha256: SHA }] };
|
||||
if (args.name === 'blocked') throw new BusReadError('definition-not-hireable');
|
||||
return { id: `hire-${calls.length}`, op: 'resolved', choice: 'yes' };
|
||||
},
|
||||
requestRead: async (verb, args, opts) => { calls.push({ verb, args, opts }); return structuredClone(state); },
|
||||
autonomyWrite: async (verb, args, opts) => {
|
||||
calls.push({ verb, args, opts });
|
||||
if (verb === 'autonomy.review.console') return { reviewed: args.decisionClass };
|
||||
if (args.revision !== state.revision) throw new BusReadError('revision-conflict');
|
||||
const { notices, reverseWindowHours, authorityResolver, ...levels } = args.autonomy;
|
||||
state = { revision: REV(parseInt(state.revision, 16) + 1), levels, autonomy: { notices, reverseWindowHours, ...(authorityResolver ? { authorityResolver } : {}) } };
|
||||
return { revision: state.revision, transaction: `tx-${parseInt(state.revision, 16)}` };
|
||||
},
|
||||
};
|
||||
return { calls, organizations: { list: async () => [{ id: 'alpha' }], resolve: async id => (id === 'alpha' ? org : null) } };
|
||||
}
|
||||
async function consoleFor(t, ctx, options) {
|
||||
const f = organizations(options);
|
||||
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), organizations: f.organizations, log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const port = server.address().port;
|
||||
assert.ok(!FORBIDDEN.includes(port));
|
||||
recordConsole(ctx, `http://127.0.0.1:${port}/`);
|
||||
return { ...f, port, server };
|
||||
}
|
||||
|
||||
const HIRE = ['--name', 'ada', '--template', 'worker', '--harness', 'pi', '--model', 'glm', '--reason', 'more hands'];
|
||||
const GUARDED = [
|
||||
['org', 'role-templates', 'alpha'],
|
||||
['org', 'hire', 'alpha', ...HIRE],
|
||||
['org', 'change', 'alpha', ...HIRE],
|
||||
['org', 'fire', 'alpha', '--name', 'ada', '--reason', 'done'],
|
||||
['org', 'autonomy', 'alpha'],
|
||||
['org', 'autonomy', 'alpha', 'set', '--revision', REV(3), '--merge', 'near'],
|
||||
['org', 'autonomy', 'alpha', 'review', 'merge'],
|
||||
];
|
||||
|
||||
test('hire and autonomy verbs: usage errors first (exit 4), then refused inside an agent run before any config is read', async () => {
|
||||
// The config path is not an instance config, so a read would refuse with another message.
|
||||
const env = { ...humanEnv({ MOSAIC_CONFIG: '/nonexistent/e2/config.json' }), CLAUDECODE: '1' };
|
||||
for (const args of GUARDED) {
|
||||
const r = await mosaic(env, args);
|
||||
assert.equal(r.status, 3, `${args.join(' ')}: ${r.stderr}`);
|
||||
assert.match(r.stderr, /outside any agent run \(found CLAUDECODE/, args.join(' '));
|
||||
assert.equal(r.stdout, '');
|
||||
}
|
||||
for (const args of [
|
||||
['org', 'hire', 'alpha', '--name', 'ada'],
|
||||
['org', 'fire', 'alpha', '--name', 'ada', '--reason', 'done', '--model', 'glm'],
|
||||
['org', 'hire', 'alpha', ...HIRE, '--level', 'project'],
|
||||
['org', 'autonomy', 'alpha', 'set', '--merge', 'near'],
|
||||
['org', 'autonomy', 'alpha', 'set', '--revision', REV(3), '--merge', 'sideways'],
|
||||
['org', 'autonomy', 'alpha', 'set', '--revision', '3', '--merge', 'near'],
|
||||
['org', 'autonomy', 'alpha', 'set', '--revision', REV(3), '--reverse-window-hours', '200'],
|
||||
['org', 'autonomy', 'alpha', 'review', 'everything'],
|
||||
['org', 'role-templates', 'Not An Id'],
|
||||
]) {
|
||||
const r = await mosaic(env, args);
|
||||
assert.equal(r.status, 4, `${args.join(' ')}: ${r.stderr}`);
|
||||
assert.doesNotMatch(r.stderr, /agent run/);
|
||||
}
|
||||
// This process's own environment counts too, as for org archive|delete: the marker is only in
|
||||
// process.env here, never in io.env.
|
||||
const saved = process.env.CLAUDECODE;
|
||||
process.env.CLAUDECODE = '1';
|
||||
try {
|
||||
for (const args of GUARDED) {
|
||||
const x = io(humanEnv({ MOSAIC_CONFIG: '/nonexistent/e2/config.json' }));
|
||||
delete x.env.CLAUDECODE;
|
||||
await assert.rejects(orgCommand(args.slice(1), x, {}), e => e instanceof CliError && e.exitCode === 3 && /outside any agent run/.test(e.message), args.join(' '));
|
||||
}
|
||||
} finally { if (saved === undefined) delete process.env.CLAUDECODE; else process.env.CLAUDECODE = saved; }
|
||||
// The default instance is a one-line refusal (exit 3), not an uncaught error.
|
||||
const d = io(humanEnv());
|
||||
delete d.env.MOSAIC_CONFIG;
|
||||
await assert.rejects(orgCommand(['autonomy', 'alpha'], d, {}), e => e instanceof CliError && e.exitCode === 3 && /requires a named instance.*--dev-local-owner/.test(e.message));
|
||||
});
|
||||
|
||||
test('role-templates, hire, change and fire send the hire form\'s request and get its answer, refusals included', { timeout: 120000 }, async t => {
|
||||
const { ctx, env } = await instance(t);
|
||||
const c = await consoleFor(t, ctx);
|
||||
const opts = { subject: SUBJECT, business: 'alpha' };
|
||||
|
||||
let r = await mosaic(env, ['org', 'role-templates', 'alpha', '--json']);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
const pageTemplates = await page(c.port, 'POST', '/api/orgs/alpha/decisions/hire/templates', { business: 'alpha' });
|
||||
assert.deepEqual(JSON.parse(r.stdout), pageTemplates.body);
|
||||
assert.deepEqual(c.calls.splice(0), [{ verb: 'hire.templates.console', args: {}, opts }, { verb: 'hire.templates.console', args: {}, opts }]);
|
||||
r = await mosaic(env, ['org', 'role-templates', 'alpha']);
|
||||
assert.equal(r.stdout, `worker ${SHA.slice(0, 12)}\n`);
|
||||
c.calls.splice(0);
|
||||
|
||||
// The form's body for this input (bus.js hireSubmit): the template it read, by name and sha256.
|
||||
const form = (op, name, extra = {}) => ({ op, level: 'org', name, reason: 'more hands', ...extra, ...(op === 'fire' ? {} : { role: { template: 'worker', sha256: SHA }, route: { harness: 'pi', model: 'glm' } }) });
|
||||
for (const op of ['hire', 'change']) {
|
||||
r = await mosaic(env, ['org', op, 'alpha', ...HIRE, '--json']);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
const cli = c.calls.splice(0);
|
||||
assert.deepEqual(cli.map(x => x.verb), ['hire.templates.console', 'hire.start.console']);
|
||||
const pageAnswer = await page(c.port, 'POST', '/api/orgs/alpha/decisions/hire/start', { business: 'alpha', hire: form(op, 'ada') });
|
||||
const pageCall = c.calls.splice(0);
|
||||
assert.deepEqual(cli[1], pageCall[0]);
|
||||
assert.deepEqual(cli[1].args, form(op, 'ada'));
|
||||
assert.equal(pageAnswer.status, 200);
|
||||
// Each decision gets its own id; the rest of the answer is the page's.
|
||||
assert.deepEqual({ ...JSON.parse(r.stdout), id: 'x' }, { ...pageAnswer.body, id: 'x' });
|
||||
}
|
||||
r = await mosaic(env, ['org', 'fire', 'alpha', '--name', 'ada', '--reason', 'more hands', '--level', 'project', '--project', 'site']);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /^recorded decision hire-1: fire ada \(resolved yes\)\n$/);
|
||||
assert.deepEqual(c.calls.splice(0), [{ verb: 'hire.start.console', args: { op: 'fire', level: 'project', name: 'ada', reason: 'more hands', project: 'site' }, opts }]);
|
||||
|
||||
// A bus refusal reaches the terminal with the Console's code and message, as the form shows it.
|
||||
r = await mosaic(env, ['org', 'hire', 'alpha', ...HIRE.map(a => (a === 'ada' ? 'blocked' : a))]);
|
||||
const pageRefusal = await page(c.port, 'POST', '/api/orgs/alpha/decisions/hire/start', { business: 'alpha', hire: form('hire', 'blocked') });
|
||||
assert.equal(pageRefusal.status, 409);
|
||||
assert.equal(r.status, 3, r.stderr);
|
||||
assert.ok(r.stderr.includes(`hire refused (definition-not-hireable): ${pageRefusal.body.message}`), r.stderr);
|
||||
c.calls.splice(0);
|
||||
// A template the Console did not offer is refused before anything is raised.
|
||||
r = await mosaic(env, ['org', 'hire', 'alpha', ...HIRE.map(a => (a === 'worker' ? 'ghost' : a))]);
|
||||
assert.equal(r.status, 2, r.stderr);
|
||||
assert.match(r.stderr, /no role template "ghost"/);
|
||||
assert.deepEqual(c.calls.splice(0).map(x => x.verb), ['hire.templates.console']);
|
||||
|
||||
// A Console without --send: the page's refusal, and nothing reaches the bus.
|
||||
await close(c.server);
|
||||
const ro = await consoleFor(t, ctx, { send: false });
|
||||
r = await mosaic(env, ['org', 'hire', 'alpha', ...HIRE]);
|
||||
const pageRo = await page(ro.port, 'POST', '/api/orgs/alpha/decisions/hire/templates', { business: 'alpha' });
|
||||
assert.equal(r.status, 3, r.stderr);
|
||||
assert.match(r.stderr, new RegExp(`hire refused \\(${pageRo.body.error}\\)`));
|
||||
assert.equal(pageRo.body.error, 'read-only');
|
||||
assert.deepEqual(ro.calls, []);
|
||||
// An organization the Console does not know is refused as the page is.
|
||||
r = await mosaic(env, ['org', 'role-templates', 'beta']);
|
||||
assert.equal(r.status, 3, r.stderr);
|
||||
assert.match(r.stderr, /role-templates refused \(org-unavailable\)/);
|
||||
});
|
||||
|
||||
test('autonomy reads, saves with the panel\'s revision check and records a review through the panel\'s route', { timeout: 120000 }, async t => {
|
||||
const { ctx, env } = await instance(t);
|
||||
const c = await consoleFor(t, ctx);
|
||||
const opts = { subject: SUBJECT };
|
||||
|
||||
let r = await mosaic(env, ['org', 'autonomy', 'alpha', '--json']);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.deepEqual(JSON.parse(r.stdout), (await page(c.port, 'GET', '/api/orgs/alpha/autonomy')).body);
|
||||
r = await mosaic(env, ['org', 'autonomy', 'alpha']);
|
||||
assert.equal(r.stdout, `revision ${REV(3)}\ncross-role: in the loop\nmerge: in the loop\npolicy: in the loop\nauthority: in the loop\nnotices: off\nreverse window: not set (the page offers 24 hours)\nauthority resolver: none\n`);
|
||||
c.calls.splice(0);
|
||||
|
||||
// The panel's save for "merge near the loop" (autonomy.js): every class as shown, notices, the
|
||||
// window it offers (24) and no resolver.
|
||||
const panel = { ...LOOP, merge: 'near the loop', notices: false, reverseWindowHours: 24 };
|
||||
// Imported here so that the other tests still run, and fail on their own, on a base without it.
|
||||
const { autonomyDocument } = await import('../src/org-owner.mjs');
|
||||
assert.deepEqual(autonomyDocument({ revision: REV(3), levels: LOOP, autonomy: { notices: false } }, { merge: 'near the loop' }), panel);
|
||||
// An emptied resolver field: the panel leaves it out of the save, and so does the terminal.
|
||||
assert.deepEqual(autonomyDocument({ revision: REV(3), levels: LOOP, autonomy: { notices: false, authorityResolver: 'ops' } }, { authorityResolver: '' }), { ...LOOP, notices: false, reverseWindowHours: 24 });
|
||||
assert.equal(autonomyDocument({ revision: REV(3), levels: LOOP, autonomy: { notices: false, authorityResolver: 'ops' } }, {}).authorityResolver, 'ops');
|
||||
r = await mosaic(env, ['org', 'autonomy', 'alpha', 'set', '--revision', REV(3), '--merge', 'near', '--json']);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.deepEqual(JSON.parse(r.stdout), { revision: REV(4), transaction: 'tx-4', writable: true });
|
||||
assert.deepEqual(c.calls.splice(0), [{ verb: 'autonomy.get.console', args: {}, opts }, { verb: 'autonomy.set.console', args: { revision: REV(3), autonomy: panel }, opts }]);
|
||||
// The page's save of the same document at revision 4 lands the same way.
|
||||
const pageSave = await page(c.port, 'POST', '/api/orgs/alpha/autonomy', { revision: REV(4), autonomy: panel });
|
||||
assert.deepEqual(pageSave.body, { revision: REV(5), transaction: 'tx-5', writable: true });
|
||||
assert.deepEqual(c.calls.splice(0), [{ verb: 'autonomy.set.console', args: { revision: REV(4), autonomy: panel }, opts }]);
|
||||
|
||||
// A stale revision is the panel's revision-conflict, and nothing changes.
|
||||
r = await mosaic(env, ['org', 'autonomy', 'alpha', 'set', '--revision', REV(3), '--policy', 'near']);
|
||||
const pageStale = await page(c.port, 'POST', '/api/orgs/alpha/autonomy', { revision: REV(3), autonomy: panel });
|
||||
assert.deepEqual(pageStale, { status: 409, body: { error: 'revision-conflict' } });
|
||||
assert.equal(r.status, 3, r.stderr);
|
||||
assert.match(r.stderr, /autonomy set refused \(revision-conflict\): the organization changed; read it again with mosaic org autonomy ID/);
|
||||
assert.equal((await page(c.port, 'GET', '/api/orgs/alpha/autonomy')).body.revision, REV(5));
|
||||
c.calls.splice(0);
|
||||
|
||||
r = await mosaic(env, ['org', 'autonomy', 'alpha', 'review', 'merge']);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(r.stdout, 'recorded your trail review for merge on alpha\n');
|
||||
await page(c.port, 'POST', '/api/orgs/alpha/autonomy/review', { decisionClass: 'merge' });
|
||||
const [cli, pageReview] = c.calls.splice(0);
|
||||
assert.deepEqual(cli, pageReview);
|
||||
assert.deepEqual(cli, { verb: 'autonomy.review.console', args: { decisionClass: 'merge' }, opts });
|
||||
|
||||
// Without --send the read still answers and both writes are the page's read-only refusal.
|
||||
await close(c.server);
|
||||
const ro = await consoleFor(t, ctx, { send: false });
|
||||
r = await mosaic(env, ['org', 'autonomy', 'alpha', '--json']);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(JSON.parse(r.stdout).writable, false);
|
||||
for (const args of [['set', '--revision', REV(3), '--merge', 'near'], ['review', 'merge']]) {
|
||||
r = await mosaic(env, ['org', 'autonomy', 'alpha', ...args]);
|
||||
assert.equal(r.status, 3, r.stderr);
|
||||
assert.match(r.stderr, /refused \(read-only\): start the Console with --send/);
|
||||
}
|
||||
assert.deepEqual((await page(ro.port, 'POST', '/api/orgs/alpha/autonomy/review', { decisionClass: 'merge' })).body, { error: 'read-only' });
|
||||
assert.deepEqual(ro.calls.map(x => x.verb), ['autonomy.get.console', 'autonomy.get.console']);
|
||||
});
|
||||
|
||||
// mosaic tasks create|edit|archive with the person's transport over a real broker.
|
||||
async function run(argv, s) {
|
||||
const x = io();
|
||||
try { await main(argv, x, { system: { dataRoot: s.dataRoot }, transport: s.transport }); return { code: 0, out: x.out.text, err: '' }; }
|
||||
catch (e) { if (!(e instanceof CliError)) throw e; return { code: e.exitCode, out: x.out.text, err: e.message }; }
|
||||
}
|
||||
|
||||
test('tasks create, edit and archive send the board\'s writes to the bus as the person', async t => {
|
||||
const b = broker(t);
|
||||
const s = { ...b, dataRoot: mkdtempSync(join(tmpdir(), 'e2t-')) };
|
||||
t.after(() => rmSync(s.dataRoot, { recursive: true, force: true }));
|
||||
// The board's request for the same input, through the board's own check.
|
||||
const board = body => { const w = parseTaskWrite({ org: 'demo', ...body }); return { verb: w.verb, args: w.args }; };
|
||||
|
||||
let r = await run(['tasks', 'create', '--title', 'Plan the store', '--requirement', 'REQ-TASK-1', '--assignees', 'coder', '--priority', '2', '--business', 'demo', '--json'], s);
|
||||
assert.equal(r.code, 0, r.err);
|
||||
const created = JSON.parse(r.out);
|
||||
assert.deepEqual(s.calls.splice(0).map(({ verb, args }) => ({ verb, args })), [board({ op: 'create', title: 'Plan the store', requirement: 'REQ-TASK-1', assignees: ['coder'], priority: 2 })]);
|
||||
assert.deepEqual({ ...created, digest: 'd' }, { ok: true, org: 'demo', op: 'create', task_ref: 'task:1', digest: 'd' });
|
||||
const row = () => s.read('tasks').find(x => x.task_ref === 'task:1');
|
||||
assert.equal(created.digest, row().digest);
|
||||
assert.equal(row().fields.title, 'Plan the store');
|
||||
assert.deepEqual(row().fields.assignees, ['coder']);
|
||||
|
||||
r = await run(['tasks', 'edit', 'task:1', '--expect', created.digest, '--state', 'in-progress', '--due', 'none', '--business', 'demo'], s);
|
||||
assert.equal(r.code, 0, r.err);
|
||||
assert.equal(r.out, `changed task:1, digest ${row().digest}\n`);
|
||||
assert.deepEqual(s.calls.splice(0).map(({ verb, args }) => ({ verb, args })), [board({ op: 'edit', task_ref: 'task:1', expect: created.digest, state: 'in-progress', due_date: null })]);
|
||||
assert.equal(row().fields.state, 'in-progress');
|
||||
|
||||
// A stale digest is the board's task-conflict, and nothing is written.
|
||||
const before = row();
|
||||
r = await run(['tasks', 'archive', 'task:1', '--expect', created.digest, '--business', 'demo'], s);
|
||||
assert.equal(r.code, 2, r.err);
|
||||
assert.match(r.err, /^tasks archive refused \(task-conflict\): the task changed after you read it/);
|
||||
assert.deepEqual(row(), before);
|
||||
s.calls.splice(0);
|
||||
|
||||
r = await run(['tasks', 'archive', 'task:1', '--expect', before.digest, '--business', 'demo'], s);
|
||||
assert.equal(r.code, 0, r.err);
|
||||
assert.deepEqual(s.calls.splice(0).map(({ verb, args }) => ({ verb, args })), [board({ op: 'archive', task_ref: 'task:1', expect: before.digest })]);
|
||||
assert.match(r.out, /^archived task:1, digest [0-9a-f]{64}\n$/);
|
||||
|
||||
// What the board refuses with 400 the terminal refuses as usage (exit 4), with the board's words,
|
||||
// before anything is sent.
|
||||
for (const [argv, body] of [
|
||||
[['tasks', 'create', '--title', 'x', '--business', 'demo'], { op: 'create', title: 'x' }],
|
||||
[['tasks', 'edit', 'task:1', '--expect', 'nope', '--title', 'y', '--business', 'demo'], { op: 'edit', task_ref: 'task:1', expect: 'nope', title: 'y' }],
|
||||
[['tasks', 'archive', 'task:1', '--business', 'demo'], { op: 'archive', task_ref: 'task:1' }],
|
||||
]) {
|
||||
let boardError;
|
||||
try { parseTaskWrite({ org: 'demo', ...body }); } catch (e) { boardError = e; }
|
||||
assert.equal(boardError?.status, 400, JSON.stringify(body));
|
||||
r = await run(argv, s);
|
||||
assert.equal(r.code, 4, argv.join(' '));
|
||||
assert.ok(r.err.includes(boardError.message), `${r.err} / ${boardError.message}`);
|
||||
}
|
||||
assert.deepEqual(s.calls, []);
|
||||
// Terminal-only slips the board cannot make are usage too: an option-looking word is not a value,
|
||||
// and the organization is named (exit 4 even inside an agent run).
|
||||
for (const [argv, env] of [
|
||||
[['tasks', 'create', '--title', '--json', '--requirement', 'REQ-TASK-1', '--business', 'demo'], humanEnv()],
|
||||
[['tasks', 'create', '--title', 'x', '--requirement', 'REQ-TASK-1'], { ...humanEnv(), CLAUDECODE: '1' }],
|
||||
]) {
|
||||
const y = io(env);
|
||||
await assert.rejects(main(argv, y, { system: { dataRoot: s.dataRoot }, transport: s.transport }), e => e instanceof CliError && e.exitCode === 4, argv.join(' '));
|
||||
}
|
||||
assert.deepEqual(s.calls, []);
|
||||
// A marker only in this process's own environment counts too.
|
||||
const savedMarker = process.env.CLAUDECODE;
|
||||
process.env.CLAUDECODE = '1';
|
||||
try {
|
||||
const y = io(humanEnv());
|
||||
delete y.env.CLAUDECODE;
|
||||
await assert.rejects(main(['tasks', 'create', '--title', 'x', '--requirement', 'REQ-TASK-1', '--business', 'demo'], y, { system: { dataRoot: s.dataRoot }, transport: s.transport }), e => e instanceof CliError && e.exitCode === 3 && /outside any agent run/.test(e.message));
|
||||
} finally { if (savedMarker === undefined) delete process.env.CLAUDECODE; else process.env.CLAUDECODE = savedMarker; }
|
||||
assert.deepEqual(s.calls, []);
|
||||
// Inside an agent run the write is refused before anything is sent.
|
||||
const x = io({ ...humanEnv(), CLAUDECODE: '1' });
|
||||
await assert.rejects(main(['tasks', 'create', '--title', 'x', '--requirement', 'REQ-TASK-1', '--business', 'demo'], x, { system: { dataRoot: s.dataRoot }, transport: s.transport }), e => e instanceof CliError && e.exitCode === 3);
|
||||
assert.deepEqual(s.calls, []);
|
||||
});
|
||||
@@ -22,7 +22,9 @@ baseline CLI.
|
||||
| Start, stop or restart a bus | `mosaic bus start <id>` / `mosaic bus stop <id>` / `mosaic bus restart <id>` | S6 integration hook |
|
||||
| Restart (or first start) an organization's bus | `mosaic bus restart <id>`, or `mosaic bus start <id>` for a bus never started | Enabled (F3): the owner's **Restart bus** control, offered where a page says the bus is down or a change needs a restart; trailed |
|
||||
| Restart the Console | Stop and run `mosaic console start` again | Enabled (F3) when `mosaic console start` runs it in the foreground, or with `--dev-local-owner --detach` under a detached supervisor (D1): Settings > Console; the owner only; trailed. Other Consoles answer `not-supervised` |
|
||||
| Save, review, confirm or reverse an organization's autonomy | Save only: edit the autonomy block and run `mosaic org update ORG --from FILE --if-revision N`; no autonomy verb yet (readiness P1-3) | Enabled (D1) on a `--dev-local-owner` Console: the owner only, on the selected organization's bus, behind a fence that passes only the four console verbs `autonomy.set.console`, `autonomy.review.console`, `decision.confirm.console` and `decision.reverse.console` |
|
||||
| Save, review, confirm or reverse an organization's autonomy | Read, save and review: `mosaic org autonomy ORG`, `mosaic org autonomy ORG set --revision REV [--cross-role|--merge|--policy|--authority in|near|out] [--notices on|off] [--reverse-window-hours 1-168] [--authority-resolver NAME|""]`, `mosaic org autonomy ORG review CLASS` (E2, through this route and its revision check); confirm and reverse act on inbox decisions and have no verb yet | Enabled (D1) on a `--dev-local-owner` Console: the owner only, on the selected organization's bus, behind a fence that passes only the four console verbs `autonomy.set.console`, `autonomy.review.console`, `decision.confirm.console` and `decision.reverse.console` |
|
||||
| Hire, fire or change an agent; read role templates | `mosaic org role-templates ORG`; `mosaic org hire|change ORG --name NAME --template TEMPLATE --harness H --model M [--thinking T] [--level org|project|root] [--project P] --reason TEXT`; `mosaic org fire ORG --name NAME [--level ...] --reason TEXT` (E2, through this route) | Enabled (D4) for the owner on a selected organization's bus: one decision raised and approved through `hire.start.console`, with the bus's hire checks |
|
||||
| Create, edit or archive a board task | `mosaic tasks create --title T --requirement REQ [--project P] [--state S] [--assignees R,...] [--priority 0-5] [--due ISO|none]`; `mosaic tasks edit task:N --expect DIGEST ...`; `mosaic tasks archive task:N --expect DIGEST` (E2: the board's own check, then the person's `tasks.create|edit|archive` on the bus) | Enabled for the owner on an organization with the built-in task store |
|
||||
| Create chat project linked to a configured repository | `mosaic project create <name> --repo <project> --org <id>` | Enabled |
|
||||
| Create a thread | `mosaic thread create <project> <name> --org <id>` | Planned enabled |
|
||||
| Open a thread | `mosaic thread open <thread> --org <id>` | Planned enabled |
|
||||
|
||||
Reference in new issue
Block a user