cli, webui: bus, Console and mosaic CLI on one pinned copy (#1579)

Row 85 round 2. Built by Rocko, packet agents/rocko/work/queue-85/r2/,
candidate manifest
bd7ea899c7cacef43b415b8e9ecb164e1f36718ffbe64347881a2c5653170c98.
Filbert approved on #1579, comment 28256; Darkwing approved, comment
28258.

stack-copy.sh builds shared read-only copies under
XDG_DATA_HOME/mosaic-stack/copies; the bus and Console services render
and install against a copy with --copy and --pm. No live step is run
by this commit; those need Mos's go.

Sage's gate rerun on 3a1e11b83 with rows 85, 102 and 103 applied,
Docker up: node suites bus 128, business 67, cli 165, control-board
124, conversation 182, discord 178, harness 73, ledger 78, mosaic 69,
queue 155, runs 43, seat 113, tasks 92, webui 108; scripts auth 15,
conductor 17, config 24, discord 66, extension-package 18,
foundation 44, queue 28, release 14, task 98. All pass, 0 fail.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-13 16:34:47 -05:00
co-authored by Claude Opus 5.5
parent 3a1e11b83e
commit 305a012b13
11 changed files with 532 additions and 133 deletions
+91 -16
View File
@@ -202,7 +202,9 @@ scripts/mosaic talk <role> <text> [--wait SECONDS] [--business <id>]
scripts/mosaic stop <run>
scripts/mosaic launches off | on [--business <id>]
scripts/mosaic launches list [--json]
scripts/bus-service.sh render [--claude PATH] | install [--dir DIR] [--no-reload] [--claude PATH] | uninstall | status <business>
scripts/bus-service.sh render [--copy ID [--copies DIR]] [--pm] [--claude PATH]
scripts/bus-service.sh install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH]
scripts/bus-service.sh uninstall | status <business>
```
The human commands read and resolve through the broker's human transport
@@ -238,6 +240,86 @@ stdin and stdout it exits 4 with the usage.
Exit codes: `0` ok · `1` failed or outcome unknown · `2` invalid input ·
`3` refused or config problem · `4` usage. Details: `packages/cli/README.md`.
## Stack copies (`scripts/stack-copy.sh`)
```
scripts/stack-copy.sh copy COMMIT [--copies DIR] [--allow-worktree]
scripts/stack-copy.sh wrapper ID [--copies DIR] [--bin DIR]
scripts/stack-copy.sh check [--copies DIR] [--bin DIR] [--business ID]
```
One read-only tree supplies the bus, Console and human CLI (row 85,
#1579). Copies default to `${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies/<full id>`.
`copy` requires a canonical checkout (.git directory); --allow-worktree
is a test override. COMMIT must be reachable from both local refactor and
origin/refactor. It archives the runtime/import closure, adapters, roles,
RELEASE, package.json and package-lock.json; refuses missing manifests;
runs npm ci inside the temporary copy; removes write permissions; and
renames beside the target. A published existing copy stays unchanged.
Dependencies use the exact lock and carry Pi's installed lock record.
Failure publishes nothing and removes the temporary tree.
`wrapper` is a live switching step, reserved for Jason or Mos. It accepts
only an existing copy and a regular non-symlink mosaic wrapper with the
Q81 marker on line 2 and its exact lease line to mosaic-old. It preserves
both lines and atomically replaces the wrapper's final exec with the
copy's scripts/mosaic. --bin is a fixture override; the default is
~/.npm-global/bin. Neither the Q81 worktree nor mosaic-old is removed.
`check` changes nothing: it reads effective systemd WorkingDirectory and
ExecStart (including drop-ins) for the bus and Console, plus the wrapper
exec. It prints all three paths and succeeds only on the same existing
copy; absent/failed reads and a mismatched ExecStart fail. Business defaults
to mosaic-stack. Queue work continues on the canonical scripts/mosaic.
Bus render/install accept --copy ID, --copies DIR, --pm and the unchanged
--claude pin. Without --copy the rendered unit stays the checkout unit.
--pm adds the PM startup; a refusal exits 3, and RestartPreventExitStatus
prevents a retry. Install refuses any busy bus instance, even with
--no-reload; a failed state/drop-in read also refuses. Any template or
instance drop-in assigning ExecStart or WorkingDirectory refuses,
naming the file. Status reads effective roots, --pm and DropInPaths,
reports checkout or copy identity and refactor drift, and writes nothing.
### Pinned stack update
This is an operator plan, not an action agents run. Both reviews and the
landing precede it. Mos grants each live step first. A bus restart ends
running sessions; do this only when no PM turn is in flight.
1. Make the pushed target copy with `stack-copy.sh copy COMMIT` and note
its full id. Before the first update, preserve the current Q81 CLI wrapper
(bytes and mode), bus and Console unit files and drop-ins, and the effective
WorkingDirectory/ExecStart output from each status command. Record the bus's
--claude pin and the Console's preview/send flags. Keep the bus pm.conf and
the existing Console copy or checkout: the first rollback needs them.
On later updates, also record the prior shared copy's full id.
2. With Mos's go, stop the Console, then stop the bus.
3. Mos removes overriding bus drop-ins identified by status. The first
update removes `~/.config/systemd/user/[email protected]/pm.conf`.
Reload systemd after removal; keep unrelated environment drop-ins.
4. With Mos's go, install the bus with `bus-service.sh install --copy ID --pm`
and its existing --claude pin, then the Console with
`webui-service.sh install --copy ID` and its existing preview/send flags.
5. With Mos's go, run `stack-copy.sh wrapper ID` to switch the human CLI.
6. Mos starts the bus first, then the Console. Read `stack-copy.sh check`,
both service status commands, Console `/healthz` and the canonical
`scripts/mosaic launches list`; the latter must show the PM.
7. Jason's plain `mosaic talk pm "..."` must print sent and a reply before
#225 closes. Keep the receipts for the three paths and reply.
The first rollback restores the pre-update mixed setup, which has no shared
copy id. Each live step needs Mos's go: stop the Console then the bus;
reinstall the checkout bus unit without --copy or --pm, retaining its saved
--claude pin; restore the saved pm.conf and other bus drop-ins; restore the
saved Console unit and drop-ins pointing to its original checkout or
mosaic-console copy; restore the saved Q81 wrapper bytes and mode; reload
systemd; start the bus then the Console. Check their effective paths against
the saved status output, Console /healthz and the PM launch. The shared-copy
check is expected to refuse this restored mixed setup; #225 remains open.
Later rollback repeats steps 2-6 with the prior shared copy's full id, each
live step again approved by Mos. Nothing prunes copies or removes mosaic-old.
## Console service (`scripts/webui-service.sh`)
```bash
@@ -262,9 +344,9 @@ read-only. The template is
The unit runs a pinned copy, not the working checkout (row 77, #1571).
`copy COMMIT` resolves COMMIT to its full id, refuses one not reachable
from `refs/heads/refactor`, and writes `git archive` of the paths the
Console loads into `DIR/<id>/`, by default
`${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-console/<id>/`. It writes a
from both `refs/heads/refactor` and `refs/remotes/origin/refactor`, and
delegates to stack-copy.sh for the archive and pinned npm install into `DIR/<id>/`, by default
`${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies/<id>/`. It writes a
temp directory beside the target, makes every file and directory read-only
(so a writer that replaces by rename can't change it either), then renames
it, so the copy's path is always whole. A copy that exists is left alone
@@ -282,8 +364,8 @@ checks that path against its own, so the bus must run from the same copy
`packages/bus/src/human-cli.mjs`, so a Console on a copy whose bus runs
from the checkout would fail every bus call with `human-required`. With
`--business ID`, install reads `[email protected]`; without it, every
loaded `mosaic-bus@` unit. It reads the effective ExecStart from
`systemctl --user show -p LoadState -p ExecStart`, so a drop-in counts,
loaded `mosaic-bus@` unit. It reads effective WorkingDirectory and ExecStart from
`systemctl --user show -p LoadState -p WorkingDirectory -p ExecStart`, so a drop-in counts,
and every ExecStart path must lie inside the copy. It refuses, naming both
paths, when one doesn't; when the unit is not installed or none is loaded;
when ExecStart has no path; and when the systemctl call fails. The check
@@ -309,16 +391,9 @@ uid, `^[0-9a-f]{64}$` (lead decision 101). The unit sets no `MOSAIC_*`,
`CLAUDE*`, `CODEX*` or `PI_*` variable. Serve's exit 2 (usage, config, or a
port already in use) is not restarted.
The update step: `copy` the commit, stop the unit, `install --copy ID`
with the same flags, start it. Rollback is the same steps with the earlier
id, which must be at or after row 77's landing: an older `cli.mjs` refuses
serve's `--checkout` ("unknown option"). The queue view isn't pinned: it
reads the checkout, so it shows the queue as it is now. Copies are kept
until someone removes them, with `chmod -R u+w DIR && rm -rf DIR` since
they are read-only; nothing prunes them. The step can't be used yet: the
bus still runs from the checkout (`copy` doesn't include
`scripts/mosaic`), so `install --copy ID` refuses until a later row puts
the bus on the copy.
The update/rollback sequence is [Pinned stack update](#pinned-stack-update)
above. The queue view reads the checkout and stays current. Copies remain
until an operator removes them; nothing here prunes or migrates copies.
There is no start, stop, enable or restart verb. The Console reads the bus
through the human transport, so whoever starts it holds the human's
+21 -2
View File
@@ -384,10 +384,10 @@ template `systemd/mosaic-bus.service.in` as the systemd user unit
The template's file name has no `@` because queue candidate manifests refuse
it. It is modelled on `scripts/discord-service.sh`:
- `install [--dir DIR] [--no-reload] [--claude PATH]` writes the unit through
- `install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH]` writes the unit through
a temp file and a rename, then runs `systemctl --user daemon-reload`. It
prints the pinned claude and its `--version`.
- `render [--claude PATH]` prints the unit without writing it.
- `render [--copy ID [--copies DIR]] [--pm] [--claude PATH]` prints the unit without writing it.
- `uninstall` refuses while an instance is active.
- `status <business>` shows the unit's state and its `Environment`, which
includes the pinned claude.
@@ -431,6 +431,25 @@ The unit has no `network-online.target` ordering, since a user unit cannot
order on that system target. The notifier needs no network at start: a
send that fails is journaled and retried.
### One pinned copy (row 85, #1579)
The bus, Console and mosaic wrapper must run the same copy: the broker's
human proof accepts only its own human-cli entry path. stack-copy.sh
creates a pushed, read-only tree with pinned npm dependencies under
mosaic-stack/copies/<full id>. bus-service.sh render/install take --copy
ID and --pm alongside the existing --claude pin. Without --copy render
keeps today's checkout unit. A PM refusal exits 3 and the unit does not
retry it. Install refuses every busy instance (also --no-reload), failed
state reads and drop-ins overriding ExecStart or WorkingDirectory.
Status reads effective systemd properties, reports the tree/copy, PM flag,
refactor drift and drop-in files; it changes nothing. The temporary PM
ExecStart drop-in must be removed by Mos before installing the pinned unit.
The one update and rollback plan is [TOOLS.md, Pinned stack update](../../docs/TOOLS.md#pinned-stack-update).
Each live switch/unit change/stop/start requires Mos's go; agents build
and test fixtures only. Restarting the bus ends its sessions, so no PM
turn may be in flight. Queue writes stay on the canonical scripts/mosaic.
## Notifier
The notifier is a child of the host. Each poll (every 30 s) it reads the
+1 -1
View File
@@ -27,7 +27,7 @@ EnvironmentFile=-%h/.config/mosaic-dev/secrets/claude-session.env
# refusals exit 3, usage 4, invalid input 2: none of those is retried. A
# child that dies takes the host down with exit 1, and the unit restarts
# the pair.
ExecStart=@REPO@/scripts/mosaic bus start %i
ExecStart=@REPO@/scripts/mosaic bus start %i@PM_FLAGS@
Restart=on-failure
RestartSec=15
RestartPreventExitStatus=2 3 4
+9 -3
View File
@@ -491,13 +491,17 @@ test("bus-service.sh renders the unit and installs it into a given directory", (
assert.doesNotMatch(render.stdout, /@REPO@|@PATH@|@CLAUDE_ENV@/);
// A user unit cannot order on a system target (Darkwing F3).
assert.doesNotMatch(render.stdout, /network-online/);
const first = spawnSync(script, ["install", "--dir", dir, "--no-reload"], { encoding: "utf8" });
const fakeBin=join(dir,"bin");mkdirSync(fakeBin);
writeFileSync(join(fakeBin,"systemctl"),"#!/bin/sh\ncase \"$*\" in *show*) echo DropInPaths=;; esac\nexit 0\n");chmodSync(join(fakeBin,"systemctl"),0o755);
writeFileSync(join(fakeBin,"systemd-analyze"),`#!/bin/sh\nprintf '%s\\n' '${dir}/unit-search'\n`);chmodSync(join(fakeBin,"systemd-analyze"),0o755);
const unitEnv={...process.env,PATH:`${fakeBin}:${process.env.PATH}`};
const first = spawnSync(script, ["install", "--dir", dir, "--no-reload"], { encoding: "utf8",env:unitEnv });
assert.equal(first.status, 0, first.stderr);
assert.match(first.stdout, /written: /);
assert.match(first.stdout, /^ {2}mkdir -m 0700 -p <dataRoot>\/notify\/<business> +the notifier refuses a looser directory$/m);
assert.equal(readFileSync(join(dir, "[email protected]"), "utf8"), render.stdout);
assert.match(spawnSync(script, ["install", "--dir", dir, "--no-reload"], { encoding: "utf8" }).stdout, /unchanged: /);
assert.match(spawnSync(script, ["uninstall", "--dir", dir, "--no-reload"], { encoding: "utf8" }).stdout, /removed: /);
assert.match(spawnSync(script, ["install", "--dir", dir, "--no-reload"], { encoding: "utf8",env:unitEnv }).stdout, /unchanged: /);
assert.match(spawnSync(script, ["uninstall", "--dir", dir, "--no-reload"], { encoding: "utf8",env:unitEnv }).stdout, /removed: /);
assert.equal(spawnSync(script, ["bogus"], { encoding: "utf8" }).status, 4);
});
@@ -512,6 +516,8 @@ test("bus-service.sh pins the claude binary, refuses one the unit can't carry, a
return p;
};
mkdirSync(join(dir, "bin"));
writeFileSync(join(dir,"bin","systemctl"),'#!/bin/sh\ncase "$*" in *show*) echo DropInPaths=;; esac\nexit 0\n');chmodSync(join(dir,"bin","systemctl"),0o755);
writeFileSync(join(dir,"bin","systemd-analyze"),`#!/bin/sh\nprintf '%s\\n' '${dir}/unit-search'\n`);chmodSync(join(dir,"bin","systemd-analyze"),0o755);
const onPath = stand(join(dir, "bin", "claude"));
const named = stand(join(dir, "claude-named"));
symlinkSync(named, join(dir, "claude-link"));
+178
View File
@@ -0,0 +1,178 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {execFileSync,spawnSync,spawn} from 'node:child_process';
import {symlinkSync,unlinkSync,mkdtempSync,mkdirSync,writeFileSync,readFileSync,cpSync,rmSync,chmodSync,existsSync,statSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join,dirname} from 'node:path';
import {fileURLToPath} from 'node:url';
import {once} from 'node:events';
import {pathToFileURL} from 'node:url';
import {fixture,notifyConfig,BUSINESSES} from './helpers.mjs';
const ROOT=fileURLToPath(new URL('../../..',import.meta.url)).replace(/\/$/,'');
const git=(cwd,...args)=>execFileSync('git',['-c','user.name=fixture','-c','user.email=fixture@invalid','-c','commit.gpgsign=false','-c','core.hooksPath=/dev/null','-C',cwd,...args],{encoding:'utf8'}).trim();
export function setup(t, realPin=false){
const root=mkdtempSync(join(tmpdir(),'stack-copy-')),repo=join(root,'repo'),copies=join(root,'copies'),bin=join(root,'bin');
t.after(()=>{execFileSync('chmod',['-R','u+w',root]);rmSync(root,{recursive:true,force:true})});
mkdirSync(repo);mkdirSync(bin);
const tracked=execFileSync('git',['-C',ROOT,'ls-files','-co','--exclude-standard','--','scripts','packages','adapters','roles','RELEASE','package.json','package-lock.json',':!packages/*/tests',':!scripts/foundation'],{encoding:'utf8'}).trim().split('\n');
for(const f of tracked)if(existsSync(join(ROOT,f)))cpSync(join(ROOT,f),join(repo,f));
// Lightweight npm-ci fixture; real pin/host/proof integration is tested separately.
const pkg={name:'copy-fixture',version:'1.0.0'};
if(!realPin){writeFileSync(join(repo,'package.json'),JSON.stringify(pkg));
writeFileSync(join(repo,'package-lock.json'),JSON.stringify({name:pkg.name,version:pkg.version,lockfileVersion:3,packages:{'':pkg}}));}
else {for(const f of ['package.json','package-lock.json'])cpSync(join(ROOT,f),join(repo,f));}
git(repo,'init','-q','-b','refactor');git(repo,'config','gc.auto','0');git(repo,'add','.');git(repo,'commit','-qm','fixture');
const id=git(repo,'rev-parse','HEAD');git(repo,'update-ref','refs/remotes/origin/refactor',id);
const env={PATH:`${bin}:${dirname(process.execPath)}:/usr/bin:/bin`,HOME:root,XDG_DATA_HOME:join(root,'data'),XDG_CONFIG_HOME:join(root,'config')};
const search=join(root,'unit-search');mkdirSync(search);
const analyze=body=>{writeFileSync(join(bin,'systemd-analyze'),'#!/bin/sh\n'+body);chmodSync(join(bin,'systemd-analyze'),0o755);};
analyze(`printf '%s\\n' '${search}'\n`);
const run=(script,args=[])=>spawnSync(join(repo,'scripts',script),args,{env,encoding:'utf8',timeout:30000});
const fake=body=>{writeFileSync(join(bin,'systemctl'),'#!/bin/bash\n'+body);chmodSync(join(bin,'systemctl'),0o755)};
return {root,repo,copies,bin,id,env,run,fake,search,analyze};
}
test('shared copy: npm ci, pushed reachability, readonly/existing copy, webui delegation',t=>{
const s=setup(t),r=s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]);assert.equal(r.status,0,r.stderr);
const copy=join(s.copies,s.id);assert.equal(statSync(copy).mode&0o777,0o555);
for(const f of ['scripts/mosaic','adapters/pi/adapter.sh','packages/queue/src/cli.mjs','package.json','package-lock.json','packages/tasks/requirements.json'])assert.ok(existsSync(join(copy,f)),f);
const npm=join(s.bin,'npm');writeFileSync(npm,'#!/bin/sh\nexit 99\n');chmodSync(npm,0o755);
const existing=s.run('webui-service.sh',['copy',s.id,'--copies',s.copies]);assert.equal(existing.status,0);assert.equal(existing.stdout,`exists: ${copy}\n`);unlinkSync(npm);
git(s.repo,'update-ref','refs/remotes/origin/refactor',git(s.repo,'hash-object','-w','package.json'));
const denied=s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]);assert.notEqual(denied.status,0);assert.match(denied.stderr,/origin\/refactor/);
});
test('wrapper preserves Q81 lease and marker, rejects foreign/symlink; check uses effective roots',t=>{
const s=setup(t);assert.equal(s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]).status,0);
const wrapper=join(s.bin,'mosaic'),marker='# mosaic-cli wrapper (Q81, agents/sage/work/q81/mosaic-cli.sh)';
const lease=`[ "\${1:-}" = __lease-capability ] && exec "${s.bin}/mosaic-old" "$@"`;
writeFileSync(wrapper,`#!/usr/bin/env bash\n${marker}\n${lease}\nexec "/old/scripts/mosaic" "$@"\n`);
assert.equal(s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin]).status,0);
assert.match(readFileSync(wrapper,'utf8'),new RegExp(s.id));assert.ok(readFileSync(wrapper,'utf8').includes(lease));
const old=join(s.bin,'mosaic-old');writeFileSync(old,'#!/bin/sh\nprintf \"legacy:%s\\n\" \"$1\"\n');chmodSync(old,0o755);
assert.equal(spawnSync(wrapper,['__lease-capability'],{env:s.env,encoding:'utf8'}).stdout,'legacy:__lease-capability\n');
const copy=join(s.copies,s.id);s.fake(`case "$*" in *mosaic-bus*) echo 'LoadState=loaded'; echo 'WorkingDirectory=${copy}'; echo 'ExecStart={ path=${copy}/scripts/mosaic ; argv[]=${copy}/scripts/mosaic bus start mosaic-stack --pm ; }';; *) echo 'LoadState=loaded'; echo 'WorkingDirectory=${copy}'; echo 'ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node ${copy}/packages/webui/src/cli.mjs serve ; }';; esac\n`);
assert.equal(s.run('stack-copy.sh',['check','--copies',s.copies,'--bin',s.bin]).status,0);
s.fake(`echo 'LoadState=loaded'; echo 'WorkingDirectory=${copy}';case \"$*\" in *mosaic-bus*)echo 'ExecStart={ path=/other/scripts/mosaic ; argv[]=/other/scripts/mosaic bus start demo ; }';; *)echo 'ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node ${copy}/packages/webui/src/cli.mjs serve ; }';;esac\n`);
assert.equal(s.run('stack-copy.sh',['check','--copies',s.copies,'--bin',s.bin]).status,1,'effective ExecStart mismatch');
s.fake(`echo 'LoadState=loaded'; echo 'WorkingDirectory=/other';\n`);assert.equal(s.run('stack-copy.sh',['check','--copies',s.copies,'--bin',s.bin]).status,1);
const foreign=`#!/usr/bin/env bash\n# foreign wrapper\n${lease}\nexec \"/old/scripts/mosaic\" \"$@\"\n`;writeFileSync(wrapper,foreign);assert.notEqual(s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin]).status,0);assert.equal(readFileSync(wrapper,'utf8'),foreign);
const backing=join(s.bin,'backing');writeFileSync(backing,`#!/usr/bin/env bash\n${marker}\n${lease}\nexec \"/old/scripts/mosaic\" \"$@\"\n`);unlinkSync(wrapper);symlinkSync(backing,wrapper);assert.notEqual(s.run('stack-copy.sh',['wrapper',s.id,'--copies',s.copies,'--bin',s.bin]).status,0);
});
test('bus unit copy/pm pins, install refuses all busy states and failed reads, drop-in refusal',t=>{
const s=setup(t);assert.equal(s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]).status,0);
const args=['--copy',s.id,'--copies',s.copies,'--pm'];
const render=s.run('bus-service.sh',['render',...args]);assert.equal(render.status,0,render.stderr);assert.match(render.stdout,new RegExp(`WorkingDirectory=${s.copies}/${s.id}`));assert.match(render.stdout,/bus start %i --pm/);
const units=join(s.root,'units');
for(const state of ['active','activating','deactivating','reloading']){s.fake(`echo '[email protected] loaded ${state} running fixture';\n`);const r=s.run('bus-service.sh',['install',...args,'--dir',units,'--no-reload']);assert.notEqual(r.status,0,state);assert.ok(!existsSync(units));}
s.fake('exit 1\n');assert.notEqual(s.run('bus-service.sh',['install',...args,'--dir',units,'--no-reload']).status,0);assert.ok(!existsSync(units));
const dropdir=join(s.search,'[email protected]');mkdirSync(dropdir);const drop=join(dropdir,'pm.conf');writeFileSync(drop,'[Service]\nExecStart=elsewhere\n');
s.fake(`case "$*" in *list-units*) echo '[email protected] loaded inactive dead fixture';; *show*) echo 'LoadState=loaded';echo 'DropInPaths=${drop}';; esac\n`);
const refused=s.run('bus-service.sh',['install',...args,'--dir',units,'--no-reload']);assert.notEqual(refused.status,0);assert.ok(refused.stderr.includes(drop));assert.ok(!existsSync(units));
writeFileSync(drop,'[Service]\nEnvironment=SAFE=1\n');assert.equal(s.run('bus-service.sh',['install',...args,'--dir',units,'--no-reload']).status,0);
});
test('missing package manifest refuses before npm; npm failure removes the unpublished temp copy', t=>{
const s=setup(t);git(s.repo,'rm','package.json');git(s.repo,'commit','-qm','missing package');const id=git(s.repo,'rev-parse','HEAD');git(s.repo,'update-ref','refs/remotes/origin/refactor',id);
const npm=join(s.bin,'npm'),called=join(s.root,'npm-called');writeFileSync(npm,`#!/bin/bash\necho called > '${called}'\nexit 1\n`);chmodSync(npm,0o755);
const r=s.run('stack-copy.sh',['copy',id,'--copies',s.copies]);assert.notEqual(r.status,0);assert.match(r.stderr,/package.json/);assert.ok(!existsSync(called));assert.ok(!existsSync(join(s.copies,id)));
git(s.repo,'reset','--hard',s.id);git(s.repo,'update-ref','refs/remotes/origin/refactor',s.id);
const tar=join(s.bin,'tar');writeFileSync(tar,'#!/bin/sh\n/usr/bin/tar \"$@\" || exit $?\nfor a; do dest=\"$a\"; done\nrm \"$dest/package.json\"\n');chmodSync(tar,0o755);
const absent=s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]);assert.notEqual(absent.status,0);assert.match(absent.stderr,/package.json.*missing/);assert.ok(!existsSync(called));unlinkSync(tar);
const fail=s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]);assert.notEqual(fail.status,0);assert.match(fail.stderr,/npm ci failed/);assert.ok(existsSync(called));assert.deepEqual(execFileSync('ls',['-A',s.copies],{encoding:'utf8'}),'');
});
async function until(fn,ms=10000){const end=Date.now()+ms;while(Date.now()<end){if(fn())return;await new Promise(r=>setTimeout(r,25));}throw Error('fixture startup timed out');}
async function childResult(command,args,env,input){
const p=spawn(command,args,{env,stdio:['pipe','pipe','pipe']});let stdout='',stderr='';
p.stdout.on('data',c=>stdout+=c);p.stderr.on('data',c=>stderr+=c);p.stdin.end(input);
const timer=setTimeout(()=>p.kill('SIGKILL'),15000);
try{const [status]=await once(p,'close');return {status,stdout,stderr};}finally{clearTimeout(timer)}
}
test('real pinned install loads bus and Console; same-copy mosaic talk passes proof, other trees refuse', {timeout:180000},async t=>{
const s=setup(t,true);
const copied=s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]);assert.equal(copied.status,0,copied.stderr);
const root=join(s.copies,s.id),url=f=>pathToFileURL(join(root,f)).href;
const {checkEnginePin}=await import(url('packages/conversation/src/pi-pin.mjs'));assert.equal(checkEnginePin(root).version,'0.85.1');
const f=fixture(join(s.root,'home'));notifyConfig(f.dataRoot,'acme',null);
// Only synthetic configuration, no inherited credentials/agent environment.
const env={...s.env,MOSAIC_CONFIG:f.config};
const host=spawn(join(root,'scripts/mosaic'),['bus','start','acme'],{env,stdio:['ignore','pipe','pipe']});let hostOut='',hostErr='';
host.stdout.on('data',c=>hostOut+=c);host.stderr.on('data',c=>hostErr+=c);
try{await until(()=>hostOut.includes('bus host up')||host.exitCode!==null);assert.match(hostOut,/bus host up/,hostErr);}
finally{if(host.exitCode===null&&host.signalCode===null){const done=once(host,'close');host.kill('SIGTERM');await done;}}
const web=spawn(process.execPath,[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme','--board','http://127.0.0.1:9'],{env,stdio:['ignore','pipe','pipe']});let webOut='',webErr='';
web.stdout.on('data',c=>webOut+=c);web.stderr.on('data',c=>webErr+=c);
try{await until(()=>/Mosaic Console: http:/.test(webOut)||web.exitCode!==null);const port=webOut.match(/127\.0\.0\.1:(\d+)/)?.[1];assert.ok(port,webErr);assert.equal((await fetch(`http://127.0.0.1:${port}/healthz`)).status,200);}
finally{if(web.exitCode===null&&web.signalCode===null){const done=once(web,'close');web.kill();await done;}}
// The production runtime derives cliPath. Only agent ancestry is mocked;
// real pid/start/argv/nonce and the runtime's entry-path binding remain intact.
const proofScript=join(s.root,'proof-runtime.mjs');
writeFileSync(proofScript,`
import {mock} from 'node:test';
const humanURL=${JSON.stringify(url('packages/bus/src/human.mjs'))};
const real=await import(humanURL);
mock.module(humanURL,{namedExports:{verifyHuman:(proof,options)=>real.verifyHuman(proof,{...options,readProcess:pid=>({...real.readProcess(pid),ppid:1})})}});
const {startBroker}=await import(${JSON.stringify(url('packages/bus/src/runtime.mjs'))});
const runtime=await startBroker({dataRoot:${JSON.stringify(f.dataRoot)},businesses:${JSON.stringify(BUSINESSES)}});
const broker=runtime.broker;
const pm=broker.bindLaunch({business:'demo',role:'pm',run:'pm-fixture',harness:'pi'});broker.request(pm,{verb:'role.claim'});
const request=broker.request.bind(broker);broker.request=(cap,r)=>{const result=request(cap,r);if(r.verb==='message.send'&&r.args.to==='pm')request(pm,{verb:'message.send',args:{to:'human',body:'same-copy reply',in_reply_to:result.id}});return result;};
process.on('SIGTERM',async()=>{await runtime.close();process.exit(0)});
console.log('proof runtime ready');
`);
const proof=spawn(process.execPath,['--experimental-test-module-mocks',proofScript],{env,stdio:['ignore','pipe','pipe']});let proofOut='',proofErr='';
proof.stdout.on('data',c=>proofOut+=c);proof.stderr.on('data',c=>proofErr+=c);
const socket=join(f.dataRoot,'bus','broker.sock');
try{
await until(()=>proofOut.includes('proof runtime ready')||proof.exitCode!==null);assert.match(proofOut,/proof runtime ready/,proofErr);
const result=await childResult(join(root,'scripts/mosaic'),['talk','pm','hello','--business','demo','--wait','1'],env);
assert.equal(result.status,0,result.stderr);assert.match(result.stdout,/sent .* to pm/);assert.match(result.stdout,/same-copy reply/);
for(const other of [ROOT,join(s.root,'other-copy')]){
if(other!==ROOT)cpSync(join(root,'packages'),join(other,'packages'),{recursive:true});
const result=await childResult(process.execPath,[join(other,'packages/bus/src/human-cli.mjs'),socket],env,JSON.stringify({business:'demo',verb:'inbox'})+'\n');
assert.notEqual(result.status,0);assert.equal(result.stderr.trim(),'human-required');
}
}finally{if(proof.exitCode===null&&proof.signalCode===null){const done=once(proof,'close');proof.kill('SIGTERM');await done;}}
});
test('bus status reads effective copy/pm/drop-ins without writes; check refuses an overridden ExecStart',t=>{
const s=setup(t);assert.equal(s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]).status,0);
const copy=join(s.copies,s.id),drop=join(s.root,'pm.conf');
const rows=`LoadState=loaded\nWorkingDirectory=${copy}\nExecStart={ path=${copy}/scripts/mosaic ; argv[]=${copy}/scripts/mosaic bus start demo --pm ; }\nDropInPaths=${drop}\n`;
s.fake(`case "$*" in *show*) cat <<'ROWS'\n${rows}ROWS\n;; *is-active*) echo inactive;; *is-enabled*) echo disabled;; *)exit 99;;esac\n`);
const status=s.run('bus-service.sh',['status','demo']);assert.equal(status.status,0,status.stderr);assert.match(status.stdout,new RegExp(`copy: ${s.id}`));assert.match(status.stdout,/pm: on/);assert.ok(status.stdout.includes(drop));
});
test('drop-ins in either override field and malformed metadata refuse; uninstall also checks --no-reload',t=>{
const s=setup(t),units=join(s.root,'units'),dropdir=join(s.search,'[email protected]');mkdirSync(dropdir);const drop=join(dropdir,'override.conf');
for(const field of ['ExecStart','WorkingDirectory']){
writeFileSync(drop,`[Service]\n${field}=override\n`);
s.fake(`case "$*" in *list-units*) echo '[email protected] loaded inactive dead fixture';; *[email protected]*) echo 'DropInPaths=${drop}';; *)echo DropInPaths=;;esac\n`);
const r=s.run('bus-service.sh',['install','--dir',units,'--no-reload']);assert.notEqual(r.status,0);assert.ok(r.stderr.includes(drop));assert.ok(!existsSync(units));
}
s.fake('exit 0\n');s.analyze('exit 0\n');const unknown=s.run('bus-service.sh',['install','--dir',units,'--no-reload']);assert.notEqual(unknown.status,0);assert.match(unknown.stderr,/unknown/);
mkdirSync(units);const file=join(units,'[email protected]');writeFileSync(file,'unchanged');
s.fake("echo '[email protected] loaded active running fixture'\n");assert.notEqual(s.run('bus-service.sh',['uninstall','--dir',units,'--no-reload']).status,0);assert.equal(readFileSync(file,'utf8'),'unchanged');
});
test('install never shows a template name on systemd 261; template drop-ins still refuse',t=>{
const s=setup(t),units=join(s.root,'units'),calls=join(s.root,'systemctl-calls');
s.fake(`echo "$*" >> '${calls}'\ncase "$*" in *show*[email protected]*) echo 'neither a valid invocation ID nor unit name' >&2; exit 1;; *list-units*) echo '[email protected] loaded inactive dead fixture';; *show*)echo DropInPaths=;; esac\n`);
const result=s.run('bus-service.sh',['install','--dir',units,'--no-reload']);assert.equal(result.status,0,result.stderr);
assert.doesNotMatch(readFileSync(calls,'utf8'),/show mosaic-bus@\.service/);
const dropdir=join(s.search,'[email protected]');mkdirSync(dropdir);const file=join(dropdir,'pm.conf');writeFileSync(file,'[Service]\nExecStart=override\n');
const before=readFileSync(join(units,'[email protected]'),'utf8');
const denied=s.run('bus-service.sh',['install','--dir',units,'--no-reload']);assert.notEqual(denied.status,0);assert.ok(denied.stderr.includes(file));assert.equal(readFileSync(join(units,'[email protected]'),'utf8'),before);
});
test('unlisted instance drop-in is found in another unit lookup directory; path discovery errors refuse',t=>{
const s=setup(t),units=join(s.root,'units'),second=join(s.root,'second-search');mkdirSync(second);
const dropdir=join(second,'[email protected]');mkdirSync(dropdir);const file=join(dropdir,'cwd.conf');writeFileSync(file,'[Service]\nWorkingDirectory=/other\n');
s.analyze(`printf '%s\\n' '${s.search}' '${second}'\n`);
s.fake("case \"$*\" in *list-units*) echo '[email protected] loaded inactive dead fixture';; *show*)echo DropInPaths=;; esac\n");
const denied=s.run('bus-service.sh',['install','--dir',units,'--no-reload']);assert.notEqual(denied.status,0);assert.ok(denied.stderr.includes(file));assert.ok(!existsSync(units));
s.analyze('exit 1\n');const failed=s.run('bus-service.sh',['install','--dir',units,'--no-reload']);assert.notEqual(failed.status,0);assert.match(failed.stderr,/unit-paths failed/);assert.ok(!existsSync(units));
});
+18 -18
View File
@@ -110,24 +110,24 @@ system config at start: without it serve refuses with `--send needs the bus
configured` and exits 2, which the unit doesn't restart. `status` says
whether the installed unit sends.
The unit runs a read-only pinned copy of the Console's paths, not the
working checkout (row 77). `scripts/webui-service.sh copy COMMIT` makes
one from a commit on `refactor`, and `install --copy ID` points the unit
at it, with serve's `--checkout` naming the checkout, where the queue read
still runs, so the queue view isn't pinned and shows the queue as it is
now. The update step is `copy`, stop, `install --copy ID` with the same
flags, start; rollback is the same with the earlier id (one at or after
row 77's landing, since an older `cli.mjs` refuses `--checkout`), and
copies are kept (they are read-only, files and directories; remove one
with `chmod -R u+w DIR && rm -rf DIR`). `install --copy ID` refuses unless
the bus unit runs from the same copy, because the bus accepts a human
proof only from its own `human-cli.mjs`. The bus still runs from the
checkout, so the update step can't be used until a later row puts the bus
on the copy. The stop and start are Jason's or Mos's: `install` and
`uninstall` refuse while the unit runs. With `--auth-header
X-authentik-uid` the allowed user must be the 64-character Authentik uid
(lead decision 101). `status` names the copy and whether `refactor` has
moved past it.
The unit can run a read-only shared stack copy (rows 77 and 85), with
serve's --checkout naming the canonical checkout so the queue stays current.
webui-service.sh copy calls stack-copy.sh copy: the default directory is
mosaic-stack/copies/<full id>, and commits must be on both local and pushed
refactor. The copy includes bus/CLI/Console code, adapters and pinned npm
dependencies. install --copy ID refuses unless the bus's effective
ExecStart and WorkingDirectory name that same copy, including drop-ins.
It also refuses while the Console runs. With --auth-header X-authentik-uid,
allow-user must be the 64-character Authentik uid (decision 101). Status
names the copy and refactor drift. Files and directories are read-only;
copies are retained. Existing Console-only copies are not migrated or pruned.
The single update and rollback sequence is
[TOOLS.md, Pinned stack update](../../docs/TOOLS.md#pinned-stack-update).
A bus restart ends managed sessions, so no PM turn may be in flight.
Switching the Console, bus or wrapper, changing any unit, and starting or
stopping either service requires Mos's go for that live step. Agents build
and test fixtures; they stop before the live sequence.
## Message a role (row 76, #1570)
+15 -11
View File
@@ -33,15 +33,18 @@ function tmp(t, prefix) {
function fixtureRepo(t) {
const dir = tmp(t, 'webui-copy-');
const repo = join(dir, 'repo');
const files = execFileSync('git', ['-C', REPO, 'ls-files', '-z', '-co', '--exclude-standard', '--', 'packages', 'scripts', 'roles', 'RELEASE', ':!packages/*/tests', ':!scripts/foundation'], { encoding: 'utf8' }).split('\0').filter(Boolean);
const files = execFileSync('git', ['-C', REPO, 'ls-files', '-z', '-co', '--exclude-standard', '--', 'packages', 'scripts', 'roles', 'adapters', 'RELEASE', ':!packages/*/tests', ':!scripts/foundation'], { encoding: 'utf8' }).split('\0').filter(Boolean);
for (const f of files) if (existsSync(join(REPO, f))) cpSync(join(REPO, f), join(repo, f));
writeFileSync(join(repo, 'RELEASE'), '0.0.0-fixture\n');
writeFileSync(join(repo,'package.json'),JSON.stringify({name:'copy-fixture',version:'1.0.0'}));
writeFileSync(join(repo,'package-lock.json'),JSON.stringify({name:'copy-fixture',version:'1.0.0',lockfileVersion:3,packages:{'':{name:'copy-fixture',version:'1.0.0'}}}));
git(dir, 'init', '-q', '-b', 'refactor', repo);
// The commit leaves thousands of loose objects; a detached gc --auto
// would still be deleting them when the cleanup's chmod walks .git.
git(repo, 'config', 'gc.auto', '0');
git(repo, 'add', '-A');
git(repo, 'commit', '-q', '-m', 'fixture');
git(repo,'update-ref','refs/remotes/origin/refactor',git(repo,'rev-parse','HEAD'));
return { dir, repo, script: join(repo, 'scripts', 'webui-service.sh'), head: git(repo, 'rev-parse', 'HEAD') };
}
@@ -69,12 +72,12 @@ test('copy writes the Console paths at the full id, read-only, and leaves an exi
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, new RegExp(`^copied: ${target}$`, 'm'));
assert.deepEqual(readdirSync(copies), [head], 'no temp directory left behind');
assert.deepEqual(readdirSync(target).sort(), ['RELEASE', 'packages', 'roles', 'scripts']);
assert.deepEqual(readdirSync(join(target, 'scripts')), ['mosaic-config.mjs']);
assert.deepEqual(readdirSync(target).sort(), ['RELEASE', 'adapters', 'package-lock.json', 'package.json', 'packages', 'roles', 'scripts']);
assert.deepEqual(readdirSync(join(target, 'scripts')), ['mosaic', 'mosaic-config.mjs']);
assert.ok(existsSync(join(target, 'packages', 'webui', 'README.md')), "the unit's Documentation=");
assert.equal(statSync(join(target, 'packages', 'webui', 'README.md')).mode & 0o222, 0, 'README.md is read-only');
assert.ok(existsSync(join(target, 'packages', 'tasks', 'fields.json')), 'the task field table');
assert.ok(!existsSync(join(target, 'packages', 'queue')), 'the queue read runs from the checkout');
assert.ok(existsSync(join(target, 'packages', 'queue', 'src')), 'wrapper queue branch imports queue code; mutations still refuse off checkout');
assert.equal(readFileSync(join(target, 'RELEASE'), 'utf8'), '0.0.0-fixture\n');
const files = filesUnder(target);
assert.ok(files.length > 50, String(files.length));
@@ -99,7 +102,7 @@ test('copy writes the Console paths at the full id, read-only, and leaves an exi
// The default directory is under XDG_DATA_HOME, else ~/.local/share.
const byDefault = await run(dir, script, ['copy', 'refactor']);
assert.equal(byDefault.status, 0, byDefault.stderr);
assert.ok(existsSync(join(dir, '.local', 'share', 'mosaic-console', head, 'packages', 'webui', 'src', 'cli.mjs')));
assert.ok(existsSync(join(dir, '.local', 'share', 'mosaic-stack', 'copies', head, 'packages', 'webui', 'src', 'cli.mjs')));
assert.equal(git(repo, 'status', '--porcelain'), '', 'the checkout is untouched');
});
@@ -112,7 +115,7 @@ test('copy refuses a commit off refactor, an unknown one, a worktree and a bad c
git(repo, 'commit', '-q', '-m', 'side');
const side = git(repo, 'rev-parse', 'HEAD');
git(repo, 'checkout', '-q', 'refactor');
for (const [commit, why] of [[side, new RegExp(`^webui-service: refusing: ${side} is not reachable from refs/heads/refactor$`, 'm')], ['side', /is not reachable from refs\/heads\/refactor/], ['nope', /^webui-service: not a commit: nope$/m], ['a b', /commit must be a commit id or branch name/], ['HEAD~1', /commit must be a commit id or branch name/], ['.refactor', /^webui-service: commit must be a commit id or branch name: \.refactor$/m]]) {
for (const [commit, why] of [[side, new RegExp(`^stack-copy: refusing: ${side} is not reachable from refs/heads/refactor$`, 'm')], ['side', /is not reachable from refs\/heads\/refactor/], ['nope', /^stack-copy: not a commit: nope$/m], ['a b', /commit must be a commit id or branch name/], ['HEAD~1', /commit must be a commit id or branch name/], ['.refactor', /^stack-copy: commit must be a commit id or branch name: \.refactor$/m]]) {
const r = await run(dir, script, ['copy', commit, '--copies', copies]);
assert.equal(r.status, 1, commit);
assert.match(r.stderr, why, commit);
@@ -132,7 +135,7 @@ test('copy refuses a commit off refactor, an unknown one, a worktree and a bad c
git(repo, 'worktree', 'add', '-q', '--detach', tree, 'refactor');
const fromTree = await run(dir, join(tree, 'scripts', 'webui-service.sh'), ['copy', 'refactor', '--copies', copies]);
assert.equal(fromTree.status, 1);
assert.match(fromTree.stderr, /^webui-service: refusing: \S+ is not the canonical checkout \(its \.git is not a directory\)/m);
assert.match(fromTree.stderr, /^stack-copy: refusing: \S+ is not the canonical checkout \(its \.git is not a directory\)/m);
assert.ok(!existsSync(copies));
const allowed = await run(dir, join(tree, 'scripts', 'webui-service.sh'), ['copy', 'refactor', '--copies', copies, '--allow-worktree']);
assert.equal(allowed.status, 0, allowed.stderr);
@@ -145,9 +148,10 @@ test('copy writes nothing when an archived path is missing at the commit', async
const copies = join(dir, 'copies');
git(repo, 'rm', '-q', 'scripts/mosaic-config.mjs');
git(repo, 'commit', '-q', '-m', 'drop the config check');
git(repo,'update-ref','refs/remotes/origin/refactor',git(repo,'rev-parse','HEAD'));
const r = await run(dir, script, ['copy', 'refactor', '--copies', copies]);
assert.equal(r.status, 1);
assert.match(r.stderr, /^webui-service: git archive of [0-9a-f]{40} failed; no copy written$/m);
assert.match(r.stderr, /^stack-copy: git archive failed; no copy written$/m);
assert.deepEqual(readdirSync(copies), [], 'the temp directory is removed');
});
@@ -194,7 +198,7 @@ async function copyWithMv(t, mvBody) {
test('a copy whose rename fails with no target refuses, writes no copy and leaves no temp directory', async (t) => {
const r = await copyWithMv(t, 'exit 1');
assert.equal(r.status, 1);
assert.equal(r.stderr, `webui-service: could not rename the copy into ${r.target}\n`);
assert.ok(r.stderr.includes(`stack-copy: could not rename copy into ${r.target}\n`));
assert.equal(r.stdout, '');
assert.deepEqual(readdirSync(r.copies), [], 'no target and no temp directory');
});
@@ -211,7 +215,7 @@ test('a copy that meets a target holding files says exists: and moves nothing in
test('a copy that meets an empty target directory replaces it by rename, not by moving into it', async (t) => {
const r = await copyWithMv(t, 'for a; do t="$a"; done\nmkdir "$t"\nexec /usr/bin/mv "$@"');
assert.equal(r.status, 0, r.stderr);
assert.equal(r.stdout, `copied: ${r.target}\ninstall it with: scripts/webui-service.sh install --copy ${r.head}, with the unit's flags\n`);
assert.equal(r.stdout, `copied: ${r.target}\n`);
assert.deepEqual(readdirSync(r.copies), [r.head], 'the temp directory is gone');
assert.ok(existsSync(join(r.target, 'packages', 'webui', 'README.md')), 'the copy is the target itself');
});
@@ -266,7 +270,7 @@ test('serve runs from a copy: /healthz 200, the config check, roles and RELEASE
assert.equal((await plain.get('/healthz')).status, 200);
const none = await plain.get('/api/queue');
assert.equal(none.status, 503);
assert.equal(none.body.error, 'read-failed');
assert.equal(none.body.error, 'queue-refused');
});
test('serve refuses a --checkout that is not an absolute directory path', async (t) => {
+12 -10
View File
@@ -24,7 +24,7 @@ const PREVIEW_FLAGS = `--public-host console.example.com --auth-header X-authent
// A copy as `copy` leaves it, reduced to the file install looks for.
const COPY_ID = '0123456789abcdef0123456789abcdef01234567';
const INSTALL = ['--copy', COPY_ID, '--allow-worktree'];
const copyRoot = (dir) => join(dir, '.local', 'share', 'mosaic-console', COPY_ID);
const copyRoot = (dir) => join(dir, '.local', 'share', 'mosaic-stack', 'copies', COPY_ID);
function fakeCopy(dir) {
mkdirSync(join(copyRoot(dir), 'packages', 'webui', 'src'), { recursive: true });
writeFileSync(join(copyRoot(dir), 'packages', 'webui', 'src', 'cli.mjs'), '');
@@ -61,9 +61,9 @@ function tmp(t) {
return dir;
}
// The bus unit as `systemctl show -p LoadState -p ExecStart` prints it,
// The bus unit as `systemctl show -p LoadState -p WorkingDirectory -p ExecStart` prints it,
// started from `root`.
const busShow = (root) => `LoadState=loaded\nExecStart={ path=${root}/scripts/mosaic ; argv[]=${root}/scripts/mosaic bus start acme ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }\n`;
const busShow = (root) => `LoadState=loaded\nWorkingDirectory=${root}\nExecStart={ path=${root}/scripts/mosaic ; argv[]=${root}/scripts/mosaic bus start acme ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }\n`;
const BUS_UNITS = '[email protected] loaded active running Mosaic bus host (acme)\n';
// A stand-in systemctl that prints `lines` for the Console's list-units and
@@ -81,7 +81,7 @@ function standIn(dir, lines = '', { listFails = false, bus = null, busUnits = BU
if (bus !== null) writeFileSync(join(dir, 'bus-show.txt'), bus);
if (busOther !== null) writeFileSync(join(dir, 'bus-show-other.txt'), busOther);
const fail = 'echo "Failed to connect to bus" >&2; exit 1';
const show = bus !== null ? `cat "${dir}/bus-show.txt"` : `r="$HOME/.local/share/mosaic-console/${COPY_ID}"; printf '%s' "${busShow('$r')}"`;
const show = bus !== null ? `cat "${dir}/bus-show.txt"` : `r="$HOME/.local/share/mosaic-stack/copies/${COPY_ID}"; printf '%s' "${busShow('$r')}"`;
writeFileSync(join(bin, 'systemctl'), `#!/bin/sh
echo "$*" >> "${dir}/calls.txt"
case "$*" in
@@ -106,7 +106,7 @@ function quiet(dir) {
return join(home, 'bin');
}
// Every call but the reads: the Console's state and install's bus check.
const READS = /^--user (list-units --no-legend --plain mosaic-webui\.service|list-units --all --no-legend --plain mosaic-bus@\*\.service|show mosaic-bus@\S+ -p LoadState -p ExecStart)$/;
const READS = /^--user (list-units --no-legend --plain mosaic-webui\.service|list-units --all --no-legend --plain mosaic-bus@\*\.service|show mosaic-bus@\S+ -p LoadState -p WorkingDirectory -p ExecStart)$/;
const otherCalls = (file) => readFileSync(file, 'utf8').split('\n').filter(Boolean).filter((c) => !READS.test(c));
// Async, so an in-process server can answer the script's /healthz request.
@@ -227,7 +227,7 @@ test('install without --no-reload runs daemon-reload and nothing that starts the
const r = await run(dir, ['install', ...INSTALL, '--dir', join(dir, 'units')], bin);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /^daemon-reload done$/m);
assert.equal(readFileSync(join(dir, 'calls.txt'), 'utf8'), '--user list-units --all --no-legend --plain mosaic-bus@*.service\n--user show [email protected] -p LoadState -p ExecStart\n--user list-units --no-legend --plain mosaic-webui.service\n--user daemon-reload\n');
assert.equal(readFileSync(join(dir, 'calls.txt'), 'utf8'), '--user list-units --all --no-legend --plain mosaic-bus@*.service\n--user show [email protected] -p LoadState -p WorkingDirectory -p ExecStart\n--user list-units --no-legend --plain mosaic-webui.service\n--user daemon-reload\n');
});
test('uninstall refuses while the unit is running, with or without --no-reload, and removes it once it is not', async (t) => {
@@ -478,7 +478,7 @@ test('install needs --copy naming a copy that exists, and refuses outside the ca
[['--copy', COPY_ID.slice(0, 12)], /^webui-service: --copy must be a full 40-character commit id: 0123456789ab$/m],
[['--copy', COPY_ID.toUpperCase()], /^webui-service: --copy must be a full 40-character commit id: /m],
[['--copy', `${COPY_ID}0`], /^webui-service: --copy must be a full 40-character commit id: /m],
[['--copy', 'f'.repeat(40)], new RegExp(`^webui-service: no copy ${'f'.repeat(40)} in ${esc(join(dir, '.local', 'share', 'mosaic-console'))}; make it with: scripts/webui-service\\.sh copy ${'f'.repeat(40)}$`, 'm')],
[['--copy', 'f'.repeat(40)], new RegExp(`^webui-service: no copy ${'f'.repeat(40)} in ${esc(join(dir, '.local', 'share', 'mosaic-stack', 'copies'))}; make it with: scripts/webui-service\\.sh copy ${'f'.repeat(40)}$`, 'm')],
[['--copy', COPY_ID, '--copies', 'relative'], /^webui-service: copies directory must be an absolute path: relative$/m],
[['--copy', COPY_ID, '--copies', join(dir, 'no copies')], /^webui-service: copies directory contains a character the unit cannot carry: /m],
];
@@ -532,20 +532,22 @@ test('install refuses unless the bus unit runs from the same copy, naming both p
// The same with --business, which names the one unit to read.
s = sys({ bus: busShow(REPO) });
refused(await install(s.bin, ['--business', 'acme']), `[email protected] runs from ${REPO}/scripts/mosaic, not the copy ${copy}; the bus accepts only its own human-cli.mjs, so every Console bus call would fail human-required until the bus runs from the same copy`);
assert.equal(readFileSync(join(s.d, 'calls.txt'), 'utf8'), '--user show [email protected] -p LoadState -p ExecStart\n');
assert.equal(readFileSync(join(s.d, 'calls.txt'), 'utf8'), '--user show [email protected] -p LoadState -p WorkingDirectory -p ExecStart\n');
// A path that only starts with the copy's name is not inside it.
s = sys({ bus: busShow(`${copy}-old`) });
refused(await install(s.bin), `[email protected] runs from ${copy}-old/scripts/mosaic, not the copy ${copy}; the bus accepts only its own human-cli.mjs, so every Console bus call would fail human-required until the bus runs from the same copy`);
s = sys({bus:busShow(copy).replace(`WorkingDirectory=${copy}`, 'WorkingDirectory=/different')});
refused(await install(s.bin), `[email protected] WorkingDirectory is /different, not the copy ${copy}; nothing was changed`);
// Two loaded bus units: each must run from the copy.
s = sys({ busUnits: `${BUS_UNITS}[email protected] loaded active running Mosaic bus host (other)\n` });
assert.equal((await install(s.bin)).status, 0);
assert.match(readFileSync(join(s.d, 'calls.txt'), 'utf8'), /show mosaic-bus@other\.service -p LoadState -p ExecStart/);
assert.match(readFileSync(join(s.d, 'calls.txt'), 'utf8'), /show mosaic-bus@other\.service -p LoadState -p WorkingDirectory -p ExecStart/);
rmSync(unitDir, { recursive: true });
// The second loaded unit is off the copy: refused, naming the second unit and its path.
const two = `${BUS_UNITS}[email protected] loaded active running Mosaic bus host (other)\n`;
s = sys({ busUnits: two, bus: busShow(copy), busOther: busShow(REPO) });
refused(await install(s.bin), `[email protected] runs from ${REPO}/scripts/mosaic, not the copy ${copy}; the bus accepts only its own human-cli.mjs, so every Console bus call would fail human-required until the bus runs from the same copy`);
assert.match(readFileSync(join(s.d, 'calls.txt'), 'utf8'), /show mosaic-bus@acme\.service -p LoadState -p ExecStart\n.*show mosaic-bus@other\.service -p LoadState -p ExecStart/s);
assert.match(readFileSync(join(s.d, 'calls.txt'), 'utf8'), /show mosaic-bus@acme\.service -p LoadState -p WorkingDirectory -p ExecStart\n.*show mosaic-bus@other\.service -p LoadState -p WorkingDirectory -p ExecStart/s);
// No bus unit installed for the business: refused.
s = sys({ bus: 'LoadState=not-found\nExecStart=\n' });
+74 -8
View File
@@ -24,13 +24,16 @@ set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMPLATE="$REPO/packages/cli/systemd/mosaic-bus.service.in"
UNIT_NAME="[email protected]"
USAGE="usage: scripts/bus-service.sh render [--claude PATH] | install [--dir DIR] [--no-reload] [--claude PATH] | uninstall [--dir DIR] [--no-reload] | status <business>"
USAGE="usage: scripts/bus-service.sh render [--copy ID [--copies DIR]] [--pm] [--claude PATH] | install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH] | uninstall [--dir DIR] [--no-reload] | status <business>"
die() { echo "bus-service: $*" >&2; exit 1; }
usage() { echo "$USAGE" >&2; exit 4; }
CLAUDE_BIN=""
CLAUDE_SET=0
COPY_ID=""
COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies"
PM=0
# The unit's MOSAIC_CLAUDE_BIN line, or a comment when there is no claude to
# pin. systemd expands % and splits Environment= on spaces, so the path is
@@ -55,6 +58,16 @@ claude_line() {
render() {
[ -f "$TEMPLATE" ] || die "template missing: $TEMPLATE"
local root="$REPO"
if [ -n "$COPY_ID" ]; then
[[ "$COPY_ID" =~ ^[0-9a-f]{40}$ ]] || die "--copy must be a full 40-character commit id"
case "$COPIES" in /*) ;; *) die "copies directory must be absolute" ;; esac
case "$COPIES" in *[!A-Za-z0-9/._+-]*) die "copies directory contains a character the unit cannot carry" ;; esac
root="$COPIES/$COPY_ID"
[ -f "$root/scripts/mosaic" ] || die "no copy $COPY_ID in $COPIES"
fi
local pm_flags=""
[ "$PM" = 0 ] || pm_flags=" --pm"
local node_dir
node_dir="$(dirname "$(command -v node || true)")"
[ -n "$node_dir" ] && [ "$node_dir" != "." ] || die "node not found on PATH"
@@ -63,7 +76,7 @@ render() {
case "$REPO" in *@*|*'|'*) die "repository path contains a character the template cannot carry: $REPO" ;; esac
local claude
claude="$(claude_line)" || exit 1
sed -e "s|@REPO@|$REPO|g" -e "s|@PATH@|$path|g" -e "s|^@CLAUDE_ENV@$|$claude|" "$TEMPLATE"
sed -e "s|@REPO@|$root|g" -e "s|@PATH@|$path|g" -e "s|@PM_FLAGS@|$pm_flags|g" -e "s|^@CLAUDE_ENV@$|$claude|" "$TEMPLATE"
}
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
@@ -73,14 +86,52 @@ parse_flags() {
case "$1" in
--dir) [ $# -ge 2 ] || usage; UNIT_DIR="$2"; shift 2 ;;
--no-reload) RELOAD=0; shift ;;
--copy) [ $# -ge 2 ] || usage; COPY_ID="$2"; shift 2 ;;
--copies) [ $# -ge 2 ] || usage; COPIES="$2"; shift 2 ;;
--pm) PM=1; shift ;;
--claude) [ $# -ge 2 ] || usage; CLAUDE_BIN="$2"; CLAUDE_SET=1; shift 2 ;;
*) usage ;;
esac
done
}
# Inspect effective unit metadata: drop-ins can override the installed template.
# Any failed read refuses before a write, including --no-reload installations.
install_guard() {
local out unit file
out="$(systemctl --user list-units --all --no-legend --plain 'mosaic-bus@*.service' 2>/dev/null)" || die "refusing: systemctl --user list-units failed, so the bus state is unknown; nothing was changed"
while read -r unit load state rest; do
[[ "$unit" == mosaic-bus@*.service ]] || continue
case "$state" in inactive|failed) ;; *) die "refusing: $unit is running ($state); Jason or Mos stops it first, then install, then start it" ;; esac
done <<< "$out"
# systemctl show rejects a template name. The lookup-path scan also
# covers stopped/unloaded instances that list-units does not enumerate.
local paths directory dropdir content
paths="$(systemd-analyze --user unit-paths 2>/dev/null)" || die "refusing: systemd-analyze --user unit-paths failed; drop-in paths are unknown; nothing was changed"
[ -n "$paths" ] || die "refusing: systemd unit paths are unknown; nothing was changed"
while IFS= read -r directory; do
[ -n "$directory" ] || continue
case "$directory" in /*) ;; *) die "refusing: invalid systemd unit path $directory; nothing was changed" ;; esac
[ -e "$directory" ] || [ -L "$directory" ] || continue
[ -d "$directory" ] && [ -r "$directory" ] && [ -x "$directory" ] || die "refusing: cannot inspect unit path $directory; nothing was changed"
# * includes the empty instance, i.e. [email protected].
for dropdir in "$directory"/mosaic-bus@*.service.d; do
[ -e "$dropdir" ] || [ -L "$dropdir" ] || continue
[ -d "$dropdir" ] && [ -r "$dropdir" ] && [ -x "$dropdir" ] || die "refusing: cannot inspect drop-ins $dropdir; nothing was changed"
for file in "$dropdir"/*.conf; do
[ -e "$file" ] || [ -L "$file" ] || continue
content="$(cat "$file" 2>/dev/null)" || die "refusing: cannot read drop-in $file; nothing was changed"
if grep -Eq '^[[:space:]]*(ExecStart|WorkingDirectory)[[:space:]]*=' <<< "$content"; then
die "refusing: drop-in $file overrides ExecStart or WorkingDirectory; Mos removes it first and reloads systemd"
fi
done
done
done <<< "$paths"
}
install_unit() {
parse_flags "$@"
install_guard
mkdir -p "$UNIT_DIR"
local target="$UNIT_DIR/$UNIT_NAME" tmp unit
unit="$(render)" || exit 1
@@ -120,13 +171,12 @@ MSG
uninstall_unit() {
parse_flags "$@"
[ "$CLAUDE_SET" = 0 ] || usage
[ "$CLAUDE_SET" = 0 ] && [ -z "$COPY_ID" ] && [ "$PM" = 0 ] || usage
local target="$UNIT_DIR/$UNIT_NAME"
if [ "$RELOAD" = 1 ]; then
local active
active="$(systemctl --user list-units --no-legend --plain 'mosaic-bus@*' 2>/dev/null | awk '$3 == "active" || $3 == "activating" || $3 == "deactivating" {print $1}')"
[ -z "$active" ] || die "refusing: instance(s) still running: $(echo "$active" | tr '\n' ' ')stop them first"
fi
local out active
out="$(systemctl --user list-units --all --no-legend --plain 'mosaic-bus@*.service' 2>/dev/null)" || die "refusing: systemctl read failed; nothing was changed"
active="$(awk '$3 != "inactive" && $3 != "failed" {print $1}' <<< "$out")"
[ -z "$active" ] || die "refusing: instance(s) still running: $active; Jason or Mos stops them first"
if [ -f "$target" ]; then
rm -f "$target"
echo "removed: $target"
@@ -148,6 +198,19 @@ status_unit() {
echo " enabled: $(systemctl --user is-enabled "$unit" 2>&1 || true)"
echo " active: $(systemctl --user is-active "$unit" 2>&1 || true)"
systemctl --user show "$unit" -p MainPID -p NRestarts -p ExecMainStartTimestamp -p Result -p Environment 2>/dev/null | sed 's/^/ /'
local effective root exec_start drops copy=checkout
effective="$(systemctl --user show "$unit" -p LoadState -p WorkingDirectory -p ExecStart -p DropInPaths 2>/dev/null)" || die "cannot read effective unit $unit"
root="$(sed -n 's/^WorkingDirectory=//p' <<< "$effective")"
exec_start="$(sed -n 's/^ExecStart=//p' <<< "$effective")"
drops="$(sed -n 's/^DropInPaths=//p' <<< "$effective")"
if [[ "${root##*/}" =~ ^[0-9a-f]{40}$ ]] && [ -f "$root/scripts/mosaic" ];then copy="${root##*/}";fi
echo "tree: ${root:-absent}"
echo "copy: $copy"
if [[ "$exec_start" =~ [[:space:]]--pm([[:space:];]|$) ]];then echo 'pm: on';else echo 'pm: off';fi
echo "drop-ins: ${drops:-none}"
local head
head="$(git -C "$REPO" rev-parse refs/heads/refactor 2>/dev/null || true)"
if [ "$copy" != checkout ] && [ "$head" != "$copy" ];then echo "refactor: ${head:-unknown} (another commit)";else echo "refactor: ${head:-unknown}";fi
"$REPO/scripts/mosaic" bus status | sed 's/^/ /' || true
}
@@ -157,6 +220,9 @@ case "$cmd" in
render)
while [ $# -gt 0 ]; do
case "$1" in
--copy) [ $# -ge 2 ] || usage; COPY_ID="$2"; shift 2 ;;
--copies) [ $# -ge 2 ] || usage; COPIES="$2"; shift 2 ;;
--pm) PM=1; shift ;;
--claude) [ $# -ge 2 ] || usage; CLAUDE_BIN="$2"; CLAUDE_SET=1; shift 2 ;;
*) usage ;;
esac
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# One immutable pushed tree for the bus, Console and human CLI (#1579).
# copy creates files only; wrapper is a separately Mos-gated live step.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies"
BIN="$HOME/.npm-global/bin"
BUSINESS=mosaic-stack
ALLOW_WORKTREE=0
MARKER='# mosaic-cli wrapper (Q81, agents/sage/work/q81/mosaic-cli.sh)'
usage() { echo 'usage: stack-copy.sh copy COMMIT [--copies DIR] [--allow-worktree] | wrapper ID [--copies DIR] [--bin DIR] | check [--copies DIR] [--bin DIR] [--business ID]' >&2; exit "${1:-4}"; }
die() { echo "stack-copy: $*" >&2; exit 1; }
plain_path() { case "$1" in /*) ;; *) die "$2 must be an absolute path: $1" ;; esac; case "$1" in *[!A-Za-z0-9/._+-]*) die "$2 contains a character the unit cannot carry: $1" ;; esac; }
parse() { while [ $# -gt 0 ]; do case "$1" in
--copies) [ $# -ge 2 ] || usage; COPIES="$2"; shift 2;;
--bin) [ "$CMD" != copy ] && [ $# -ge 2 ] || usage; BIN="$2";shift 2;;
--business) [ "$CMD" = check ] && [ $# -ge 2 ] || usage; BUSINESS="$2";shift 2;;
--allow-worktree) [ "$CMD" = copy ] || usage; ALLOW_WORKTREE=1;shift;;
*) usage;; esac; done; plain_path "$COPIES" 'copies directory'; }
copy_root() { [[ "$ID" =~ ^[0-9a-f]{40}$ ]] || die '--copy must be a full 40-character commit id'; [ -f "$COPIES/$ID/scripts/mosaic" ] || die "no copy $ID in $COPIES"; echo "$COPIES/$ID"; }
# Closure of scripts/mosaic, bus process/notifier/launcher, Console serve,
# human-cli and both harness adapters, including on-demand children/data.
COPY_PATHS=(
scripts/mosaic scripts/mosaic-config.mjs
adapters roles RELEASE package.json package-lock.json
packages/bus/src packages/bus/schema.sql packages/business/src packages/cli/src
packages/conversation/src packages/discord/src packages/discord/bin packages/discord/extension packages/harness/src
packages/seat/src packages/tasks/src packages/tasks/fields.json packages/tasks/requirements.json
packages/webui/src packages/webui/README.md packages/cli/README.md
packages/queue/src
)
copy_commit() {
[ -d "$REPO/.git" ] || [ "$ALLOW_WORKTREE" = 1 ] || die "refusing: $REPO is not the canonical checkout (its .git is not a directory); run copy from the canonical checkout"
[[ "$ID" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] || die "commit must be a commit id or branch name: $ID"
local full ref target tmp
full="$(git -C "$REPO" rev-parse --verify --quiet "$ID^{commit}")" || die "not a commit: $ID"
for ref in refs/heads/refactor refs/remotes/origin/refactor; do
git -C "$REPO" rev-parse --verify --quiet "$ref^{commit}" >/dev/null || die "no $ref in $REPO"
git -C "$REPO" merge-base --is-ancestor "$full" "$ref" || die "refusing: $full is not reachable from $ref"
done
target="$COPIES/$full"
[ ! -L "$target" ] || die "refusing: copy target is a symlink: $target"
if [ -e "$target" ]; then echo "exists: $target"; return; fi
mkdir -p "$COPIES"
tmp="$(mktemp -d "$COPIES/.$full.XXXXXX")"
trap "chmod -R u+w '$tmp' 2>/dev/null || true; rm -rf '$tmp'" EXIT
git -C "$REPO" archive --format=tar "$full" -- "${COPY_PATHS[@]}" | tar -xf - -C "$tmp" || die "git archive failed; no copy written"
[ -f "$tmp/package.json" ] && [ -f "$tmp/package-lock.json" ] || die 'package.json or package-lock.json missing in temp copy; npm ci not run'
(cd "$tmp" && npm ci --no-audit --no-fund) >&2 || die 'npm ci failed; no copy written'
find "$tmp" -type f -exec chmod a-w {} +
find "$tmp" -type d -exec chmod 0555 {} +
if ! mv -T "$tmp" "$target" 2>/dev/null; then
[ -d "$target" ] && [ ! -L "$target" ] || die "could not rename copy into $target"
echo "exists: $target"; return
fi
trap - EXIT
echo "copied: $target"
}
wrapper() {
plain_path "$BIN" 'bin directory'
local root file lease tmp
root="$(copy_root)";file="$BIN/mosaic"
[ -f "$file" ] && [ ! -L "$file" ] || die "refusing: wrapper is absent, not regular, or a symlink: $file"
[ "$(sed -n '2p' "$file")" = "$MARKER" ] || die "refusing: foreign wrapper (missing Q81 marker on line 2): $file"
lease='[ "${1:-}" = __lease-capability ] && exec "'"$BIN"'/mosaic-old" "$@"'
[ "$(grep -Fxc "$lease" "$file" || true)" = 1 ] || die 'refusing: Q81 lease line missing or ambiguous'
tmp="$(mktemp "$BIN/.mosaic.XXXXXX")"
trap 'rm -f "$tmp"' EXIT
printf '#!/usr/bin/env bash\n%s\n%s\nexec "%s/scripts/mosaic" "$@"\n' "$MARKER" "$lease" "$root" > "$tmp"
chmod 0755 "$tmp";mv -f "$tmp" "$file";trap - EXIT
echo "wrapper: $root"
}
unit_root() {
local out load root exec_start
out="$(systemctl --user show "$1" -p LoadState -p WorkingDirectory -p ExecStart 2>/dev/null)" || { echo 'absent (systemctl read failed)';return; }
load="$(sed -n 's/^LoadState=//p' <<< "$out")"; root="$(sed -n 's/^WorkingDirectory=//p' <<< "$out")"
exec_start="$(sed -n 's/^ExecStart=//p' <<< "$out")"
if [ "$load" != loaded ] || [ -z "$root" ];then echo absent;return;fi
case "$1" in
mosaic-bus@*) [[ "$exec_start" == *"path=$root/scripts/mosaic ;"* ]] || { echo "$root (ExecStart mismatch)";return; };;
mosaic-webui*) [[ "$exec_start" == *"$root/packages/webui/src/cli.mjs serve"* ]] || { echo "$root (ExecStart mismatch)";return; };;
esac
echo "$root"
}
check() {
plain_path "$BIN" 'bin directory';[[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] || die 'invalid business id'
local bus console cli=absent line path
bus="$(unit_root "mosaic-bus@$BUSINESS.service")";console="$(unit_root mosaic-webui.service)"
if [ -f "$BIN/mosaic" ] && [ ! -L "$BIN/mosaic" ] && [ "$(sed -n '2p' "$BIN/mosaic")" = "$MARKER" ]; then
line="$(tail -n 1 "$BIN/mosaic")"
path="${line#exec \"}";path="${path%\" \"\$@\"}"
if [ "$line" = "exec \"$path\" \"\$@\"" ] && [[ "$path" == */scripts/mosaic ]];then cli="${path%/scripts/mosaic}";fi
fi
printf 'bus: %s\nConsole: %s\nCLI: %s\n' "$bus" "$console" "$cli"
[[ "$bus" == "$COPIES/"* ]] && [[ "${bus#"$COPIES/"}" =~ ^[0-9a-f]{40}$ ]] && [ -f "$bus/scripts/mosaic" ] && [ "$bus" = "$console" ] && [ "$bus" = "$cli" ]
}
[ $# -gt 0 ] || usage
CMD="$1";shift
case "$CMD" in
copy|wrapper) [ $# -gt 0 ] || usage;ID="$1";shift;case "$ID" in -*)usage;;esac;parse "$@";if [ "$CMD" = copy ];then copy_commit;else wrapper;fi;;
check) parse "$@";check;;
-h|--help) usage 0;;
*) usage;;esac
+10 -64
View File
@@ -10,7 +10,7 @@
# write `git archive` of the paths the Console
# loads at COMMIT into DIR/<full id>/, read-only;
# DIR defaults to
# ${XDG_DATA_HOME:-~/.local/share}/mosaic-console
# ${XDG_DATA_HOME:-~/.local/share}/mosaic-stack/copies
# render [--copy ID [--copies DIR]] [FLAGS]
# print the rendered unit on stdout; without
# --copy it runs from this checkout
@@ -37,8 +37,7 @@
# --copy ID` with the same flags; Jason or Mos starts it. Rollback is the
# same with the earlier id. Copies stay until someone removes them. install
# refuses unless the bus unit runs from the same copy (bus_on_copy below),
# and nothing puts the bus on a copy yet, so the step can't be used until a
# later row does.
# row 85 supplies the shared copy and bus unit flags; Mos owns switching.
#
# FLAGS are serve's: --port N (default 7332; 7330 is the operator's own
# Console), --business ID, --board URL, and the preview set --public-host
@@ -72,7 +71,7 @@ die() { echo "webui-service: $*" >&2; exit 1; }
usage() { echo "$USAGE" >&2; exit 4; }
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-console"
COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies"
COPY_ID=""
COPY_FLAGS=0
ALLOW_WORKTREE=0
@@ -152,33 +151,8 @@ serve_flags() {
echo "$flags"
}
# The paths the Console loads, the only ones a copy holds: serve's import
# closure (packages/webui/src/cli.mjs and the human transport child,
# packages/bus/src/human-cli.mjs) reaches these nine src directories
# (packages/tasks/src since row 61: reads.mjs imports its field table
# loader, which reads packages/tasks/fields.json);
# serve runs scripts/mosaic-config.mjs as its config check and reads roles/
# and RELEASE; the unit's Documentation= names the README. The queue read
# runs from the checkout (serve --checkout), so packages/queue is not here.
# The pinned-copy test serves from a copy: a missing import fails it at
# load, and a missing file read at request time (fields.json, RELEASE,
# roles/) fails the route that reads it, which the test also GETs.
COPY_PATHS=(
packages/bus/src
packages/business/src
packages/cli/src
packages/conversation/src
packages/discord/src
packages/harness/src
packages/seat/src
packages/webui/src
packages/webui/README.md
packages/tasks/src
packages/tasks/fields.json
scripts/mosaic-config.mjs
roles
RELEASE
)
# Row 85: stack-copy.sh owns the pushed runtime closure and npm install.
# This script delegates copy and shares its copies directory.
# N2 (row 75 review): copy and install read and point at this checkout, so
# they run from the canonical one, never a worktree someone may remove.
@@ -243,7 +217,7 @@ running() {
# has loaded, and none loaded refuses. Like the state read, a failed call
# refuses with one line.
bus_on_copy() {
local copy="$1" units unit out load paths p
local copy="$1" units unit out load paths p working
if [ -n "$BUSINESS" ]; then
units="mosaic-bus@$BUSINESS.service"
else
@@ -252,7 +226,7 @@ bus_on_copy() {
[ -n "$units" ] || die "refusing: no mosaic-bus@ unit is loaded, and the Console on the copy $copy needs its bus on that copy; name the business with --business ID"
fi
for unit in $units; do
out="$(systemctl --user show "$unit" -p LoadState -p ExecStart 2>/dev/null)" || die "refusing: systemctl --user show $unit failed, so the bus unit is unknown; nothing was changed"
out="$(systemctl --user show "$unit" -p LoadState -p WorkingDirectory -p ExecStart 2>/dev/null)" || die "refusing: systemctl --user show $unit failed, so the bus unit is unknown; nothing was changed"
load="$(printf '%s\n' "$out" | sed -n 's/^LoadState=//p')"
[ "$load" = loaded ] || die "refusing: no bus unit $unit is installed (LoadState ${load:-unknown}); the Console on the copy $copy needs its bus on that copy"
paths="$(printf '%s\n' "$out" | sed -n 's/^ExecStart=//p' | grep -o 'path=[^ ;]*' | sed 's/^path=//' || true)"
@@ -260,6 +234,8 @@ bus_on_copy() {
for p in $paths; do
case "$p" in "$copy"/*) ;; *) die "refusing: $unit runs from $p, not the copy $copy; the bus accepts only its own human-cli.mjs, so every Console bus call would fail human-required until the bus runs from the same copy" ;; esac
done
working="$(printf '%s\n' "$out" | sed -n 's/^WorkingDirectory=//p')"
[ "$working" = "$copy" ] || die "refusing: $unit WorkingDirectory is ${working:-unknown}, not the copy $copy; nothing was changed"
done
}
@@ -376,37 +352,7 @@ fetch(process.argv[1], { signal: AbortSignal.timeout(5000) })
# permission off every file, then renames it into place, so a copy at its
# final path is always whole and read-only. An existing copy is left alone.
copy_commit() {
[ $# -ge 1 ] || usage
local commit="$1"; shift
case "$commit" in -*) usage ;; esac
parse_flags "$@"
[ "$SERVE_SET" = 0 ] && [ "$DIR_FLAGS" = 0 ] && [ -z "$COPY_ID" ] || usage
canonical
copies_ok
case "$commit" in [A-Za-z0-9]*) ;; *) die "commit must be a commit id or branch name: $commit" ;; esac
case "$commit" in *[!A-Za-z0-9._/-]*) die "commit must be a commit id or branch name: $commit" ;; esac
git -C "$REPO" rev-parse --verify --quiet refs/heads/refactor >/dev/null || die "no refs/heads/refactor in $REPO"
local id
id="$(git -C "$REPO" rev-parse --verify --quiet "$commit^{commit}")" || die "not a commit: $commit"
git -C "$REPO" merge-base --is-ancestor "$id" refs/heads/refactor || die "refusing: $id is not reachable from refs/heads/refactor"
local target="$COPIES/$id"
if [ -e "$target" ]; then echo "exists: $target"; return 0; fi
mkdir -p "$COPIES"
COPY_TMP="$(mktemp -d "$COPIES/.$id.XXXXXX")"
trap 'chmod -R u+w "$COPY_TMP" 2>/dev/null; rm -rf "$COPY_TMP"' EXIT
git -C "$REPO" archive --format=tar "$id" -- "${COPY_PATHS[@]}" | tar -x -f - -C "$COPY_TMP" || die "git archive of $id failed; no copy written"
# Read-only files and directories: a writer that replaces by rename
# (git, editors, sed -i) can't change a copy either.
find "$COPY_TMP" -type f -exec chmod a-w {} +
find "$COPY_TMP" -type d -exec chmod 0555 {} +
if ! mv -T "$COPY_TMP" "$target" 2>/dev/null; then
[ -e "$target" ] || die "could not rename the copy into $target"
echo "exists: $target"
return 0
fi
trap - EXIT
echo "copied: $target"
echo "install it with: scripts/webui-service.sh install --copy $id, with the unit's flags"
exec "$REPO/scripts/stack-copy.sh" copy "$@"
}
[ $# -ge 1 ] || usage