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:
+91
-16
@@ -202,7 +202,9 @@ scripts/mosaic talk <role> <text> [--wait SECONDS] [--business <id>]
|
|||||||
scripts/mosaic stop <run>
|
scripts/mosaic stop <run>
|
||||||
scripts/mosaic launches off | on [--business <id>]
|
scripts/mosaic launches off | on [--business <id>]
|
||||||
scripts/mosaic launches list [--json]
|
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
|
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 ·
|
Exit codes: `0` ok · `1` failed or outcome unknown · `2` invalid input ·
|
||||||
`3` refused or config problem · `4` usage. Details: `packages/cli/README.md`.
|
`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`)
|
## Console service (`scripts/webui-service.sh`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -262,9 +344,9 @@ read-only. The template is
|
|||||||
|
|
||||||
The unit runs a pinned copy, not the working checkout (row 77, #1571).
|
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
|
`copy COMMIT` resolves COMMIT to its full id, refuses one not reachable
|
||||||
from `refs/heads/refactor`, and writes `git archive` of the paths the
|
from both `refs/heads/refactor` and `refs/remotes/origin/refactor`, and
|
||||||
Console loads into `DIR/<id>/`, by default
|
delegates to stack-copy.sh for the archive and pinned npm install into `DIR/<id>/`, by default
|
||||||
`${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-console/<id>/`. It writes a
|
`${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
|
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
|
(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
|
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
|
`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
|
from the checkout would fail every bus call with `human-required`. With
|
||||||
`--business ID`, install reads `[email protected]`; without it, every
|
`--business ID`, install reads `[email protected]`; without it, every
|
||||||
loaded `mosaic-bus@` unit. It reads the effective ExecStart from
|
loaded `mosaic-bus@` unit. It reads effective WorkingDirectory and ExecStart from
|
||||||
`systemctl --user show -p LoadState -p ExecStart`, so a drop-in counts,
|
`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
|
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;
|
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
|
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
|
`CLAUDE*`, `CODEX*` or `PI_*` variable. Serve's exit 2 (usage, config, or a
|
||||||
port already in use) is not restarted.
|
port already in use) is not restarted.
|
||||||
|
|
||||||
The update step: `copy` the commit, stop the unit, `install --copy ID`
|
The update/rollback sequence is [Pinned stack update](#pinned-stack-update)
|
||||||
with the same flags, start it. Rollback is the same steps with the earlier
|
above. The queue view reads the checkout and stays current. Copies remain
|
||||||
id, which must be at or after row 77's landing: an older `cli.mjs` refuses
|
until an operator removes them; nothing here prunes or migrates copies.
|
||||||
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.
|
|
||||||
|
|
||||||
There is no start, stop, enable or restart verb. The Console reads the bus
|
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
|
through the human transport, so whoever starts it holds the human's
|
||||||
|
|||||||
+21
-2
@@ -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
|
The template's file name has no `@` because queue candidate manifests refuse
|
||||||
it. It is modelled on `scripts/discord-service.sh`:
|
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
|
a temp file and a rename, then runs `systemctl --user daemon-reload`. It
|
||||||
prints the pinned claude and its `--version`.
|
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.
|
- `uninstall` refuses while an instance is active.
|
||||||
- `status <business>` shows the unit's state and its `Environment`, which
|
- `status <business>` shows the unit's state and its `Environment`, which
|
||||||
includes the pinned claude.
|
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
|
order on that system target. The notifier needs no network at start: a
|
||||||
send that fails is journaled and retried.
|
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
|
## Notifier
|
||||||
|
|
||||||
The notifier is a child of the host. Each poll (every 30 s) it reads the
|
The notifier is a child of the host. Each poll (every 30 s) it reads the
|
||||||
|
|||||||
@@ -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
|
# 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
|
# child that dies takes the host down with exit 1, and the unit restarts
|
||||||
# the pair.
|
# the pair.
|
||||||
ExecStart=@REPO@/scripts/mosaic bus start %i
|
ExecStart=@REPO@/scripts/mosaic bus start %i@PM_FLAGS@
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=15
|
RestartSec=15
|
||||||
RestartPreventExitStatus=2 3 4
|
RestartPreventExitStatus=2 3 4
|
||||||
|
|||||||
@@ -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@/);
|
assert.doesNotMatch(render.stdout, /@REPO@|@PATH@|@CLAUDE_ENV@/);
|
||||||
// A user unit cannot order on a system target (Darkwing F3).
|
// A user unit cannot order on a system target (Darkwing F3).
|
||||||
assert.doesNotMatch(render.stdout, /network-online/);
|
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.equal(first.status, 0, first.stderr);
|
||||||
assert.match(first.stdout, /written: /);
|
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.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.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, ["install", "--dir", dir, "--no-reload"], { encoding: "utf8",env:unitEnv }).stdout, /unchanged: /);
|
||||||
assert.match(spawnSync(script, ["uninstall", "--dir", dir, "--no-reload"], { encoding: "utf8" }).stdout, /removed: /);
|
assert.match(spawnSync(script, ["uninstall", "--dir", dir, "--no-reload"], { encoding: "utf8",env:unitEnv }).stdout, /removed: /);
|
||||||
assert.equal(spawnSync(script, ["bogus"], { encoding: "utf8" }).status, 4);
|
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;
|
return p;
|
||||||
};
|
};
|
||||||
mkdirSync(join(dir, "bin"));
|
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 onPath = stand(join(dir, "bin", "claude"));
|
||||||
const named = stand(join(dir, "claude-named"));
|
const named = stand(join(dir, "claude-named"));
|
||||||
symlinkSync(named, join(dir, "claude-link"));
|
symlinkSync(named, join(dir, "claude-link"));
|
||||||
|
|||||||
@@ -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
@@ -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
|
configured` and exits 2, which the unit doesn't restart. `status` says
|
||||||
whether the installed unit sends.
|
whether the installed unit sends.
|
||||||
|
|
||||||
The unit runs a read-only pinned copy of the Console's paths, not the
|
The unit can run a read-only shared stack copy (rows 77 and 85), with
|
||||||
working checkout (row 77). `scripts/webui-service.sh copy COMMIT` makes
|
serve's --checkout naming the canonical checkout so the queue stays current.
|
||||||
one from a commit on `refactor`, and `install --copy ID` points the unit
|
webui-service.sh copy calls stack-copy.sh copy: the default directory is
|
||||||
at it, with serve's `--checkout` naming the checkout, where the queue read
|
mosaic-stack/copies/<full id>, and commits must be on both local and pushed
|
||||||
still runs, so the queue view isn't pinned and shows the queue as it is
|
refactor. The copy includes bus/CLI/Console code, adapters and pinned npm
|
||||||
now. The update step is `copy`, stop, `install --copy ID` with the same
|
dependencies. install --copy ID refuses unless the bus's effective
|
||||||
flags, start; rollback is the same with the earlier id (one at or after
|
ExecStart and WorkingDirectory name that same copy, including drop-ins.
|
||||||
row 77's landing, since an older `cli.mjs` refuses `--checkout`), and
|
It also refuses while the Console runs. With --auth-header X-authentik-uid,
|
||||||
copies are kept (they are read-only, files and directories; remove one
|
allow-user must be the 64-character Authentik uid (decision 101). Status
|
||||||
with `chmod -R u+w DIR && rm -rf DIR`). `install --copy ID` refuses unless
|
names the copy and refactor drift. Files and directories are read-only;
|
||||||
the bus unit runs from the same copy, because the bus accepts a human
|
copies are retained. Existing Console-only copies are not migrated or pruned.
|
||||||
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
|
The single update and rollback sequence is
|
||||||
on the copy. The stop and start are Jason's or Mos's: `install` and
|
[TOOLS.md, Pinned stack update](../../docs/TOOLS.md#pinned-stack-update).
|
||||||
`uninstall` refuse while the unit runs. With `--auth-header
|
A bus restart ends managed sessions, so no PM turn may be in flight.
|
||||||
X-authentik-uid` the allowed user must be the 64-character Authentik uid
|
Switching the Console, bus or wrapper, changing any unit, and starting or
|
||||||
(lead decision 101). `status` names the copy and whether `refactor` has
|
stopping either service requires Mos's go for that live step. Agents build
|
||||||
moved past it.
|
and test fixtures; they stop before the live sequence.
|
||||||
|
|
||||||
## Message a role (row 76, #1570)
|
## Message a role (row 76, #1570)
|
||||||
|
|
||||||
|
|||||||
@@ -33,15 +33,18 @@ function tmp(t, prefix) {
|
|||||||
function fixtureRepo(t) {
|
function fixtureRepo(t) {
|
||||||
const dir = tmp(t, 'webui-copy-');
|
const dir = tmp(t, 'webui-copy-');
|
||||||
const repo = join(dir, 'repo');
|
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));
|
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, '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);
|
git(dir, 'init', '-q', '-b', 'refactor', repo);
|
||||||
// The commit leaves thousands of loose objects; a detached gc --auto
|
// The commit leaves thousands of loose objects; a detached gc --auto
|
||||||
// would still be deleting them when the cleanup's chmod walks .git.
|
// would still be deleting them when the cleanup's chmod walks .git.
|
||||||
git(repo, 'config', 'gc.auto', '0');
|
git(repo, 'config', 'gc.auto', '0');
|
||||||
git(repo, 'add', '-A');
|
git(repo, 'add', '-A');
|
||||||
git(repo, 'commit', '-q', '-m', 'fixture');
|
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') };
|
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.equal(r.status, 0, r.stderr);
|
||||||
assert.match(r.stdout, new RegExp(`^copied: ${target}$`, 'm'));
|
assert.match(r.stdout, new RegExp(`^copied: ${target}$`, 'm'));
|
||||||
assert.deepEqual(readdirSync(copies), [head], 'no temp directory left behind');
|
assert.deepEqual(readdirSync(copies), [head], 'no temp directory left behind');
|
||||||
assert.deepEqual(readdirSync(target).sort(), ['RELEASE', 'packages', 'roles', 'scripts']);
|
assert.deepEqual(readdirSync(target).sort(), ['RELEASE', 'adapters', 'package-lock.json', 'package.json', 'packages', 'roles', 'scripts']);
|
||||||
assert.deepEqual(readdirSync(join(target, 'scripts')), ['mosaic-config.mjs']);
|
assert.deepEqual(readdirSync(join(target, 'scripts')), ['mosaic', 'mosaic-config.mjs']);
|
||||||
assert.ok(existsSync(join(target, 'packages', 'webui', 'README.md')), "the unit's Documentation=");
|
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.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', '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');
|
assert.equal(readFileSync(join(target, 'RELEASE'), 'utf8'), '0.0.0-fixture\n');
|
||||||
const files = filesUnder(target);
|
const files = filesUnder(target);
|
||||||
assert.ok(files.length > 50, String(files.length));
|
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.
|
// The default directory is under XDG_DATA_HOME, else ~/.local/share.
|
||||||
const byDefault = await run(dir, script, ['copy', 'refactor']);
|
const byDefault = await run(dir, script, ['copy', 'refactor']);
|
||||||
assert.equal(byDefault.status, 0, byDefault.stderr);
|
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');
|
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');
|
git(repo, 'commit', '-q', '-m', 'side');
|
||||||
const side = git(repo, 'rev-parse', 'HEAD');
|
const side = git(repo, 'rev-parse', 'HEAD');
|
||||||
git(repo, 'checkout', '-q', 'refactor');
|
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]);
|
const r = await run(dir, script, ['copy', commit, '--copies', copies]);
|
||||||
assert.equal(r.status, 1, commit);
|
assert.equal(r.status, 1, commit);
|
||||||
assert.match(r.stderr, why, 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');
|
git(repo, 'worktree', 'add', '-q', '--detach', tree, 'refactor');
|
||||||
const fromTree = await run(dir, join(tree, 'scripts', 'webui-service.sh'), ['copy', 'refactor', '--copies', copies]);
|
const fromTree = await run(dir, join(tree, 'scripts', 'webui-service.sh'), ['copy', 'refactor', '--copies', copies]);
|
||||||
assert.equal(fromTree.status, 1);
|
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));
|
assert.ok(!existsSync(copies));
|
||||||
const allowed = await run(dir, join(tree, 'scripts', 'webui-service.sh'), ['copy', 'refactor', '--copies', copies, '--allow-worktree']);
|
const allowed = await run(dir, join(tree, 'scripts', 'webui-service.sh'), ['copy', 'refactor', '--copies', copies, '--allow-worktree']);
|
||||||
assert.equal(allowed.status, 0, allowed.stderr);
|
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');
|
const copies = join(dir, 'copies');
|
||||||
git(repo, 'rm', '-q', 'scripts/mosaic-config.mjs');
|
git(repo, 'rm', '-q', 'scripts/mosaic-config.mjs');
|
||||||
git(repo, 'commit', '-q', '-m', 'drop the config check');
|
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]);
|
const r = await run(dir, script, ['copy', 'refactor', '--copies', copies]);
|
||||||
assert.equal(r.status, 1);
|
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');
|
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) => {
|
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');
|
const r = await copyWithMv(t, 'exit 1');
|
||||||
assert.equal(r.status, 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.equal(r.stdout, '');
|
||||||
assert.deepEqual(readdirSync(r.copies), [], 'no target and no temp directory');
|
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) => {
|
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 "$@"');
|
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.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.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');
|
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);
|
assert.equal((await plain.get('/healthz')).status, 200);
|
||||||
const none = await plain.get('/api/queue');
|
const none = await plain.get('/api/queue');
|
||||||
assert.equal(none.status, 503);
|
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) => {
|
test('serve refuses a --checkout that is not an absolute directory path', async (t) => {
|
||||||
|
|||||||
@@ -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.
|
// A copy as `copy` leaves it, reduced to the file install looks for.
|
||||||
const COPY_ID = '0123456789abcdef0123456789abcdef01234567';
|
const COPY_ID = '0123456789abcdef0123456789abcdef01234567';
|
||||||
const INSTALL = ['--copy', COPY_ID, '--allow-worktree'];
|
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) {
|
function fakeCopy(dir) {
|
||||||
mkdirSync(join(copyRoot(dir), 'packages', 'webui', 'src'), { recursive: true });
|
mkdirSync(join(copyRoot(dir), 'packages', 'webui', 'src'), { recursive: true });
|
||||||
writeFileSync(join(copyRoot(dir), 'packages', 'webui', 'src', 'cli.mjs'), '');
|
writeFileSync(join(copyRoot(dir), 'packages', 'webui', 'src', 'cli.mjs'), '');
|
||||||
@@ -61,9 +61,9 @@ function tmp(t) {
|
|||||||
return dir;
|
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`.
|
// 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';
|
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
|
// 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 (bus !== null) writeFileSync(join(dir, 'bus-show.txt'), bus);
|
||||||
if (busOther !== null) writeFileSync(join(dir, 'bus-show-other.txt'), busOther);
|
if (busOther !== null) writeFileSync(join(dir, 'bus-show-other.txt'), busOther);
|
||||||
const fail = 'echo "Failed to connect to bus" >&2; exit 1';
|
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
|
writeFileSync(join(bin, 'systemctl'), `#!/bin/sh
|
||||||
echo "$*" >> "${dir}/calls.txt"
|
echo "$*" >> "${dir}/calls.txt"
|
||||||
case "$*" in
|
case "$*" in
|
||||||
@@ -106,7 +106,7 @@ function quiet(dir) {
|
|||||||
return join(home, 'bin');
|
return join(home, 'bin');
|
||||||
}
|
}
|
||||||
// Every call but the reads: the Console's state and install's bus check.
|
// 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));
|
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.
|
// 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);
|
const r = await run(dir, ['install', ...INSTALL, '--dir', join(dir, 'units')], bin);
|
||||||
assert.equal(r.status, 0, r.stderr);
|
assert.equal(r.status, 0, r.stderr);
|
||||||
assert.match(r.stdout, /^daemon-reload done$/m);
|
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) => {
|
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.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.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', `${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', '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],
|
[['--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.
|
// The same with --business, which names the one unit to read.
|
||||||
s = sys({ bus: busShow(REPO) });
|
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`);
|
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.
|
// A path that only starts with the copy's name is not inside it.
|
||||||
s = sys({ bus: busShow(`${copy}-old`) });
|
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`);
|
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.
|
// 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` });
|
s = sys({ busUnits: `${BUS_UNITS}[email protected] loaded active running Mosaic bus host (other)\n` });
|
||||||
assert.equal((await install(s.bin)).status, 0);
|
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 });
|
rmSync(unitDir, { recursive: true });
|
||||||
// The second loaded unit is off the copy: refused, naming the second unit and its path.
|
// 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`;
|
const two = `${BUS_UNITS}[email protected] loaded active running Mosaic bus host (other)\n`;
|
||||||
s = sys({ busUnits: two, bus: busShow(copy), busOther: busShow(REPO) });
|
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`);
|
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.
|
// No bus unit installed for the business: refused.
|
||||||
s = sys({ bus: 'LoadState=not-found\nExecStart=\n' });
|
s = sys({ bus: 'LoadState=not-found\nExecStart=\n' });
|
||||||
|
|||||||
+74
-8
@@ -24,13 +24,16 @@ set -euo pipefail
|
|||||||
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
TEMPLATE="$REPO/packages/cli/systemd/mosaic-bus.service.in"
|
TEMPLATE="$REPO/packages/cli/systemd/mosaic-bus.service.in"
|
||||||
UNIT_NAME="[email protected]"
|
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; }
|
die() { echo "bus-service: $*" >&2; exit 1; }
|
||||||
usage() { echo "$USAGE" >&2; exit 4; }
|
usage() { echo "$USAGE" >&2; exit 4; }
|
||||||
|
|
||||||
CLAUDE_BIN=""
|
CLAUDE_BIN=""
|
||||||
CLAUDE_SET=0
|
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
|
# 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
|
# pin. systemd expands % and splits Environment= on spaces, so the path is
|
||||||
@@ -55,6 +58,16 @@ claude_line() {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
[ -f "$TEMPLATE" ] || die "template missing: $TEMPLATE"
|
[ -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
|
local node_dir
|
||||||
node_dir="$(dirname "$(command -v node || true)")"
|
node_dir="$(dirname "$(command -v node || true)")"
|
||||||
[ -n "$node_dir" ] && [ "$node_dir" != "." ] || die "node not found on PATH"
|
[ -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
|
case "$REPO" in *@*|*'|'*) die "repository path contains a character the template cannot carry: $REPO" ;; esac
|
||||||
local claude
|
local claude
|
||||||
claude="$(claude_line)" || exit 1
|
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"
|
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
||||||
@@ -73,14 +86,52 @@ parse_flags() {
|
|||||||
case "$1" in
|
case "$1" in
|
||||||
--dir) [ $# -ge 2 ] || usage; UNIT_DIR="$2"; shift 2 ;;
|
--dir) [ $# -ge 2 ] || usage; UNIT_DIR="$2"; shift 2 ;;
|
||||||
--no-reload) RELOAD=0; shift ;;
|
--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 ;;
|
--claude) [ $# -ge 2 ] || usage; CLAUDE_BIN="$2"; CLAUDE_SET=1; shift 2 ;;
|
||||||
*) usage ;;
|
*) usage ;;
|
||||||
esac
|
esac
|
||||||
done
|
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() {
|
install_unit() {
|
||||||
parse_flags "$@"
|
parse_flags "$@"
|
||||||
|
install_guard
|
||||||
mkdir -p "$UNIT_DIR"
|
mkdir -p "$UNIT_DIR"
|
||||||
local target="$UNIT_DIR/$UNIT_NAME" tmp unit
|
local target="$UNIT_DIR/$UNIT_NAME" tmp unit
|
||||||
unit="$(render)" || exit 1
|
unit="$(render)" || exit 1
|
||||||
@@ -120,13 +171,12 @@ MSG
|
|||||||
|
|
||||||
uninstall_unit() {
|
uninstall_unit() {
|
||||||
parse_flags "$@"
|
parse_flags "$@"
|
||||||
[ "$CLAUDE_SET" = 0 ] || usage
|
[ "$CLAUDE_SET" = 0 ] && [ -z "$COPY_ID" ] && [ "$PM" = 0 ] || usage
|
||||||
local target="$UNIT_DIR/$UNIT_NAME"
|
local target="$UNIT_DIR/$UNIT_NAME"
|
||||||
if [ "$RELOAD" = 1 ]; then
|
local out active
|
||||||
local 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="$(systemctl --user list-units --no-legend --plain 'mosaic-bus@*' 2>/dev/null | awk '$3 == "active" || $3 == "activating" || $3 == "deactivating" {print $1}')"
|
active="$(awk '$3 != "inactive" && $3 != "failed" {print $1}' <<< "$out")"
|
||||||
[ -z "$active" ] || die "refusing: instance(s) still running: $(echo "$active" | tr '\n' ' ')stop them first"
|
[ -z "$active" ] || die "refusing: instance(s) still running: $active; Jason or Mos stops them first"
|
||||||
fi
|
|
||||||
if [ -f "$target" ]; then
|
if [ -f "$target" ]; then
|
||||||
rm -f "$target"
|
rm -f "$target"
|
||||||
echo "removed: $target"
|
echo "removed: $target"
|
||||||
@@ -148,6 +198,19 @@ status_unit() {
|
|||||||
echo " enabled: $(systemctl --user is-enabled "$unit" 2>&1 || true)"
|
echo " enabled: $(systemctl --user is-enabled "$unit" 2>&1 || true)"
|
||||||
echo " active: $(systemctl --user is-active "$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/^/ /'
|
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
|
"$REPO/scripts/mosaic" bus status | sed 's/^/ /' || true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +220,9 @@ case "$cmd" in
|
|||||||
render)
|
render)
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
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 ;;
|
--claude) [ $# -ge 2 ] || usage; CLAUDE_BIN="$2"; CLAUDE_SET=1; shift 2 ;;
|
||||||
*) usage ;;
|
*) usage ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
Executable
+103
@@ -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
@@ -10,7 +10,7 @@
|
|||||||
# write `git archive` of the paths the Console
|
# write `git archive` of the paths the Console
|
||||||
# loads at COMMIT into DIR/<full id>/, read-only;
|
# loads at COMMIT into DIR/<full id>/, read-only;
|
||||||
# DIR defaults to
|
# DIR defaults to
|
||||||
# ${XDG_DATA_HOME:-~/.local/share}/mosaic-console
|
# ${XDG_DATA_HOME:-~/.local/share}/mosaic-stack/copies
|
||||||
# render [--copy ID [--copies DIR]] [FLAGS]
|
# render [--copy ID [--copies DIR]] [FLAGS]
|
||||||
# print the rendered unit on stdout; without
|
# print the rendered unit on stdout; without
|
||||||
# --copy it runs from this checkout
|
# --copy it runs from this checkout
|
||||||
@@ -37,8 +37,7 @@
|
|||||||
# --copy ID` with the same flags; Jason or Mos starts it. Rollback is the
|
# --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
|
# 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),
|
# 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
|
# row 85 supplies the shared copy and bus unit flags; Mos owns switching.
|
||||||
# later row does.
|
|
||||||
#
|
#
|
||||||
# FLAGS are serve's: --port N (default 7332; 7330 is the operator's own
|
# 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
|
# 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; }
|
usage() { echo "$USAGE" >&2; exit 4; }
|
||||||
|
|
||||||
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
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_ID=""
|
||||||
COPY_FLAGS=0
|
COPY_FLAGS=0
|
||||||
ALLOW_WORKTREE=0
|
ALLOW_WORKTREE=0
|
||||||
@@ -152,33 +151,8 @@ serve_flags() {
|
|||||||
echo "$flags"
|
echo "$flags"
|
||||||
}
|
}
|
||||||
|
|
||||||
# The paths the Console loads, the only ones a copy holds: serve's import
|
# Row 85: stack-copy.sh owns the pushed runtime closure and npm install.
|
||||||
# closure (packages/webui/src/cli.mjs and the human transport child,
|
# This script delegates copy and shares its copies directory.
|
||||||
# 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
|
|
||||||
)
|
|
||||||
|
|
||||||
# N2 (row 75 review): copy and install read and point at this checkout, so
|
# 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.
|
# 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
|
# has loaded, and none loaded refuses. Like the state read, a failed call
|
||||||
# refuses with one line.
|
# refuses with one line.
|
||||||
bus_on_copy() {
|
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
|
if [ -n "$BUSINESS" ]; then
|
||||||
units="mosaic-bus@$BUSINESS.service"
|
units="mosaic-bus@$BUSINESS.service"
|
||||||
else
|
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"
|
[ -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
|
fi
|
||||||
for unit in $units; do
|
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="$(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"
|
[ "$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)"
|
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
|
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
|
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
|
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
|
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
|
# 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.
|
# final path is always whole and read-only. An existing copy is left alone.
|
||||||
copy_commit() {
|
copy_commit() {
|
||||||
[ $# -ge 1 ] || usage
|
exec "$REPO/scripts/stack-copy.sh" copy "$@"
|
||||||
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"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[ $# -ge 1 ] || usage
|
[ $# -ge 1 ] || usage
|
||||||
|
|||||||
Reference in New Issue
Block a user