diff --git a/docs/TOOLS.md b/docs/TOOLS.md index e52dcf1ce..404dd9391 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -202,7 +202,9 @@ scripts/mosaic talk [--wait SECONDS] [--business ] scripts/mosaic stop scripts/mosaic launches off | on [--business ] scripts/mosaic launches list [--json] -scripts/bus-service.sh render [--claude PATH] | install [--dir DIR] [--no-reload] [--claude PATH] | uninstall | status +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 ``` 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/`. +`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/mosaic-bus@mosaic-stack.service.d/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//`, by default -`${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-console//`. 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//`, by default +`${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies//`. 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 `mosaic-bus@ID.service`; 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 diff --git a/packages/cli/README.md b/packages/cli/README.md index fb964dd44..5328b8a51 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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 ` 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/. 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 diff --git a/packages/cli/systemd/mosaic-bus.service.in b/packages/cli/systemd/mosaic-bus.service.in index 77be69129..50728f675 100644 --- a/packages/cli/systemd/mosaic-bus.service.in +++ b/packages/cli/systemd/mosaic-bus.service.in @@ -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 diff --git a/packages/cli/tests/host.test.mjs b/packages/cli/tests/host.test.mjs index b6644cf3c..e69b87de1 100644 --- a/packages/cli/tests/host.test.mjs +++ b/packages/cli/tests/host.test.mjs @@ -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 \/notify\/ +the notifier refuses a looser directory$/m); assert.equal(readFileSync(join(dir, "mosaic-bus@.service"), "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")); diff --git a/packages/cli/tests/stack-copy.test.mjs b/packages/cli/tests/stack-copy.test.mjs new file mode 100644 index 000000000..2ebacfa8f --- /dev/null +++ b/packages/cli/tests/stack-copy.test.mjs @@ -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 'mosaic-bus@demo.service 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,'mosaic-bus@.service.d');mkdirSync(dropdir);const drop=join(dropdir,'pm.conf');writeFileSync(drop,'[Service]\nExecStart=elsewhere\n'); + s.fake(`case "$*" in *list-units*) echo 'mosaic-bus@demo.service 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()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,'mosaic-bus@demo.service.d');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 'mosaic-bus@demo.service loaded inactive dead fixture';; *mosaic-bus@demo.service*) 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,'mosaic-bus@.service');writeFileSync(file,'unchanged'); + s.fake("echo 'mosaic-bus@demo.service 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*mosaic-bus@.service*) echo 'neither a valid invocation ID nor unit name' >&2; exit 1;; *list-units*) echo 'mosaic-bus@demo.service 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,'mosaic-bus@.service.d');mkdirSync(dropdir);const file=join(dropdir,'pm.conf');writeFileSync(file,'[Service]\nExecStart=override\n'); + const before=readFileSync(join(units,'mosaic-bus@.service'),'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,'mosaic-bus@.service'),'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,'mosaic-bus@unlisted.service.d');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 'mosaic-bus@demo.service 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)); +}); diff --git a/packages/webui/README.md b/packages/webui/README.md index de34660cf..bd4376430 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -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/, 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) diff --git a/packages/webui/tests/pinned-copy.test.mjs b/packages/webui/tests/pinned-copy.test.mjs index 07589efcc..f2bdfa025 100644 --- a/packages/webui/tests/pinned-copy.test.mjs +++ b/packages/webui/tests/pinned-copy.test.mjs @@ -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) => { diff --git a/packages/webui/tests/service.test.mjs b/packages/webui/tests/service.test.mjs index d5d55a1b5..e30d9ca38 100644 --- a/packages/webui/tests/service.test.mjs +++ b/packages/webui/tests/service.test.mjs @@ -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 = 'mosaic-bus@acme.service 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 mosaic-bus@acme.service -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 mosaic-bus@acme.service -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']), `mosaic-bus@acme.service 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 mosaic-bus@acme.service -p LoadState -p ExecStart\n'); + assert.equal(readFileSync(join(s.d, 'calls.txt'), 'utf8'), '--user show mosaic-bus@acme.service -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), `mosaic-bus@acme.service 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), `mosaic-bus@acme.service 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}mosaic-bus@other.service 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}mosaic-bus@other.service loaded active running Mosaic bus host (other)\n`; s = sys({ busUnits: two, bus: busShow(copy), busOther: busShow(REPO) }); refused(await install(s.bin), `mosaic-bus@other.service 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' }); diff --git a/scripts/bus-service.sh b/scripts/bus-service.sh index f641c4795..297c633b2 100755 --- a/scripts/bus-service.sh +++ b/scripts/bus-service.sh @@ -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="mosaic-bus@.service" -USAGE="usage: scripts/bus-service.sh render [--claude PATH] | install [--dir DIR] [--no-reload] [--claude PATH] | uninstall [--dir DIR] [--no-reload] | status " +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 " 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. mosaic-bus@.service.d. + 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 diff --git a/scripts/stack-copy.sh b/scripts/stack-copy.sh new file mode 100755 index 000000000..a3b6f9143 --- /dev/null +++ b/scripts/stack-copy.sh @@ -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 diff --git a/scripts/webui-service.sh b/scripts/webui-service.sh index b330e5cf4..002b01c3c 100755 --- a/scripts/webui-service.sh +++ b/scripts/webui-service.sh @@ -10,7 +10,7 @@ # write `git archive` of the paths the Console # loads at COMMIT into DIR//, 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