cli, organization: org config and org instances commands, old business spelling kept (rename r2 stage B2)
mosaic org config validate|resolve runs runConfigCommand (packages/organization/src/config-command.mjs), the callable service the old mosaic business validate|resolve executable now calls too. The service returns the exit code, never exits the process and does nothing on import. The spelling sets only the stderr prefix and the usage text; status and stdout are byte-identical, and the v1 output keeps the business key. mosaic org instances|instance|veto takes the same arguments as mosaic business instances|instance|veto and runs the same proof-bearing instances command. It is dispatched before orgCommand, so it never builds the dev-local-owner context. mosaic org validate FILE is unchanged. Help, the UI labels for agent actions and instance reasons, and the current docs now use the org spelling and the packages/organization paths. Two webui test regexes follow the label text. packages/cli/tests/ rename-b-commands.test.mjs compares the two spellings golden for golden on status, stdout, stderr and authorization outcomes, and covers a draft file named like an installed ID.
This commit is contained in:
1 parent
a8d9eecf65
commit
13c75b699f
21 files changed
+670
-191
No files matched your search
+15
-7
@@ -251,16 +251,24 @@ Exit codes: `0` ok · `1` failed · `2` invalid or refused · `3` uncertain,
|
||||
retry the same op (for a review request, the retry sends nothing; resolve or
|
||||
abandon it) · `4` usage. Details: `packages/queue/README.md`.
|
||||
|
||||
## Business files and role instances (`scripts/mosaic business`)
|
||||
## Organization files and role instances (`scripts/mosaic org config`, `scripts/mosaic org instances`)
|
||||
|
||||
```bash
|
||||
scripts/mosaic business validate <business>
|
||||
scripts/mosaic business resolve <business> <instance> [--project <id>]
|
||||
scripts/mosaic business instances <business> [--json]
|
||||
scripts/mosaic business instance add <business> <instance> --definition <def> [--vars <file>] [--launch] --revision <rev>
|
||||
scripts/mosaic business instance set <business> <instance> [--definition <def>] [--vars <file>] [--launch | --no-launch] --revision <rev>
|
||||
scripts/mosaic org config validate <org>
|
||||
scripts/mosaic org config resolve <org> <instance> [--project <id>]
|
||||
scripts/mosaic org instances <org> [--json]
|
||||
scripts/mosaic org instance add <org> <instance> --definition <def> [--vars <file>] [--launch] --revision <rev>
|
||||
scripts/mosaic org instance set <org> <instance> [--definition <def>] [--vars <file>] [--launch | --no-launch] --revision <rev>
|
||||
```
|
||||
|
||||
`scripts/mosaic business validate|resolve|instances|instance|veto` is the old
|
||||
spelling of these commands, with the same arguments, and it still works. Both
|
||||
spellings run the same code and give the same status and stdout. The
|
||||
validate/resolve stderr lines start with the spelling you used
|
||||
(`mosaic org config: ` or `mosaic business: `), and a usage error prints that
|
||||
spelling's usage. `scripts/mosaic org validate FILE` is a different command: it
|
||||
checks a draft file by path and prints `valid`.
|
||||
|
||||
Reads `businesses/<business>.json` next to the system config
|
||||
(`$MOSAIC_CONFIG` or `~/.config/mosaic-dev/`), the version 2 role files in
|
||||
`roles/` (or `$MOSAIC_ROLES_DIR`) and each project's `.mosaic/project.json`.
|
||||
@@ -271,7 +279,7 @@ its narrowed tools, network and authority, and its credential references.
|
||||
The business file must belong to you and not be group or other writable.
|
||||
Exit codes: `0` ok · `2` invalid file or credential reference · `3` system
|
||||
config problem · `4` usage or a required file missing. Details:
|
||||
`packages/business/README.md`.
|
||||
`packages/organization/README.md`.
|
||||
|
||||
`mosaic credentials status|issue|rotate|revoke|delete` (row 199) goes to
|
||||
`packages/cli` and the bus's human-only credential verbs: an agent's own
|
||||
|
||||
@@ -157,7 +157,7 @@ Kinds of work:
|
||||
| Talk `#/talk` | the conversation History view exists; the live PM session is row 41 | blocked | Row 41 (S6). Reuse the controller/observer rules of `packages/conversation` |
|
||||
| Queue `#/queue` | `packages/queue/src/store.mjs` `list`/`show`; no endpoint | new view | Read-only. Rows move only through `queue move` |
|
||||
| Ledger `#/ledger` | `packages/ledger` `summarize`, `queueChecks` | new view | Read-only |
|
||||
| Business `#/business` | `packages/business` `loadBusiness`, `loadRole`, `resolveInstance`, `vocabulary.mjs` | new view | Read-only. A missing or invalid file shows the refusal state. Credential refs as metadata only |
|
||||
| Business `#/business` | `packages/organization` `loadBusiness`, `loadRole`, `resolveInstance`, `vocabulary.mjs` | new view | Read-only. A missing or invalid file shows the refusal state. Credential refs as metadata only |
|
||||
| Runs and releases `#/runs` | readers live inside `scripts/mosaic-task.mjs` and `scripts/release.sh`, not exported | backend | Extract a read-only module first |
|
||||
| Library `#/library` | no meta-harness in committed code | blocked | Row 41 (S6) creates `packages/harness` |
|
||||
| Settings `#/settings` | appearance exists in the webui header; notifier config `readNotifyConfig` | extend | Appearance, notifications (binding names only), about (loopback, release). Read-only apart from appearance |
|
||||
|
||||
@@ -327,7 +327,7 @@ After install, `stack-copy.sh check --units external` reads the copy the units r
|
||||
|
||||
Before running `case2-install.sh`, check the exact file you will pass as `--business-file`. Each
|
||||
non-library `projects` key must equal the `id` in `<root>/.mosaic/project.json`, and that file must exist
|
||||
in the stack's user's clone. Library entries are skipped by validate (`packages/business/src/cli.mjs:72`).
|
||||
in the stack's user's clone. Library entries are skipped by validate (`packages/organization/src/config-command.mjs:95`).
|
||||
Run this read-only check as `mosaic` with section 2's pinned node:
|
||||
|
||||
```sh
|
||||
@@ -356,7 +356,7 @@ MOSAIC_PROJECT_KEYS
|
||||
For .53 expect `ok project stack: /srv/mosaic/src/mosaic-stack/.mosaic/project.json has id stack`
|
||||
(`.mosaic/project.json:3`, `docs/guides/case1-business.mosaic-stack.json:10-11`). Any STOP or nonzero exit
|
||||
stops the install. Fix a mismatched business key, not the project id (mos-1563). The install otherwise
|
||||
reaches that refusal at step 7, after making the copy (`packages/business/src/cli.mjs:80`,
|
||||
reaches that refusal at step 7, after making the copy (`packages/organization/src/config-command.mjs:103`,
|
||||
`scripts/case2-install.sh:178`, `scripts/case2-install.sh:217`).
|
||||
|
||||
The stack's user's `scripts/case2-install.sh` (C2-13b, design 10 step 2b) makes the first copy with
|
||||
@@ -365,7 +365,7 @@ harness, so the host needs to reach the npm registry during the install. The ins
|
||||
`release.sh package` or `activate`.
|
||||
|
||||
A missing `.mosaic/project.json` only warns: `project <id>: no project file at <path>; it has no project
|
||||
variables`. Validate reports that project `absent` and continues (`packages/business/src/cli.mjs:74-78`).
|
||||
variables`. Validate reports that project `absent` and continues (`packages/organization/src/config-command.mjs:97-101`).
|
||||
An install can therefore pass step 7 without the project's variables. It captures validate's output
|
||||
and prints `business <id> validates` (`scripts/case2-install.sh:217-221`). Treat the missing-file warning as a stop,
|
||||
place the correct project file and repeat the check above before continuing (Duckworth 323 N2;
|
||||
|
||||
@@ -718,7 +718,7 @@ keys with `invalid-request` before the writer runs (sage-0918). `instances` take
|
||||
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
|
||||
organization package's `instance-settings.mjs` writes through the organization writer, which
|
||||
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
|
||||
@@ -731,8 +731,8 @@ tester may send `routes.set` (row 175), logged with actor `tester`; its `routes`
|
||||
`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;
|
||||
`routes.set` takes exactly `{revision,routes}`. The business package's `launch-settings.mjs`
|
||||
writes them with the human as actor (`packages/business/README.md`, "Routes"). `harness-off`,
|
||||
`routes.set` takes exactly `{revision,routes}`. The organization package's `launch-settings.mjs`
|
||||
writes them with the human as actor (`packages/organization/README.md`, "Routes"). `harness-off`,
|
||||
`harness-refused` and `thinking-unsupported` pass through as they are, from `routes.set`,
|
||||
`instances.set` and `organization.set` alike. The runtime builds the write checks' `levels`
|
||||
from `harnessLevels()`, which loads each harness module in the tree once at start, switched on
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/config-command.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/config-command.mjs";
|
||||
+32
-13
@@ -192,9 +192,10 @@ mosaic launches list [--json]
|
||||
(`tester-off`), each exit 3. Like the human commands, `--tester` works
|
||||
only from the tree the bus runs from ("One pinned copy" below).
|
||||
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
|
||||
`limits`, `plugins`, `threads`, `project list` and `org instances` (old
|
||||
spelling `business instances`), which print what the human's forms print,
|
||||
and the writes `plugins set`, `org instance add` and `set` (old spelling
|
||||
`business instance`), `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
|
||||
@@ -509,7 +510,7 @@ threads (`threadsState`) and acts on each one:
|
||||
tick. Each new message or open starts at most one session: a session
|
||||
that ends without taking the work waits for the next message or open.
|
||||
- A running thread session stops once the thread has had no message or
|
||||
open for `launch.threadIdleSeconds` (`packages/business/README.md`) and
|
||||
open for `launch.threadIdleSeconds` (`packages/organization/README.md`) and
|
||||
the run is between turns. The broker's `threadIdle` decides that from
|
||||
the store, not from the tick's snapshot: it releases the claim only with
|
||||
nothing delivered and unread and nothing waiting. If a message came in
|
||||
@@ -1010,13 +1011,20 @@ the re-pin follows in the same window (decision 145).
|
||||
Row 165 (#1662, Q171, REQ-ROLE-3).
|
||||
|
||||
```
|
||||
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]
|
||||
mosaic org instances ORG [--json] [--tester]
|
||||
mosaic org instance add ORG INSTANCE --definition DEF [--vars FILE] [--launch] --revision REV [--tester]
|
||||
mosaic org instance set ORG INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV [--tester]
|
||||
```
|
||||
|
||||
`scripts/mosaic` sends `business instances` and `business instance` here and
|
||||
every other `business` command to `packages/business`. `instances` prints the
|
||||
`mosaic business instances|instance|veto` is the old spelling, with the same
|
||||
arguments. `scripts/mosaic` sends `business instances`, `business instance`
|
||||
and `business veto` here and every other `business` command to
|
||||
`packages/organization`; `org instances|instance|veto` come here with the rest
|
||||
of `org`. Both spellings run `instancesCommand` (`src/instances.mjs`) over the
|
||||
human-proof bus transport, so the principal checks, refusals, output and exit
|
||||
codes are the same. Only a usage error differs: it prints the usage of the
|
||||
spelling you used. These commands never use the dev-local-owner context that
|
||||
`mosaic org create|show|update` use. `instances` prints the
|
||||
file's revision, each instance with its definition and its marks (`launcher`,
|
||||
`launched`, `protected`), the definitions `add` and `set` accept, and the
|
||||
definitions that declare credentials. `add` and `set` send the bus's
|
||||
@@ -1025,7 +1033,7 @@ human-only `instances.set` verb. `set` needs at least one of `--definition`,
|
||||
instance's vars whole. `--launch` and `--no-launch` add the instance to
|
||||
`launch.instances` or remove it.
|
||||
|
||||
The business package's instance writer runs the checks `mosaic business
|
||||
The organization package's instance writer runs the checks `mosaic org config
|
||||
validate` runs before it writes, and logs the change in `changes.jsonl` with
|
||||
the human as actor. It refuses, and the file keeps its bytes:
|
||||
|
||||
@@ -1037,7 +1045,7 @@ the human as actor. It refuses, and the file keeps its bytes:
|
||||
| `organization-definition-refused` | an assistant definition or instance; those change through `mosaic limits set` |
|
||||
| `organization-launch-not-configured` | a launch flag when the file has no launch block |
|
||||
| `organization-no-change` | the file already says that |
|
||||
| `organization-project-mismatch`, `organization-unresolvable-instance` | the change would fail `mosaic business validate` against a project file |
|
||||
| `organization-project-mismatch`, `organization-unresolvable-instance` | the change would fail `mosaic org config validate` against a project file |
|
||||
| `revision-conflict` | the file changed since `--revision` |
|
||||
|
||||
The bus sends a refusal's code alone, so the command adds the reason. A
|
||||
@@ -1242,14 +1250,25 @@ local --output FILE`, `mosaic org validate FILE`, and `mosaic org create ID
|
||||
--if-revision N` requires the revision from show. Each configured project gets
|
||||
its own derived root.
|
||||
|
||||
`mosaic org validate FILE` checks a draft by its path and prints `valid`; it
|
||||
never looks up an installed organization. `mosaic org config validate ID` and
|
||||
`mosaic org config resolve ID INSTANCE [--project ID]` read the installed
|
||||
organization `ID` and print the same JSON as `mosaic business validate|resolve`
|
||||
(`packages/organization/README.md`). A draft file in the working directory
|
||||
named like an installed ID changes neither: each command reads only its own
|
||||
kind of input. `mosaic org instances|instance|veto` are the role instance
|
||||
commands under "Role instances" above. The config and instances commands
|
||||
never use the dev-local-owner context, so they don't need a named development
|
||||
instance.
|
||||
|
||||
`mosaic org queue init ID --local-only --accept-local-loss-risk` initializes
|
||||
its checkout; `mosaic org project repo init ID PROJECT --local-only
|
||||
--accept-local-loss-risk` initializes a project. For either command `--remote
|
||||
URL` selects remote mode instead. These commands never push or overwrite an
|
||||
existing repository. Local-only acceptance records the risk of losing the only
|
||||
copy. Read back with `mosaic org queue show ID [--json]` or `mosaic org project
|
||||
repo show ID PROJECT [--json]`. See the Business README for the layout and
|
||||
scope. On existing CLI commands, `--org ID` is an alias for `--business ID`;
|
||||
repo show ID PROJECT [--json]`. See `packages/organization/README.md` for the
|
||||
layout and scope. On existing CLI commands, `--org ID` is an alias for `--business ID`;
|
||||
if both are supplied they must name the same ID.
|
||||
|
||||
`mosaic org delete ID --confirm ID --if-revision N [--json]` stops the
|
||||
|
||||
@@ -21,11 +21,11 @@ import { proxyCommand } from "./model-proxy.mjs";
|
||||
import { credentialsCommand, CREDENTIALS_USAGE } from "./credentials.mjs";
|
||||
import { hostsCommand, HOSTS_USAGE } from "./hosts.mjs";
|
||||
import { grantsCommand, GRANTS_USAGE } from "./grants.mjs";
|
||||
import { instancesCommand, INSTANCES_USAGE } from "./instances.mjs";
|
||||
import { instancesCommand, orgInstancesCommand, ORG_INSTANCES_USAGE } from "./instances.mjs";
|
||||
import { accessCommand, accessContextFor } from "./access.mjs";
|
||||
import { createChannelCaller } from "../../plugins/src/channel.mjs";
|
||||
import { PluginError } from "../../plugins/src/kinds.mjs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
import { CliError, CliExit } from "./errors.mjs";
|
||||
import { readNotifyConfig } from "./notify-config.mjs";
|
||||
export { readNotifyConfig } from "./notify-config.mjs";
|
||||
import { DEV_OWNER, devOwnerInstance, devLanOptions } from "./dev-local-owner.mjs";
|
||||
@@ -46,6 +46,7 @@ import { consoleStart, consoleStatus, consoleStop } from "./org-console.mjs";
|
||||
|
||||
import { usageCommand, USAGE_USAGE } from "./usage.mjs";
|
||||
import { orgCommand, ORG_USAGE } from "./org.mjs";
|
||||
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";
|
||||
|
||||
@@ -58,7 +59,8 @@ export const USAGE = `usage:
|
||||
mosaic proxy fetch | fence status [--json] | fence clear --proxy-restarted | init [--port N] [--business ID] [--rotate-grace-minutes N] [--first-output-seconds N]
|
||||
${HOSTS_USAGE}
|
||||
${GRANTS_USAGE}
|
||||
${INSTANCES_USAGE}
|
||||
${ORG_INSTANCES_USAGE}
|
||||
mosaic business validate|resolve|instances|instance|veto: the old spelling of mosaic org config validate|resolve and mosaic org instances|instance|veto, same arguments
|
||||
${PROJECT_USAGE}
|
||||
${PROJECT_BUS_USAGE}
|
||||
${THREADS_USAGE}
|
||||
@@ -686,9 +688,22 @@ async function app(io, deps) {
|
||||
io.stdout.write(`${quitLine(result)}\n`);
|
||||
}
|
||||
|
||||
// `mosaic org config validate|resolve`: the service writes its own stdout and stderr lines, prefixed
|
||||
// `mosaic org config: `, and returns the exit code; run() turns a CliExit into that code.
|
||||
function orgConfig(args, io) {
|
||||
const code = runConfigCommand(args, { stdout: io.stdout, stderr: io.stderr, env: io.env, spelling: ORG_CONFIG });
|
||||
if (code !== 0) throw new CliExit(code);
|
||||
}
|
||||
|
||||
export async function main(argv, io = { env: process.env, stdin: process.stdin, stdout: process.stdout, stderr: process.stderr }, deps = {}) {
|
||||
if (argv.length === 0) return app(io, deps);
|
||||
const [verb, ...rest] = argv;
|
||||
// Rename r2 stage B: `org config validate|resolve` runs the installed-config service that
|
||||
// `mosaic business validate|resolve` runs, and `org instances|instance|veto` the proof-bearing
|
||||
// instances command `mosaic business` runs below. Neither reaches the dev-owner context orgCommand
|
||||
// builds; `org validate FILE` stays orgCommand's draft check.
|
||||
if (verb === 'org' && rest[0] === 'config') return orgConfig(rest.slice(1), io);
|
||||
if (verb === 'org' && ["instances", "instance", "veto"].includes(rest[0])) return orgInstancesCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
if (verb === 'org') return orgCommand(rest, io, deps);
|
||||
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
|
||||
if (verb === "project") return ["create", "list", "archive", "unarchive"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps));
|
||||
@@ -714,7 +729,9 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
if (verb === "hosts") return hostsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
// Row 236 (#1742, #436b r5): the Gitea grant path; admin set reads the admin token on stdin.
|
||||
if (verb === "grants") return grantsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
// Row 165 (#1662): scripts/mosaic sends only `business instances`, `business instance` and (row 194) `business veto` here.
|
||||
// Row 165 (#1662): scripts/mosaic sends only `business instances`, `business instance` and (row 194) `business veto` here;
|
||||
// `org instances|instance|veto` arrive with the rest of `org`.
|
||||
// Rename r2 stage B: the old spelling of `org instances|instance|veto`, kept with its own usage text.
|
||||
if (verb === "business") return instancesCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
if (verb === "talk") return talk(rest, io, deps);
|
||||
if (verb === "follow") return talk(rest, io, deps, true);
|
||||
@@ -743,6 +760,7 @@ export async function run(argv, io = { env: process.env, stdin: process.stdin, s
|
||||
await main(argv, io, deps);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
if (error instanceof CliExit) return error.exitCode;
|
||||
if (error instanceof OrgError) {
|
||||
io.stderr.write(`mosaic: ${error.code}: ${error.message}\n`);
|
||||
return 3;
|
||||
|
||||
@@ -10,3 +10,14 @@ export class CliError extends Error {
|
||||
this.exitCode = exitCode;
|
||||
}
|
||||
}
|
||||
|
||||
// Rename r2 stage B: `mosaic org config validate|resolve` writes its own messages
|
||||
// (packages/organization/src/config-command.mjs) and then ends with this exit code; run()
|
||||
// returns it without printing anything more.
|
||||
export class CliExit extends Error {
|
||||
constructor(exitCode) {
|
||||
super(`exit ${exitCode}`);
|
||||
this.name = "CliExit";
|
||||
this.exitCode = exitCode;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@
|
||||
// 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.
|
||||
// Row 194 (#1692, D1): `business instance launch|terminate|regenerate|delete` and `business veto`.
|
||||
// Rename r2 stage B: `mosaic org instances|instance|veto` is the primary spelling of all of these,
|
||||
// with the same arguments and this same code (orgInstancesCommand). Only the usage text differs;
|
||||
// the refusal reasons below still name the old spelling, which keeps working.
|
||||
// Launch, regenerate and delete are requests the launcher's pass carries out. The command polls
|
||||
// instance.request until the request closes or --wait runs out (D1.1 item 5).
|
||||
import { readFileSync } from "node:fs";
|
||||
@@ -17,6 +20,12 @@ export const INSTANCES_USAGE = "mosaic business instances BUSINESS [--json] [--t
|
||||
"business instance delete BUSINESS INSTANCE --revision REV [--wait SECONDS] | " +
|
||||
"business veto BUSINESS keep|unkeep INSTANCE --revision REV | business veto BUSINESS nohire|hire DEFINITION --revision REV";
|
||||
|
||||
// Rename r2 stage B: the primary spelling. `mosaic org instances|instance|veto` takes the same
|
||||
// arguments and runs the same command through the same proof-bearing call; only the usage text it
|
||||
// prints for bad arguments reads `org`. Output and refusals are byte for byte the old spelling's.
|
||||
export const ORG_INSTANCES_USAGE = INSTANCES_USAGE.replace(/^mosaic business /, "mosaic org ")
|
||||
.replaceAll(" | business ", " | org ").replaceAll("BUSINESS", "ORG");
|
||||
|
||||
// The bus answers a refusal with its code alone, so the reason a human reads is here.
|
||||
export const REASONS = {
|
||||
"organization-credentials-out-of-scope": "only add binds credentials, as store references; set refuses a definition that " +
|
||||
@@ -218,6 +227,16 @@ export function formatInstances(view) {
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
// args are the words after `mosaic org`; the same command as instancesCommand, with the org usage.
|
||||
export async function orgInstancesCommand(args, io, callFor) {
|
||||
try {
|
||||
return await instancesCommand(args, io, callFor);
|
||||
} catch (error) {
|
||||
if (error instanceof CliError && error.message === INSTANCES_USAGE) throw new CliError(ORG_INSTANCES_USAGE, error.exitCode);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// args are the words after `mosaic business`. callFor(parsed) returns the human's call(verb, args).
|
||||
export async function instancesCommand(args, io, callFor) {
|
||||
const parsed = parse(args);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
|
||||
import { loadSystem } from './config.mjs';
|
||||
import { CliError } from './errors.mjs';
|
||||
import { busStopForDelete } from './org-bus.mjs';
|
||||
export const ORG_USAGE = 'mosaic org lock show|clear ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--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]';
|
||||
export const ORG_USAGE = 'mosaic org lock show|clear ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--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] | 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'].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 }; }
|
||||
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read document ${file}: ${e.message}`, 2); } }
|
||||
export async function orgCommand(args, io, deps = {}) {
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
// Rename design r2, stage B2: the command map. `mosaic org config validate|resolve` is the primary
|
||||
// spelling of `mosaic business validate|resolve`, and `mosaic org instances|instance|veto` of
|
||||
// `mosaic business instances|instance|veto`, with the same arguments. Both spellings call one
|
||||
// implementation: the installed-config service (packages/organization/src/config-command.mjs) and the
|
||||
// proof-bearing instances command (src/instances.mjs). These golden comparisons pin status, stdout,
|
||||
// stderr and authorization outcomes across the wrapper, the direct entry points and in-process run().
|
||||
// The old spelling stays byte for byte; the new one differs only where the design lets help read
|
||||
// organization first: the stderr prefix of the config commands and the usage text.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmodSync, readFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { BusError } from "../../bus/src/broker.mjs";
|
||||
import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { businessDoc, rolesCopy, systemConfig, writeJson } from "../../organization/tests/helpers.mjs";
|
||||
import { projectFilePath } from "../../organization/src/index.mjs";
|
||||
import { canonicalJson, sha256 } from "../../organization/src/util.mjs";
|
||||
import { templateRender } from "../../organization/src/org-service.mjs";
|
||||
import { BUSINESS_CONFIG, ORG_CONFIG, runConfigCommand } from "../../organization/src/config-command.mjs";
|
||||
import * as oldConfigCommand from "../../business/src/config-command.mjs";
|
||||
import { run, USAGE } from "../src/cli.mjs";
|
||||
import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { INSTANCES_USAGE, ORG_INSTANCES_USAGE } from "../src/instances.mjs";
|
||||
import { ORG_USAGE } from "../src/org.mjs";
|
||||
import { fixture, io, tmp } from "./helpers.mjs";
|
||||
|
||||
const path = (p) => fileURLToPath(new URL(p, import.meta.url));
|
||||
const MOSAIC = path("../../../scripts/mosaic");
|
||||
const CLI = path("../src/cli.mjs");
|
||||
const ORG_CLI = path("../../organization/src/cli.mjs");
|
||||
const OLD_CLI = path("../../business/src/cli.mjs");
|
||||
const CONFIG_COMMAND = path("../../organization/src/config-command.mjs");
|
||||
const golden = (name) => readFileSync(path(`../../business/tests/fixtures/rename-a/${name}`), "utf8");
|
||||
|
||||
// The old spelling's stderr as the new spelling prints it: each line's prefix and the usage text.
|
||||
const asOrgConfig = (stderr) => stderr.replaceAll(BUSINESS_CONFIG.usage, ORG_CONFIG.usage).replace(/^mosaic business: /gm, "mosaic org config: ");
|
||||
|
||||
const spawn = (command, args, env, cwd) => {
|
||||
const p = spawnSync(command, args, { env, cwd, encoding: "utf8" });
|
||||
assert.equal(p.error, undefined);
|
||||
return { status: p.status, stdout: p.stdout, stderr: p.stderr };
|
||||
};
|
||||
|
||||
// An installed organization acme with its stack project file, as the stage A contract test builds it.
|
||||
function installed(t, edit = () => {}) {
|
||||
const root = tmp(t, "rb-");
|
||||
const config = systemConfig(root), doc = businessDoc(root);
|
||||
writeJson(join(root, "config", "businesses", "acme.json"), doc);
|
||||
writeJson(projectFilePath(doc.projects.stack.root), { projectVersion: 1, id: "stack", vars: { "tracker.project": 3 } });
|
||||
const env = { PATH: process.env.PATH, HOME: join(root, "home"), TMPDIR: process.env.TMPDIR, LANG: "C.UTF-8", MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: rolesCopy(root) };
|
||||
edit({ root, doc, env });
|
||||
return { root, doc, env };
|
||||
}
|
||||
|
||||
// Every entry point for one argument list. Old: the wrapper, the old executable path and the moved
|
||||
// executable. New: the wrapper, the direct packages/cli executable and in-process run().
|
||||
async function entries(args, env, cwd) {
|
||||
const x = io(env);
|
||||
const status = await run(["org", "config", ...args], x, {});
|
||||
return {
|
||||
old: [spawn(MOSAIC, ["business", ...args], env, cwd), spawn(process.execPath, [OLD_CLI, ...args], env, cwd), spawn(process.execPath, [ORG_CLI, ...args], env, cwd)],
|
||||
now: [spawn(MOSAIC, ["org", "config", ...args], env, cwd), spawn(process.execPath, [CLI, "org", "config", ...args], env, cwd), { status, stdout: x.out.text, stderr: x.err.text }],
|
||||
};
|
||||
}
|
||||
|
||||
async function sameAcrossSpellings(args, env, cwd, status) {
|
||||
const { old, now } = await entries(args, env, cwd);
|
||||
const label = args.join(" ") || "(no arguments)";
|
||||
for (const r of old.slice(1)) assert.deepEqual(r, old[0], `old entries agree: ${label}`);
|
||||
for (const r of now.slice(1)) assert.deepEqual(r, now[0], `new entries agree: ${label}`);
|
||||
assert.equal(old[0].status, status, `${label}: ${old[0].stderr}`);
|
||||
assert.deepEqual(now[0], { status: old[0].status, stdout: old[0].stdout, stderr: asOrgConfig(old[0].stderr) }, label);
|
||||
if (status !== 0) assert.equal(now[0].stdout, "", label);
|
||||
return { old: old[0], now: now[0] };
|
||||
}
|
||||
|
||||
test("rename r2 B2: org config validate|resolve has the status, stdout and stderr of business validate|resolve at every entry point", async (t) => {
|
||||
const { root, env } = installed(t);
|
||||
const cases = [
|
||||
[["validate", "acme"], 0],
|
||||
[["resolve", "acme", "coder", "--project", "stack"], 0],
|
||||
[["resolve", "acme", "chief"], 0],
|
||||
[["validate", "missing"], 4],
|
||||
[["resolve", "acme", "unknown"], 2],
|
||||
[["resolve", "acme", "coder", "--project", "nope"], 2],
|
||||
[[], 4],
|
||||
[["bogus"], 4],
|
||||
[["validate"], 4],
|
||||
[["validate", "acme", "extra"], 4],
|
||||
[["resolve", "acme"], 4],
|
||||
[["resolve", "acme", "coder", "--project"], 4],
|
||||
[["resolve", "acme", "coder", "--project", "stack", "--project", "stack"], 4],
|
||||
];
|
||||
for (const [args, status] of cases) {
|
||||
const { old, now } = await sameAcrossSpellings(args, env, root, status);
|
||||
if (status === 4 && args[1] !== "missing") {
|
||||
assert.equal(old.stderr, `mosaic business: ${BUSINESS_CONFIG.usage}\n`);
|
||||
assert.equal(now.stderr, `mosaic org config: ${ORG_CONFIG.usage}\n`);
|
||||
}
|
||||
if (status === 0) assert.equal(old.stderr, "");
|
||||
}
|
||||
// The old usage text is byte for byte what `mosaic business` printed before stage B.
|
||||
assert.equal(BUSINESS_CONFIG.usage, "usage: mosaic business validate <business> | resolve <business> <instance> [--project <id>]");
|
||||
});
|
||||
|
||||
test("rename r2 B2: a credential problem, an expiry warning and a system config problem read the same in both spellings", async (t) => {
|
||||
const bad = installed(t, ({ root }) => chmodSync(join(root, "secrets", "coder-gitea.token"), 0o644));
|
||||
for (const args of [["validate", "acme"], ["resolve", "acme", "coder"]]) {
|
||||
const { old } = await sameAcrossSpellings(args, bad.env, bad.root, 2);
|
||||
assert.match(old.stderr, /^mosaic business: .*coder-gitea\.token.*mode 644/m);
|
||||
assert.match(old.stderr, /credential reference problem\(s\)\n$/);
|
||||
}
|
||||
const soon = new Date(Date.now() + 3 * 86400000).toISOString().slice(0, 10);
|
||||
const warned = installed(t, ({ root, doc }) => {
|
||||
doc.roles.coder.credentials.vikunja.expires = soon;
|
||||
writeJson(join(root, "config", "businesses", "acme.json"), doc);
|
||||
});
|
||||
const { old } = await sameAcrossSpellings(["resolve", "acme", "coder"], warned.env, warned.root, 0);
|
||||
assert.match(old.stderr, new RegExp(`^mosaic business: warning: .*expires on ${soon}\\n$`, "m"));
|
||||
const missing = installed(t, ({ root, env }) => { env.MOSAIC_CONFIG = join(root, "nowhere", "config.json"); });
|
||||
const sys = await sameAcrossSpellings(["validate", "acme"], missing.env, missing.root, 3);
|
||||
assert.match(sys.old.stderr, /^mosaic business: system config problem \(mosaic-config exit 3\): /);
|
||||
});
|
||||
|
||||
test("rename r2 B2: org config prints the stage A golden v1 bytes, preimage and digest", async (t) => {
|
||||
const { root, env } = installed(t);
|
||||
const invoke = (args) => {
|
||||
const r = spawn(MOSAIC, ["org", "config", ...args], env, root);
|
||||
assert.deepEqual([r.status, r.stderr], [0, ""], args.join(" "));
|
||||
return r.stdout;
|
||||
};
|
||||
const validate = invoke(["validate", "acme"]);
|
||||
const resolved = invoke(["resolve", "acme", "coder", "--project", "stack"]);
|
||||
const { digest, ...preimage } = JSON.parse(resolved);
|
||||
assert.equal(sha256(canonicalJson(preimage)), digest);
|
||||
for (const [id, expected] of Object.entries(JSON.parse(validate).instances)) {
|
||||
const { digest: actual, ...record } = JSON.parse(invoke(["resolve", "acme", id]));
|
||||
assert.equal(sha256(canonicalJson(record)), actual, id);
|
||||
assert.equal(actual, expected, id);
|
||||
}
|
||||
const normalized = (text) => text.split(root).join("<ROOT>").replace(/[a-f0-9]{64}/g, "<DIGEST>");
|
||||
const preimageText = `${normalized(canonicalJson(preimage))}\n`;
|
||||
assert.equal(sha256(preimageText.slice(0, -1)), "5fc9d6258f4725b0e260d99e5cf81058a05d21b8ff298bb53653efbbc8013da9");
|
||||
assert.equal(normalized(validate), golden("installed-validate.json"));
|
||||
assert.equal(normalized(resolved), golden("installed-resolve.json"));
|
||||
assert.equal(preimageText, golden("installed-preimage.json"));
|
||||
assert.equal(JSON.parse(validate).business, "acme", "the v1 key stays business");
|
||||
});
|
||||
|
||||
test("rename r2 B2: a draft file named like an installed ID: org validate reads the file, org config validate the installed organization", async (t) => {
|
||||
const { root, env } = installed(t);
|
||||
const cwd = join(root, "drafts");
|
||||
writeJson(join(cwd, "acme"), await templateRender("acme", { fixture: "local" }));
|
||||
const draft = spawn(MOSAIC, ["org", "validate", "acme"], env, cwd);
|
||||
assert.deepEqual(draft, { status: 0, stdout: "valid\n", stderr: "" });
|
||||
const config = spawn(MOSAIC, ["org", "config", "validate", "acme"], env, cwd);
|
||||
assert.equal(config.status, 0, config.stderr);
|
||||
assert.equal(JSON.parse(config.stdout).file, join(root, "config", "businesses", "acme.json"));
|
||||
assert.deepEqual(config, spawn(MOSAIC, ["business", "validate", "acme"], env, cwd));
|
||||
// Each side reads only its own: no draft, and org validate fails though acme is installed;
|
||||
// no installed acme, and org config validate fails though the draft is in the working directory.
|
||||
rmSync(join(cwd, "acme"));
|
||||
const noDraft = spawn(MOSAIC, ["org", "validate", "acme"], env, cwd);
|
||||
assert.equal(noDraft.status, 2);
|
||||
assert.match(noDraft.stderr, /^mosaic: cannot read document acme: /);
|
||||
writeJson(join(cwd, "acme"), await templateRender("acme", { fixture: "local" }));
|
||||
rmSync(join(root, "config", "businesses", "acme.json"));
|
||||
const noInstalled = spawn(MOSAIC, ["org", "config", "validate", "acme"], env, cwd);
|
||||
assert.equal(noInstalled.status, 4);
|
||||
assert.equal(noInstalled.stdout, "");
|
||||
assert.match(noInstalled.stderr, /^mosaic org config: .*acme/);
|
||||
assert.equal(spawn(MOSAIC, ["org", "validate", "acme"], env, cwd).stdout, "valid\n");
|
||||
});
|
||||
|
||||
// A real broker over an installed organization, as instances.test.mjs builds it. Both spellings run
|
||||
// through run() with the same deps, so the proof-bearing transport and its calls are the same.
|
||||
async function bus(t) {
|
||||
const root = tmp(t, "rbi-");
|
||||
const f = fixture(root, "acme");
|
||||
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
|
||||
const b = await startBroker({ dataRoot: f.dataRoot, businesses: boot.businesses, organization: boot.organization });
|
||||
t.after(() => b.close());
|
||||
const human = b.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true });
|
||||
const calls = [];
|
||||
const deps = {
|
||||
system: { dataRoot: f.dataRoot },
|
||||
transport: (business) => async (verb, args = {}) => {
|
||||
calls.push({ business, verb, args });
|
||||
try {
|
||||
return structuredClone(b.broker.request(human, { verb, args }));
|
||||
} catch (e) {
|
||||
if (!(e instanceof BusError)) throw e;
|
||||
const error = new Error(e.code);
|
||||
error.code = e.code;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
const log = () => readFileSync(join(root, "config", "businesses", ".history", "acme", "changes.jsonl"), "utf8").trim().split("\n").map((l) => JSON.parse(l));
|
||||
const mosaic = async (words, env = f.env) => {
|
||||
const x = io(env), before = calls.length;
|
||||
const code = await run(words, x, deps);
|
||||
return { code, out: x.out.text, err: x.err.text, calls: calls.slice(before) };
|
||||
};
|
||||
const revision = async () => JSON.parse((await mosaic(["business", "instances", "acme", "--json"])).out).revision;
|
||||
return { root, f, mosaic, revision, log };
|
||||
}
|
||||
|
||||
test("rename r2 B2: org instances|instance|veto reads, refuses and writes exactly as business instances|instance|veto", async (t) => {
|
||||
const s = await bus(t);
|
||||
for (const args of [["instances", "acme"], ["instances", "acme", "--json"]]) {
|
||||
const old = await s.mosaic(["business", ...args]), now = await s.mosaic(["org", ...args]);
|
||||
assert.equal(old.code, 0, old.err);
|
||||
assert.deepEqual(now, old, args.join(" "));
|
||||
assert.deepEqual(now.calls, [{ business: "acme", verb: "instances", args: {} }]);
|
||||
}
|
||||
// A refusal from the bus: the same code, reason and exit.
|
||||
for (const args of [
|
||||
["instance", "add", "acme", "coder", "--definition", "coder", "--revision", await s.revision()],
|
||||
["veto", "acme", "keep", "coder", "--revision", "0".repeat(64)],
|
||||
["instance", "set", "acme", "nobody", "--no-launch", "--revision", await s.revision()],
|
||||
]) {
|
||||
const old = await s.mosaic(["business", ...args]), now = await s.mosaic(["org", ...args]);
|
||||
assert.equal(old.code, 2, args.join(" "));
|
||||
assert.match(old.err, /^mosaic: [a-z-]+: /);
|
||||
assert.deepEqual(now, old, args.join(" "));
|
||||
}
|
||||
// An agent marker refuses before any call, in both spellings.
|
||||
for (const marker of ["MOSAIC_RUN_ID", "CODEX_THREAD_ID"]) {
|
||||
const env = { ...s.f.env, [marker]: "fixture-agent" };
|
||||
const old = await s.mosaic(["business", "instances", "acme"], env), now = await s.mosaic(["org", "instances", "acme"], env);
|
||||
assert.equal(old.code, 3);
|
||||
assert.match(old.err, /outside any agent run/);
|
||||
assert.deepEqual(now, old);
|
||||
assert.deepEqual(now.calls, []);
|
||||
}
|
||||
// A write through the org spelling: the same lines, a log entry naming the human, and the old
|
||||
// spelling reads it back.
|
||||
const kept = await s.mosaic(["org", "veto", "acme", "keep", "coder", "--revision", await s.revision()]);
|
||||
assert.equal(kept.code, 0, kept.err);
|
||||
const entry = s.log().at(-1);
|
||||
assert.deepEqual(kept.out.split("\n"), ["business acme: veto keep coder", `revision ${await s.revision()}`, `transaction ${entry.transaction}`,
|
||||
"no restart: the launcher reads the change at its next launch check", ""]);
|
||||
assert.equal(entry.actor, "jason");
|
||||
assert.deepEqual(kept.calls.map((c) => c.verb), ["veto.set"]);
|
||||
const vars = writeJson(join(s.root, "vars.json"), { harness: "pi", model: "claude-sonnet-5-5" });
|
||||
const changed = await s.mosaic(["org", "instance", "set", "acme", "coder", "--vars", vars, "--no-launch", "--revision", await s.revision()]);
|
||||
assert.equal(changed.code, 0, changed.err);
|
||||
assert.match(changed.out, /^business acme: changed coder \(coder\)$/m);
|
||||
assert.deepEqual(changed.calls.at(-1).args, { revision: changed.calls.at(-1).args.revision, instance: "coder", op: "change", vars: { harness: "pi", model: "claude-sonnet-5-5" }, launch: false });
|
||||
const json = JSON.parse((await s.mosaic(["business", "instances", "acme", "--json"])).out);
|
||||
assert.equal(json.instances.coder.kept, true);
|
||||
// The same error refusal the old spelling gives after the write: a kept instance refuses delete.
|
||||
const delOld = await s.mosaic(["business", "instance", "delete", "acme", "coder", "--revision", await s.revision()]);
|
||||
const delNew = await s.mosaic(["org", "instance", "delete", "acme", "coder", "--revision", await s.revision()]);
|
||||
assert.equal(delOld.code, 2);
|
||||
assert.deepEqual(delNew, delOld);
|
||||
});
|
||||
|
||||
test("rename r2 B2: org instances never reaches the dev-owner context, and bad arguments print the org usage", async (t) => {
|
||||
const s = await bus(t);
|
||||
// This fixture is not a named instance, so the dev-owner context refuses here; org instances runs.
|
||||
const show = await s.mosaic(["org", "show", "acme"]);
|
||||
assert.equal(show.code, 2);
|
||||
assert.match(show.err, /--dev-local-owner requires a named instance/);
|
||||
const view = await s.mosaic(["org", "instances", "acme"]);
|
||||
assert.equal(view.code, 0, view.err);
|
||||
assert.doesNotMatch(view.err, /dev-local-owner/);
|
||||
for (const args of [["instance"], ["instances"], ["veto", "acme"], ["instances", "acme", "--bogus"], ["instance", "add", "acme", "x"]]) {
|
||||
const old = await s.mosaic(["business", ...args]), now = await s.mosaic(["org", ...args]);
|
||||
assert.deepEqual(old, { code: 4, out: "", err: `mosaic: ${INSTANCES_USAGE}\n`, calls: [] }, args.join(" "));
|
||||
assert.deepEqual(now, { code: 4, out: "", err: `mosaic: ${ORG_INSTANCES_USAGE}\n`, calls: [] }, args.join(" "));
|
||||
}
|
||||
assert.equal(ORG_INSTANCES_USAGE, INSTANCES_USAGE.replace(/^mosaic business /, "mosaic org ").replaceAll(" | business ", " | org ").replaceAll("BUSINESS", "ORG"));
|
||||
assert.doesNotMatch(ORG_INSTANCES_USAGE, /business|BUSINESS/);
|
||||
});
|
||||
|
||||
test("rename r2 B2: scripts/mosaic routes org instances|instance|veto to the proof-bearing command, not the dev-owner one", (t) => {
|
||||
const root = tmp(t, "rbw-");
|
||||
const env = { PATH: process.env.PATH, HOME: join(root, "home"), LANG: "C.UTF-8", MOSAIC_CONFIG: join(root, "none.json") };
|
||||
for (const sub of ["instance", "instances", "veto"]) {
|
||||
const old = spawn(MOSAIC, ["business", sub], env, root), now = spawn(MOSAIC, ["org", sub], env, root);
|
||||
assert.deepEqual(old, { status: 4, stdout: "", stderr: `mosaic: ${INSTANCES_USAGE}\n` }, sub);
|
||||
assert.deepEqual(now, { status: 4, stdout: "", stderr: `mosaic: ${ORG_INSTANCES_USAGE}\n` }, sub);
|
||||
}
|
||||
// A real read with an agent marker refuses with the human-shell check in both spellings.
|
||||
const agent = { ...env, MOSAIC_AGENT_RUN: "1" };
|
||||
const old = spawn(MOSAIC, ["business", "instances", "acme"], agent, root), now = spawn(MOSAIC, ["org", "instances", "acme"], agent, root);
|
||||
assert.equal(old.status, 3);
|
||||
assert.match(old.stderr, /outside any agent run \(found MOSAIC_AGENT_RUN/);
|
||||
assert.deepEqual(now, old);
|
||||
// Without a marker and without a config, both fail the same way, and neither names the dev owner.
|
||||
const plainOld = spawn(MOSAIC, ["business", "instances", "acme"], env, root), plainNew = spawn(MOSAIC, ["org", "instances", "acme"], env, root);
|
||||
assert.notEqual(plainOld.status, 0);
|
||||
assert.deepEqual(plainNew, plainOld);
|
||||
assert.doesNotMatch(plainNew.stderr, /dev-local-owner/);
|
||||
});
|
||||
|
||||
test("rename r2 B2: importing the config service runs nothing and never exits; it returns the code", async (t) => {
|
||||
const p = spawnSync(process.execPath, ["--input-type=module", "-e", `const m = await import(${JSON.stringify(CONFIG_COMMAND)}); process.stdout.write(Object.keys(m).sort().join(",") + "\\n");`, "validate", "acme"], { encoding: "utf8", env: { PATH: process.env.PATH, LANG: "C.UTF-8" } });
|
||||
assert.deepEqual([p.status, p.stdout, p.stderr], [0, "BUSINESS_CONFIG,ORG_CONFIG,runConfigCommand\n", ""]);
|
||||
assert.equal(oldConfigCommand.runConfigCommand, runConfigCommand);
|
||||
const root = tmp(t, "rbc-");
|
||||
const out = { text: "" }, err = { text: "" };
|
||||
const streams = { stdout: { write: (s) => { out.text += s; return true; } }, stderr: { write: (s) => { err.text += s; return true; } } };
|
||||
const env = { PATH: process.env.PATH, HOME: root, MOSAIC_CONFIG: join(root, "none.json") };
|
||||
assert.equal(runConfigCommand(["validate", "acme"], { ...streams, env, spelling: ORG_CONFIG }), 3);
|
||||
assert.equal(runConfigCommand([], { ...streams, env }), 4);
|
||||
assert.equal(out.text, "");
|
||||
assert.match(err.text, /^mosaic org config: system config problem /);
|
||||
assert.match(err.text, /\nmosaic business: usage: mosaic business validate <business> \| resolve /);
|
||||
});
|
||||
|
||||
test("rename r2 B2: help lists the org spellings first and names the business spelling as the old one", () => {
|
||||
assert.match(ORG_USAGE, / \| validate FILE \| /, "org validate FILE stays the draft check");
|
||||
assert.match(ORG_USAGE, / \| config validate ID \| config resolve ID INSTANCE \[--project ID\]$/);
|
||||
assert.ok(USAGE.includes(` ${ORG_USAGE}\n`));
|
||||
assert.ok(USAGE.includes(` ${ORG_INSTANCES_USAGE}\n`));
|
||||
assert.ok(!USAGE.includes(INSTANCES_USAGE));
|
||||
assert.match(USAGE, /^ {2}mosaic business validate\|resolve\|instances\|instance\|veto: the old spelling of mosaic org config validate\|resolve and mosaic org instances\|instance\|veto, same arguments$/m);
|
||||
});
|
||||
@@ -1,17 +1,31 @@
|
||||
# Business
|
||||
# Organization
|
||||
|
||||
Role definitions, the business and project files, the variable registry and
|
||||
the resolver (slice 1 row S1, #1518). Brief: `docs/plans/2026-10-04_slice-1.md`,
|
||||
`@mosaic/organization`: role definitions, the business and project files, the
|
||||
variable registry and the resolver (slice 1 row S1, #1518). Rename design r2,
|
||||
stage B moved it here from `packages/business`. That path and `@mosaic/business`
|
||||
stay as the old names: each module there re-exports the module of the same name
|
||||
here (`src/business.mjs` re-exports `src/organization-config.mjs`), and its
|
||||
`src/cli.mjs` runs this package's `src/cli.mjs`. Brief: `docs/plans/2026-10-04_slice-1.md`,
|
||||
row S1. Design: `agents/darkwing/work/slice1-data-model-2026-10-04.md` and its
|
||||
addenda A and B. The package reads files and refuses on any problem. It
|
||||
never writes, and it never opens a token file.
|
||||
|
||||
```sh
|
||||
scripts/mosaic business validate mosaic-stack
|
||||
scripts/mosaic business resolve mosaic-stack coder --project stack
|
||||
node --test packages/business/tests/
|
||||
scripts/mosaic org config validate mosaic-stack
|
||||
scripts/mosaic org config resolve mosaic-stack coder --project stack
|
||||
node --test packages/organization/tests/
|
||||
```
|
||||
|
||||
`mosaic business validate|resolve` is the old spelling, with the same
|
||||
arguments. Both spellings run `runConfigCommand` in `src/config-command.mjs`,
|
||||
so status and stdout are the same. Only stderr differs: each line starts
|
||||
`mosaic org config: ` or `mosaic business: `, and the usage line names the
|
||||
spelling you used. Importing `src/config-command.mjs` runs nothing and never
|
||||
exits the process; `runConfigCommand(argv, {stdout, stderr, env, spelling})`
|
||||
returns the exit code. `src/cli.mjs` is the thin executable that the old
|
||||
spelling runs. `mosaic org validate FILE` is different: it checks a draft file
|
||||
by path and prints `valid` (packages/cli/README.md).
|
||||
|
||||
## Files
|
||||
|
||||
| File | Who writes it | Holds |
|
||||
@@ -144,7 +158,7 @@ A write that adds none never calls it.
|
||||
`launch.threadIdleSeconds` (optional, row 121) is how long a thread's
|
||||
session runs with nothing pending and no new message or open before the
|
||||
launcher stops it. Absent means 900 (`THREAD_IDLE_SECONDS` in
|
||||
`src/business.mjs`). A set value is a positive integer with no upper bound;
|
||||
`src/organization-config.mjs`). A set value is a positive integer with no upper bound;
|
||||
anything else refuses the file. The key is accepted whether or not the file
|
||||
declares a thread instance. A thread instance is one whose definition is
|
||||
`assistant`. The launcher starts it once per thread, when the thread has a
|
||||
@@ -214,7 +228,7 @@ instance `vars` (`harness`, `model`) as the PM has, unless the system's
|
||||
execution settings cover it. A `launch.by` that names a PM, an assistant or any other instance
|
||||
whose role lacks `role.launch` is refused. A file that still names the PM
|
||||
fails to load with the shipped roles, so edit the file before a copy with
|
||||
these roles goes live. `packages/business/tests/chief-of-staff.test.mjs` and
|
||||
these roles goes live. `packages/organization/tests/chief-of-staff.test.mjs` and
|
||||
`assistant.test.mjs` check each role against its approved list and load it
|
||||
through `loadBusiness`; `role.test.mjs` loads every file in `roles/`
|
||||
through the validator.
|
||||
@@ -259,7 +273,7 @@ go. The entries are:
|
||||
"security-reviewer": { "definition": "security-reviewer" }
|
||||
```
|
||||
|
||||
`packages/business/tests/workflow-roles.test.mjs` checks both against
|
||||
`packages/organization/tests/workflow-roles.test.mjs` checks both against
|
||||
their approved lists, the arbiter refusal and the proactive line in every
|
||||
contract; `packages/bus/tests/workflow-roles.test.mjs` checks the merge
|
||||
route and the verdict path on the broker.
|
||||
@@ -314,11 +328,11 @@ that narrowed it.
|
||||
|
||||
```js
|
||||
import {
|
||||
loadBusiness, loadProject, systemVars, resolveInstance, classify,
|
||||
loadOrganization, loadProject, systemVars, resolveInstance, classify,
|
||||
loadRole, validateRoleDocument, checkCredentialRef,
|
||||
} from "../packages/business/src/index.mjs";
|
||||
} from "../packages/organization/src/index.mjs";
|
||||
|
||||
const business = loadBusiness("mosaic-stack", { rolesDir }); // frozen
|
||||
const business = loadOrganization("mosaic-stack", { rolesDir }); // frozen
|
||||
const project = loadProject(business.projects.stack.root); // frozen
|
||||
const coder = resolveInstance({ system: systemVars(config), business, project, instance: "coder" });
|
||||
classify(coder, "git.push.working"); // "within" | "cross" | "gated"
|
||||
@@ -342,8 +356,14 @@ included in the resolved digest and the generated session prompt; it does not
|
||||
grant authority. The name is descriptive, not a broker display-name alias;
|
||||
the assistant addresses messages to the exact instance key.
|
||||
|
||||
Errors are `BusinessError` with `exitCode` 2 (invalid) or 4 (a required
|
||||
file missing). The CLI adds 3 for a system config problem and 4 for usage.
|
||||
Errors are `OrganizationConfigError` with `exitCode` 2 (invalid) or 4 (a
|
||||
required file missing). `BusinessError` is the same constructor under its old
|
||||
name, and an error's `name` is still `BusinessError`. `OrganizationError`
|
||||
(`src/organization.mjs`, the organization writer's error with a `code`) is a
|
||||
different class. `mosaic org config` adds 3 for a system config problem and 4
|
||||
for usage. The old names `loadBusiness`, `validateBusinessDocument` and
|
||||
`businessFilePath` are the same functions as `loadOrganization`,
|
||||
`validateOrganizationDocument` and `organizationFilePath`.
|
||||
|
||||
## Limits
|
||||
|
||||
@@ -513,14 +533,15 @@ and whether `add` and `set` accept it. `set` takes:
|
||||
- The whole next document goes through `validateBusinessDocument`, the route
|
||||
checks (`checkRoutes`, under "Routes" above), and each project file's id and instance resolution
|
||||
(`organization-project-mismatch`, `organization-unresolvable-instance`),
|
||||
the checks `mosaic business validate` runs.
|
||||
the checks `mosaic org config validate` runs.
|
||||
- The organization writer then refuses a stale revision
|
||||
(`revision-conflict`) and logs the change with the actor.
|
||||
|
||||
A refusal writes nothing and logs nothing. The answer is the read with the
|
||||
`transaction` and `restart`. `restart` is true after an add or a definition
|
||||
change: the running broker keeps the roles it loaded at start. The writer
|
||||
doesn't restart anything. The CLI (`mosaic business instance add|set`) and the
|
||||
doesn't restart anything. The CLI (`mosaic org instance add|set`, or the old
|
||||
spelling `mosaic business instance add|set`) and the
|
||||
Console Settings page send the patch.
|
||||
|
||||
### Organizations (development release)
|
||||
|
||||
@@ -2,138 +2,23 @@
|
||||
// mosaic business validate <business>
|
||||
// mosaic business resolve <business> <instance> [--project <id>]
|
||||
//
|
||||
// validate loads the business file, every role definition it uses, the
|
||||
// project files that exist under its declared roots, and stat-checks every
|
||||
// credential reference. resolve prints one role instance's resolved record.
|
||||
// Both print JSON on stdout and problems on stderr.
|
||||
// The old spelling's executable (scripts/mosaic business validate|resolve, and the old path
|
||||
// packages/business/src/cli.mjs, which imports this file). The commands live in config-command.mjs,
|
||||
// which `mosaic org config validate|resolve` (packages/cli/src/cli.mjs) calls too; this file only
|
||||
// hands it the process's arguments, streams and environment and sets the exit status. validate loads
|
||||
// the business file, every role definition it uses, the project files that exist under its declared
|
||||
// roots, and stat-checks every credential reference. resolve prints one role instance's resolved
|
||||
// record. Both print JSON on stdout and problems on stderr, each stderr line prefixed
|
||||
// `mosaic business: `, exactly as before stage B.
|
||||
//
|
||||
// Exit codes: 0 ok; 2 invalid (business, role, project file or a credential
|
||||
// reference); 3 system config problem; 4 usage, or a required file missing.
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { BusinessError } from "./errors.mjs";
|
||||
import { loadBusiness } from "./organization-config.mjs";
|
||||
import { loadProject, projectFilePath } from "./project.mjs";
|
||||
import { checkCredentialRef } from "./credentials.mjs";
|
||||
import { systemVars, resolveInstance } from "./resolve.mjs";
|
||||
import { BUSINESS_CONFIG, runConfigCommand } from "./config-command.mjs";
|
||||
|
||||
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
const USAGE = "usage: mosaic business validate <business> | resolve <business> <instance> [--project <id>]";
|
||||
|
||||
function fail(code, message) {
|
||||
process.stderr.write(`mosaic business: ${message}\n`);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
function loadSystem() {
|
||||
const proc = spawnSync(process.execPath, [join(REPO, "scripts", "mosaic-config.mjs"), "validate"], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
if (proc.status !== 0) fail(3, `system config problem (mosaic-config exit ${proc.status}): ${proc.stderr.trim()}`);
|
||||
try {
|
||||
return JSON.parse(proc.stdout);
|
||||
} catch {
|
||||
fail(3, "system config: mosaic-config printed something that isn't JSON");
|
||||
}
|
||||
}
|
||||
|
||||
function rolesDir() {
|
||||
return resolve(process.env.MOSAIC_ROLES_DIR || join(REPO, "roles"));
|
||||
}
|
||||
|
||||
// Check credential references; print warnings; return the problem list.
|
||||
function checkRefs(refs, forbiddenRoots) {
|
||||
const problems = [];
|
||||
const warnings = [];
|
||||
for (const ref of refs) {
|
||||
const result = checkCredentialRef(ref, { forbiddenRoots });
|
||||
problems.push(...result.problems);
|
||||
warnings.push(...result.warnings);
|
||||
}
|
||||
return { problems, warnings };
|
||||
}
|
||||
|
||||
function forbiddenRoots(config, business) {
|
||||
return [REPO, config.dataRoot, ...Object.values(business.projects).filter(p => p.kind !== 'library').map((p) => p.root)];
|
||||
}
|
||||
|
||||
function validate(args) {
|
||||
if (args.length !== 1) fail(4, USAGE);
|
||||
const config = loadSystem();
|
||||
const system = systemVars(config);
|
||||
const business = loadBusiness(args[0], { rolesDir: rolesDir() });
|
||||
const projects = {};
|
||||
const warnings = [];
|
||||
for (const [id, entry] of Object.entries(business.projects)) {
|
||||
if (entry.kind === 'library') { projects[id] = 'library'; continue; }
|
||||
const file = projectFilePath(entry.root);
|
||||
if (!existsSync(file)) {
|
||||
projects[id] = "absent";
|
||||
warnings.push(`project ${id}: no project file at ${file}; it has no project variables`);
|
||||
continue;
|
||||
}
|
||||
const project = loadProject(entry.root);
|
||||
if (project.id !== id) fail(2, `project file ${file} has id ${project.id}, but business ${business.id} declares it as ${id}`);
|
||||
for (const instance of Object.keys(business.roles)) resolveInstance({ system, business, project, instance });
|
||||
projects[id] = "valid";
|
||||
}
|
||||
const refs = [
|
||||
...Object.values(business.tracker?.sync?.credentials ?? {}),
|
||||
...Object.values(business.roles).flatMap((r) => Object.values(r.credentials)),
|
||||
];
|
||||
const checked = checkRefs(refs, forbiddenRoots(config, business));
|
||||
warnings.push(...checked.warnings);
|
||||
const instances = {};
|
||||
for (const instance of Object.keys(business.roles)) {
|
||||
instances[instance] = resolveInstance({ system, business, instance }).digest;
|
||||
}
|
||||
for (const w of warnings) process.stderr.write(`mosaic business: warning: ${w}\n`);
|
||||
if (checked.problems.length > 0) {
|
||||
for (const p of checked.problems) process.stderr.write(`mosaic business: ${p}\n`);
|
||||
fail(2, `business ${business.id}: ${checked.problems.length} credential reference problem(s)`);
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify({ business: business.id, file: business.file, projects, instances }, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function resolveCommand(args) {
|
||||
let project = null;
|
||||
const rest = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--project") {
|
||||
if (!args[i + 1] || project !== null) fail(4, USAGE);
|
||||
project = args[++i];
|
||||
} else rest.push(args[i]);
|
||||
}
|
||||
if (rest.length !== 2) fail(4, USAGE);
|
||||
const [businessId, instance] = rest;
|
||||
const config = loadSystem();
|
||||
const business = loadBusiness(businessId, { rolesDir: rolesDir() });
|
||||
let loaded = null;
|
||||
if (project !== null) {
|
||||
const entry = Object.hasOwn(business.projects, project) ? business.projects[project] : null;
|
||||
if (!entry) fail(2, `business ${business.id} declares no project ${JSON.stringify(project)}`);
|
||||
if (entry.kind !== 'library') loaded = loadProject(entry.root);
|
||||
}
|
||||
const resolved = resolveInstance({ system: systemVars(config), business, project: loaded, instance });
|
||||
const checked = checkRefs(Object.values(resolved.credentials), forbiddenRoots(config, business));
|
||||
for (const w of checked.warnings) process.stderr.write(`mosaic business: warning: ${w}\n`);
|
||||
if (checked.problems.length > 0) {
|
||||
for (const p of checked.problems) process.stderr.write(`mosaic business: ${p}\n`);
|
||||
fail(2, `${business.id} ${instance}: ${checked.problems.length} credential reference problem(s)`);
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
|
||||
}
|
||||
|
||||
const [verb, ...args] = process.argv.slice(2);
|
||||
try {
|
||||
if (verb === "validate") validate(args);
|
||||
else if (verb === "resolve") resolveCommand(args);
|
||||
else fail(4, USAGE);
|
||||
} catch (error) {
|
||||
if (error instanceof BusinessError) fail(error.exitCode, error.message);
|
||||
throw error;
|
||||
}
|
||||
process.exitCode = runConfigCommand(process.argv.slice(2), {
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
env: process.env,
|
||||
spelling: BUSINESS_CONFIG,
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
// The installed organization config commands, as one callable service:
|
||||
// validate ID the installed loader, every role definition it uses, the
|
||||
// project files under its declared roots, and a stat check
|
||||
// of every credential reference
|
||||
// resolve ID INSTANCE [--project ID] one role instance's resolved record
|
||||
// Both print JSON on stdout and problems on stderr. The v1 serialization (the `business` key,
|
||||
// `business:` provenance, the canonical preimage and digest) is the stage A contract; it does not
|
||||
// change with the spelling.
|
||||
//
|
||||
// Rename design r2, stage B: `mosaic business validate|resolve` (src/cli.mjs, and the old
|
||||
// packages/business/src/cli.mjs path that forwards to it) and `mosaic org config validate|resolve`
|
||||
// (packages/cli/src/cli.mjs) both call runConfigCommand. It never exits the process or runs on
|
||||
// import: it writes to the streams it is given and returns the exit code. A spelling sets only the
|
||||
// prefix of each stderr line and the usage text; status, stdout and every message after the prefix
|
||||
// are the same for both.
|
||||
//
|
||||
// Exit codes: 0 ok; 2 invalid (organization, role, project file or a credential reference);
|
||||
// 3 system config problem; 4 usage, or a required file missing.
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { OrganizationConfigError } from "./errors.mjs";
|
||||
import { configDir, loadOrganization } from "./organization-config.mjs";
|
||||
import { loadProject, projectFilePath } from "./project.mjs";
|
||||
import { checkCredentialRef } from "./credentials.mjs";
|
||||
import { systemVars, resolveInstance } from "./resolve.mjs";
|
||||
|
||||
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
|
||||
// The old spelling's prefix and usage are byte for byte what `mosaic business` printed before stage B.
|
||||
export const BUSINESS_CONFIG = Object.freeze({
|
||||
prefix: "mosaic business",
|
||||
usage: "usage: mosaic business validate <business> | resolve <business> <instance> [--project <id>]",
|
||||
});
|
||||
export const ORG_CONFIG = Object.freeze({
|
||||
prefix: "mosaic org config",
|
||||
usage: "usage: mosaic org config validate ID | resolve ID INSTANCE [--project ID]",
|
||||
});
|
||||
|
||||
// Thrown after the message is written; runConfigCommand turns it into the exit code.
|
||||
class Exit {
|
||||
constructor(code) {
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
// argv is [verb, ...args]. Returns the exit code. An error that is not an OrganizationConfigError
|
||||
// (a bug, not a refusal) is thrown to the caller, as the old executable let it escape.
|
||||
export function runConfigCommand(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env, spelling = BUSINESS_CONFIG } = {}) {
|
||||
const say = (message) => stderr.write(`${spelling.prefix}: ${message}\n`);
|
||||
const fail = (code, message) => {
|
||||
say(message);
|
||||
throw new Exit(code);
|
||||
};
|
||||
|
||||
const loadSystem = () => {
|
||||
const proc = spawnSync(process.execPath, [join(REPO, "scripts", "mosaic-config.mjs"), "validate"], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 1024 * 1024,
|
||||
env,
|
||||
});
|
||||
if (proc.status !== 0) fail(3, `system config problem (mosaic-config exit ${proc.status}): ${proc.stderr.trim()}`);
|
||||
try {
|
||||
return JSON.parse(proc.stdout);
|
||||
} catch {
|
||||
fail(3, "system config: mosaic-config printed something that isn't JSON");
|
||||
}
|
||||
};
|
||||
const load = (id) => loadOrganization(id, { dir: configDir(env), rolesDir: resolve(env.MOSAIC_ROLES_DIR || join(REPO, "roles")) });
|
||||
|
||||
// Check credential references; return the problems and warnings.
|
||||
const checkRefs = (refs, forbiddenRoots) => {
|
||||
const problems = [];
|
||||
const warnings = [];
|
||||
for (const ref of refs) {
|
||||
const result = checkCredentialRef(ref, { forbiddenRoots, env });
|
||||
problems.push(...result.problems);
|
||||
warnings.push(...result.warnings);
|
||||
}
|
||||
return { problems, warnings };
|
||||
};
|
||||
const forbiddenRoots = (config, organization) =>
|
||||
[REPO, config.dataRoot, ...Object.values(organization.projects).filter((p) => p.kind !== "library").map((p) => p.root)];
|
||||
|
||||
const validate = (args) => {
|
||||
if (args.length !== 1) fail(4, spelling.usage);
|
||||
const config = loadSystem();
|
||||
const system = systemVars(config);
|
||||
const organization = load(args[0]);
|
||||
const projects = {};
|
||||
const warnings = [];
|
||||
for (const [id, entry] of Object.entries(organization.projects)) {
|
||||
if (entry.kind === "library") { projects[id] = "library"; continue; }
|
||||
const file = projectFilePath(entry.root);
|
||||
if (!existsSync(file)) {
|
||||
projects[id] = "absent";
|
||||
warnings.push(`project ${id}: no project file at ${file}; it has no project variables`);
|
||||
continue;
|
||||
}
|
||||
const project = loadProject(entry.root);
|
||||
if (project.id !== id) fail(2, `project file ${file} has id ${project.id}, but business ${organization.id} declares it as ${id}`);
|
||||
for (const instance of Object.keys(organization.roles)) resolveInstance({ system, business: organization, project, instance });
|
||||
projects[id] = "valid";
|
||||
}
|
||||
const refs = [
|
||||
...Object.values(organization.tracker?.sync?.credentials ?? {}),
|
||||
...Object.values(organization.roles).flatMap((r) => Object.values(r.credentials)),
|
||||
];
|
||||
const checked = checkRefs(refs, forbiddenRoots(config, organization));
|
||||
warnings.push(...checked.warnings);
|
||||
const instances = {};
|
||||
for (const instance of Object.keys(organization.roles)) {
|
||||
instances[instance] = resolveInstance({ system, business: organization, instance }).digest;
|
||||
}
|
||||
for (const w of warnings) say(`warning: ${w}`);
|
||||
if (checked.problems.length > 0) {
|
||||
for (const p of checked.problems) say(p);
|
||||
fail(2, `business ${organization.id}: ${checked.problems.length} credential reference problem(s)`);
|
||||
}
|
||||
// v1 output: the key stays `business`.
|
||||
stdout.write(`${JSON.stringify({ business: organization.id, file: organization.file, projects, instances }, null, 2)}\n`);
|
||||
};
|
||||
|
||||
const resolveCommand = (args) => {
|
||||
let project = null;
|
||||
const rest = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--project") {
|
||||
if (!args[i + 1] || project !== null) fail(4, spelling.usage);
|
||||
project = args[++i];
|
||||
} else rest.push(args[i]);
|
||||
}
|
||||
if (rest.length !== 2) fail(4, spelling.usage);
|
||||
const [organizationId, instance] = rest;
|
||||
const config = loadSystem();
|
||||
const organization = load(organizationId);
|
||||
let loaded = null;
|
||||
if (project !== null) {
|
||||
const entry = Object.hasOwn(organization.projects, project) ? organization.projects[project] : null;
|
||||
if (!entry) fail(2, `business ${organization.id} declares no project ${JSON.stringify(project)}`);
|
||||
if (entry.kind !== "library") loaded = loadProject(entry.root);
|
||||
}
|
||||
const resolved = resolveInstance({ system: systemVars(config), business: organization, project: loaded, instance });
|
||||
const checked = checkRefs(Object.values(resolved.credentials), forbiddenRoots(config, organization));
|
||||
for (const w of checked.warnings) say(`warning: ${w}`);
|
||||
if (checked.problems.length > 0) {
|
||||
for (const p of checked.problems) say(p);
|
||||
fail(2, `${organization.id} ${instance}: ${checked.problems.length} credential reference problem(s)`);
|
||||
}
|
||||
stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
|
||||
};
|
||||
|
||||
const [verb, ...args] = argv;
|
||||
try {
|
||||
if (verb === "validate") validate(args);
|
||||
else if (verb === "resolve") resolveCommand(args);
|
||||
else fail(4, spelling.usage);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
if (error instanceof Exit) return error.code;
|
||||
if (error instanceof OrganizationConfigError) {
|
||||
say(error.message);
|
||||
return error.exitCode;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -220,7 +220,7 @@ refusal through its `log` argument. The Console serves the list at
|
||||
directory on every request and logs a given refusal once per process.
|
||||
|
||||
Themes are found by listing, not switched on. The organization file picks one
|
||||
with `appearance.theme` (a name; see `packages/business/src/appearance.mjs`),
|
||||
with `appearance.theme` (a name; see `packages/organization/src/appearance.mjs`),
|
||||
never with `plugins`. The kind is in the registry, so the `plugins` key
|
||||
accepts a `theme` entry without refusing it, but nothing reads that entry:
|
||||
list or empty, every installed theme stays available.
|
||||
|
||||
@@ -35,8 +35,8 @@ baseline CLI.
|
||||
| Edit or reset launch/assistant limits | `mosaic limits set` / `mosaic limits reset` | Unavailable pending parity |
|
||||
| Rename an instance | `mosaic name` | Unavailable pending parity |
|
||||
| Stop an assistant | No equivalent CLI identified | Unavailable |
|
||||
| Add, change or delete an instance; launch, regenerate or terminate an agent; change a veto | `mosaic business instance add`, `set`, `delete`, `launch`, `regenerate`, `terminate`; `mosaic business veto` | Unavailable pending parity |
|
||||
| Change routes or enabled harnesses/providers | `mosaic business instance set`; `mosaic plugins set` | Unavailable pending parity |
|
||||
| Add, change or delete an instance; launch, regenerate or terminate an agent; change a veto | `mosaic org instance add`, `set`, `delete`, `launch`, `regenerate`, `terminate`; `mosaic org veto` (old spelling `mosaic business instance`, `mosaic business veto`) | Unavailable pending parity |
|
||||
| Change routes or enabled harnesses/providers | `mosaic org instance set`; `mosaic plugins set` | Unavailable pending parity |
|
||||
| Halt or resume processing | `mosaic halt` / `mosaic resume` | Unavailable pending parity |
|
||||
| Save a project file or revert library content | No equivalent CLI identified | Unavailable |
|
||||
| Change a project remote | No equivalent CLI identified | Unavailable |
|
||||
@@ -672,7 +672,7 @@ mono and the module's own message, redacted by the server.
|
||||
bus host's business: the human, the arbiters as instance and kind ("pm
|
||||
(delivery), cto (technical)"), projects, launch rules, the vars
|
||||
on an allowlist, each role instance with its holder and harness, the
|
||||
authority of each instance for each action as the business package
|
||||
authority of each instance for each action as the organization package
|
||||
classifies it (`resolveInstance`, `classify`), and credentials as
|
||||
metadata only: service, account, role, expiry or rotate-by date, and a
|
||||
state worked out from the date alone. A missing or invalid business file
|
||||
@@ -919,7 +919,7 @@ Routes:
|
||||
outcome (`done` or the code), never the routes.
|
||||
|
||||
Neither route is in `organization.set`'s keys, so the chief of staff can't
|
||||
reach them (`packages/business/README.md`, "Routes").
|
||||
reach them (`packages/organization/README.md`, "Routes").
|
||||
|
||||
## Data and boundaries
|
||||
|
||||
@@ -976,7 +976,7 @@ imports the store inside its `try`, so a store that fails to load reports
|
||||
forwards the child's text, as `queue-refused`; any other exit is the fixed
|
||||
`read-failed` message "the queue read failed (exit N)", because that stderr
|
||||
may be Node's own, with a `file://` path and a stack. The business
|
||||
read uses `packages/business` in process. No read writes, calls out or adds a
|
||||
read uses `packages/organization` in process. No read writes, calls out or adds a
|
||||
dependency. Answers carry `at`; failures are `{ error, message }` with 503
|
||||
for `not-configured`, `no-bus-host`, `queue-refused` and `read-failed`, 404
|
||||
for a row that isn't there, 400 for a bad address and 502 otherwise. Settings
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
and asks for a fresh read. The tester listener offers Launch and Terminate only (D4.1). */
|
||||
(() => {
|
||||
'use strict';
|
||||
const UNAVAILABLE = 'Agent actions are unavailable here. Use mosaic business instance from the instance CLI.';
|
||||
const UNAVAILABLE = 'Agent actions are unavailable here. Use mosaic org instance from the instance CLI.';
|
||||
const REQUEST_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const POLL_MS = 2000, POLL_LIMIT = 90;
|
||||
const UNKNOWN = 'Outcome unknown. Read the agents again before trying; nothing was retried.';
|
||||
|
||||
@@ -393,15 +393,15 @@ const INSTANCE_KEYS = ['revision', 'instance', 'op', 'definition', 'vars', 'laun
|
||||
// The bus answers a refusal with its code alone, so the reason the page shows is here.
|
||||
const INSTANCE_REASONS = {
|
||||
'organization-credentials-out-of-scope': 'that definition declares credentials, or the instance holds them. The Console binds no credentials, so that stays a hand edit of the business file',
|
||||
'organization-tracker-required': 'that definition uses vikunja and needs a Vikunja bot. The Console names no bot; use mosaic business instance add with --bot and --bot-id',
|
||||
'organization-tracker-required': 'that definition uses vikunja and needs a Vikunja bot. The Console names no bot; use mosaic org instance add with --bot and --bot-id',
|
||||
'organization-tracker-bot-taken': 'another instance already uses that Vikunja bot',
|
||||
'organization-instance-exists': 'the business already has that instance; change it instead',
|
||||
'organization-unknown-instance': 'the business has no such instance; add it instead',
|
||||
'organization-definition-refused': 'this form neither adds nor changes an assistant, and moves no instance to the assistant definition; an assistant changes only in the Assistant section',
|
||||
'organization-launch-not-configured': 'the business file has no launch block, so launch membership can\'t change',
|
||||
'organization-no-change': 'the business file already says that',
|
||||
'organization-project-mismatch': 'a project file\'s id differs from its key in the business file; mosaic business validate shows it',
|
||||
'organization-unresolvable-instance': 'an instance wouldn\'t resolve against a project file; mosaic business validate shows why',
|
||||
'organization-project-mismatch': 'a project file\'s id differs from its key in the business file; mosaic org config validate shows it',
|
||||
'organization-unresolvable-instance': 'an instance wouldn\'t resolve against a project file; mosaic org config validate shows why',
|
||||
'revision-conflict': 'the business file changed since you read it. Read the role instances again',
|
||||
};
|
||||
// Row 194 (#1692, D7): the lifecycle and veto refusals, for the delete op, the Agents routes and a
|
||||
|
||||
@@ -219,7 +219,7 @@ test('row 194 N5: agents.js is served as JavaScript and index.html loads it once
|
||||
const js = await get(s, '/agents.js');
|
||||
assert.equal(js.status, 200); assert.match(js.type, /^text\/javascript/);
|
||||
assert.match(js.text, /window\.MosaicAgents = Object\.freeze\(\{ section \}\)/);
|
||||
assert.match(js.text, /Agent actions are unavailable here\. Use mosaic business instance from the instance CLI\./);
|
||||
assert.match(js.text, /Agent actions are unavailable here\. Use mosaic org instance from the instance CLI\./);
|
||||
const html = await get(s, '/');
|
||||
assert.equal(html.text.match(/<script src="\/agents\.js" defer><\/script>/g)?.length, 1);
|
||||
});
|
||||
|
||||
@@ -83,7 +83,7 @@ test('row 165 Console: the signed-in human adds an instance through a real broke
|
||||
// stale revision: each refused with its code and why, nothing written, nothing logged.
|
||||
const before = readFileSync(file, 'utf8'), entries = log().length;
|
||||
for (const [args, status, code, why] of [
|
||||
[{ revision: added.body.revision, instance: 'reviewer-2', op: 'add', definition: 'reviewer' }, 400, 'organization-tracker-required', /uses vikunja and needs a Vikunja bot\. The Console names no bot; use mosaic business instance add with --bot and --bot-id/],
|
||||
[{ revision: added.body.revision, instance: 'reviewer-2', op: 'add', definition: 'reviewer' }, 400, 'organization-tracker-required', /uses vikunja and needs a Vikunja bot\. The Console names no bot; use mosaic org instance add with --bot and --bot-id/],
|
||||
[{ revision: added.body.revision, instance: 'coder', op: 'change', definition: 'security-reviewer' }, 400, 'organization-credentials-out-of-scope', /hand edit of the business file/],
|
||||
[{ revision: current.revision, instance: 'gate-1', op: 'add', definition: 'merge-gate' }, 409, 'revision-conflict', /changed since you read it/],
|
||||
[{ revision: added.body.revision, instance: 'asst-2', op: 'add', definition: 'assistant' }, 400, 'organization-definition-refused', /^organization-definition-refused: this form neither adds nor changes an assistant, and moves no instance to the assistant definition;/],
|
||||
|
||||
+5
-1
@@ -2,7 +2,9 @@
|
||||
# `mosaic launch <seat>` and `mosaic seat task <seat> <text>`: seat launch
|
||||
# with registration for the control board. See packages/seat/README.md.
|
||||
# `mosaic queue <verb>`: the work queue. See packages/queue/README.md.
|
||||
# `mosaic business <verb>`: business files and role instances. See
|
||||
# `mosaic org config validate|resolve` and `mosaic org instances|instance|veto`:
|
||||
# the installed organization files and role instances; `mosaic business <verb>`
|
||||
# is the old spelling of both, with the same arguments. See
|
||||
# packages/organization/README.md.
|
||||
# `mosaic inbox|decide|tasks|agents|trail|talk|stop|launches` and
|
||||
# `mosaic bus start|stop|status`: the human commands, managed sessions and
|
||||
@@ -29,6 +31,8 @@ if [ "${1:-}" = business ]; then
|
||||
# Row 165 (#1662): the human's role instance commands need the human proof,
|
||||
# so they run in packages/cli. validate and resolve run in packages/organization (the old
|
||||
# packages/business path is a shim of it, rename r2 stage B). Row 194 adds veto.
|
||||
# This is the old spelling: `mosaic org config` and `mosaic org instances|instance|veto`
|
||||
# run the same code through packages/cli, below.
|
||||
case "${2:-}" in
|
||||
instances|instance|veto) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
|
||||
esac
|
||||
|
||||
Reference in new issue
Block a user