stack: several Mosaic Stack instances on one host (row 190, #1688)

Squash of Darkwing's 176a6bf4b..df65a3d4c on origin/darkwing/row190-r3,
the 25 manifest paths, build-r3.patch efe898c2, manifest cc0ea9e8
(Filbert approve round 3, comment 29713). Design revision 3 in
agents/darkwing/work/queue-190/DESIGN.md. Gate on base d0bbc5c1c with
the patch: 15 package suites 2664 pass 0 fail, 10 scripts/test-*.sh
suites 429 pass 0 fail (plus 156 node tests in the queue suite).
Refactor moved to 99a406ed3 since, a window record touching no manifest
path. Mutants 20 of 20 killed (BUILD.md, round 3). No live change: the
stack runs from copy 4429a104f until a re-pin.
This commit is contained in:
2026-10-20 17:49:06 -05:00
parent 99a406ed3c
commit 00d4cc1f69
25 changed files with 1561 additions and 216 deletions
+11 -6
View File
@@ -25,10 +25,15 @@ green at every step. Not production software — a proven foundation.
1. **Root is bootstrap-only.** First-class system configuration lives at the
repository root; everything else gets a dedicated directory (`roles/`,
`contracts/`, `missions/`, `tasks/`, `docs/`). Do not add new files to root.
2. **Configuration**: `~/.config/mosaic-dev/config.json` is the sole system
config — created only by `scripts/bootstrap.sh`, never overwritten,
fail-closed on any problem. Repo-scoped role authority lives in
`roles/*.json` (versioned, reviewed commits only).
2. **Configuration**: each stack instance has exactly one system config. The
default instance's is `~/.config/mosaic-dev/config.json`. Instance NAME's
is `~/.config/mosaic-NAME/config.json`, and its data root carries a marker
that names it. `~` is the home of the OS user that runs the instance. A
process reads only the config `MOSAIC_CONFIG` names, or the default's when
that is unset. Configs are created only by
`scripts/bootstrap.sh [--instance NAME]`, never overwritten, fail-closed on
any problem. Repo-scoped role authority lives in `roles/*.json`
(versioned, reviewed commits only).
3. **Secrets** never enter the repository or container images; auth is
runtime-only (read-only mount or environment variable).
4. **Contracts** (`contracts/`) are immutable and image-baked. Missions and
@@ -177,8 +182,8 @@ Full reference — usage, fields, exit codes, safety notes:
## Data map (canon)
- `~/.config/mosaic-dev/config.json` — system config (user-authored; never
auto-written).
- `~/.config/mosaic-dev/config.json`, or `~/.config/mosaic-NAME/config.json`
per named instance: system config (user-authored; never auto-written).
- `<dataRoot>` (from config; default `~/.mosaic-dev`):
- `runs/` — write-once run evidence (`result.json`, snapshots, `stderr.txt`)
- `sessions/` — pi JSONL session trees, one directory per named session
+89 -10
View File
@@ -15,7 +15,7 @@ and changes nothing.
| Command | Purpose | Notes |
|---|---|---|
| `scripts/bootstrap.sh` | Create `~/.config/mosaic-dev/config.json` if absent | Idempotent; existing config validated, never rewritten |
| `scripts/bootstrap.sh [--instance NAME]` | Create `~/.config/mosaic-dev/config.json` if absent; with `--instance NAME`, `~/.config/mosaic-NAME/config.json` and the claimed root `~/.mosaic-NAME` | Idempotent; existing config validated, never rewritten. See [Stack instances](#stack-instances---instance-name-row-190) |
| `scripts/build.sh` | Build the release image | Tag derived from `RELEASE` + pinned pi version |
| `scripts/hello.sh` | One-shot startup request | Prints model response on stdout |
| `scripts/verify.sh` | Full gated test | Exit 0 only on exact `MOSAIC_HELLO_OK`; `EXPECTED_MARKER` overrides for negative drills |
@@ -270,9 +270,9 @@ scripts/mosaic follow --thread <thread> [--tester]
scripts/mosaic stop <run>
scripts/mosaic launches off | on [--business <id>]
scripts/mosaic launches list [--json]
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>
scripts/bus-service.sh [--instance NAME] render [--copy ID [--copies DIR]] [--pm] [--claude PATH]
scripts/bus-service.sh [--instance NAME] install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH]
scripts/bus-service.sh [--instance NAME] uninstall | status <business>
```
The human commands read and resolve through the broker's human transport
@@ -321,12 +321,89 @@ stdin and stdout it exits 4 with the usage.
Exit codes: `0` ok · `1` failed or outcome unknown · `2` invalid input ·
`3` refused or config problem · `4` usage. Details: `packages/cli/README.md`.
## Stack instances (`--instance NAME`, row 190)
Several stacks can run on one host, each with its own config, data root,
units, Console port and CLI wrapper. The default instance is today's stack
and keeps every name it had. A named instance is created once:
```
scripts/bootstrap.sh --instance NAME
```
NAME matches `^[a-z][a-z0-9]{1,15}$`; anything else exits 4
(`instance-name-invalid`), and `--instance dev` is the default. Bootstrap
writes `~/.config/mosaic-NAME/config.json` (0644) and claims `~/.mosaic-NAME`
with the marker `instance.json`, `{"instanceVersion":1,"name":"NAME"}`. It
creates either directory 0700; one that already exists keeps its mode. It refuses with exit 3 and writes
nothing when the root exists, is not empty and holds no marker naming NAME.
A second run changes nothing. `~` is the home of the OS user that runs the
instance.
`--instance NAME` is taken, once and in any position, by `bootstrap.sh`,
`bus-service.sh`, `webui-service.sh` (all but `copy`), `discord-service.sh`,
`stack-copy.sh` (`wrapper` and `check`) and `stack-release.sh`. Copies serve
every instance, so both `copy` verbs refuse it with exit 4. Inside a process the config
travels as `MOSAIC_CONFIG`. Selection, checked before any read or write:
| Given | Config | Names |
|---|---|---|
| neither | `~/.config/mosaic-dev/config.json` | the default's |
| `MOSAIC_CONFIG` only | that file | the default's |
| `--instance NAME` only | `~/.config/mosaic-NAME/config.json` | NAME's |
| both | must be one file (a symlink to it agrees), else exit 4 `instance-config-mismatch` | NAME's |
Every selection checks the data root's marker. A named instance whose root
lacks its own marker refuses `instance-root-unclaimed`; a root whose marker
names another instance refuses `instance-root-mismatch`, also when a config
reaches it with no flag. Both exit 3 before anything is read or changed. A
missing config exits 3 with the bootstrap command that creates it.
| | Default | Instance NAME |
|---|---|---|
| bus unit | `[email protected]` | `[email protected]` |
| Console unit | `mosaic-webui.service` | `mosaic-webui-NAME.service` |
| Discord unit | `[email protected]` | `[email protected]` |
| CLI wrapper | `$BIN/mosaic` (Q81 marker) | `$BIN/mosaic-NAME`, marker `# mosaic-cli wrapper, instance NAME (row 190)`, exports `MOSAIC_CONFIG` |
| bus session env | `%h/.config/mosaic-dev/secrets/claude-session.env` | `~/.config/mosaic-NAME/secrets/claude-session.env` |
| Console port | `--port`, 7332 when absent | `--port` required; 7330, 7332 and 7333 exit 4 `port-reserved` |
A named instance's units add `Environment=MOSAIC_CONFIG=<its config>` and
` [NAME]` after the Description, and the Console unit's comment on the
variables it sets names `MOSAIC_CONFIG`. The default's renders are
byte-identical to the templates' renders before row 190. `stack-copy.sh
wrapper --instance NAME` creates `$BIN/mosaic-NAME` or replaces one that
carries NAME's marker, and refuses any other file. `check --instance NAME`
needs `--business ID`, and fails with `CLI: <copy> (MOSAIC_CONFIG mismatch)`
when the wrapper does not export NAME's config.
For every instance, a Console `--port` or `--tester-port` that another
installed Console unit in the unit directory already takes refuses
`port-claimed`, naming that unit: `webui-service.sh install` exits 1, and
`stack-release.sh activate` and `rollback` exit 3 before anything stops
(`--plan` lists it). A unit with no `--port` holds 7332.
`stack-release.sh --instance NAME` reads only NAME's pointer, log, store and
backups. Its busy check sees only NAME's bus units. Its drop-in scan adds
`mosaic-bus-NAME@*.service.d` and the shared `mosaic-bus-.service.d`. Its
rollback set holds NAME's two unit files, their own `.d` directories and
`$BIN/mosaic-NAME`; it never captures or restores the shared prefix
directories (`mosaic-bus-.service.d`, `mosaic-webui-.service.d`,
`mosaic-discord-.service.d`). The live-session guard refuses a path inside
any named instance's config directory or `~/.mosaic-NAME`, even when that
instance's config is unreadable.
All instances run under one OS user for now. That user's processes can
read and control every instance; a separate user per instance is row 195.
A real second instance on this host is a Q173 window, and tunnel or Traefik
exposure of a named Console waits for Jason after Q175 SSO.
## 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]
scripts/stack-copy.sh wrapper ID [--instance NAME] [--copies DIR] [--bin DIR]
scripts/stack-copy.sh check [--instance NAME] [--copies DIR] [--bin DIR] [--business ID]
```
One read-only tree supplies the bus, Console and human CLI (row 85,
@@ -372,7 +449,7 @@ scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--mig
scripts/stack-release.sh pm-on BUSINESS [--timeout S]
scripts/stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
scripts/stack-release.sh status BUSINESS
common options: --copies DIR, --bin DIR
common options: --copies DIR, --bin DIR, --instance NAME
--events N is the total of the per-table counts the post-backup-events refusal prints: events and
every table in COUNTED.
```
@@ -602,6 +679,7 @@ its runs alone. No unit passes it, and `--pm` with it is a usage error.
```bash
scripts/webui-service.sh copy COMMIT [--copies DIR] # make a pinned copy
# render, install, uninstall and status also take --instance NAME (row 190); copy refuses it (exit 4)
scripts/webui-service.sh render [--copy ID [--copies DIR]] [FLAGS] # print the unit
scripts/webui-service.sh install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [FLAGS]
scripts/webui-service.sh uninstall [--dir DIR] [--no-reload]
@@ -679,8 +757,9 @@ Each value is held to a plain character set the unit can carry (no space,
`%`, `$`, quote, backslash, `&` or `|`), and the preview values must start
with a letter or digit; anything else is refused. With `--auth-header
X-authentik-uid`, in any letter case, `--allow-user` must be the Authentik
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
uid, `^[0-9a-f]{64}$` (lead decision 101). The default's unit sets no `MOSAIC_*`,
`CLAUDE*`, `CODEX*` or `PI_*` variable; a named instance's sets only
`MOSAIC_CONFIG` (row 190). Serve's exit 2 (usage, config, or a
port already in use) is not restarted.
The update/rollback sequence is [Pinned stack update](#pinned-stack-update)
@@ -705,7 +784,7 @@ scripts/discord.sh stop <binding>
scripts/discord.sh unlock <binding>
scripts/discord.sh recover <binding>
scripts/discord.sh reload <binding>
scripts/discord-service.sh render | install [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <binding>
scripts/discord-service.sh [--instance NAME] render | install [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <binding>
```
One seat's conversation reachable from listed channels of one Discord
+13
View File
@@ -717,6 +717,19 @@ without it, so Pi sessions still launch and a Claude Code launch refuses
(or this line and this paragraph), then restart the unit; a running host
keeps the environment it started with.
A named stack instance (row 190) takes `--instance NAME` with any verb, in
any position. The unit is then `[email protected]`, it sets
`Environment=MOSAIC_CONFIG=` to `~/.config/mosaic-NAME/config.json`, its
Description ends in ` [NAME]`, and its `EnvironmentFile=` is
`secrets/claude-session.env` in that config directory, not mosaic-dev's.
Install's busy and drop-in checks cover only `mosaic-bus-NAME@*` units and
their `.d` directories, plus the prefix directories systemd applies to them:
`mosaic-bus-.service.d`, `mosaic-.service.d` and `service.d`. A data root
that another instance claims, or a named root with no `instance.json`, exits
3 before anything is read. Without the flag the unit renders byte for byte
as before. Selection, names and refusals are in
[TOOLS.md, Stack instances](../../docs/TOOLS.md#stack-instances---instance-name-row-190).
The unit has no `network-online.target` ordering, since a user unit cannot
order on that system target. The notifier needs no network at start: a
send that fails is journaled and retried.
+2 -1
View File
@@ -13,6 +13,7 @@ StartLimitBurst=5
Type=simple
WorkingDirectory=@REPO@
Environment=PATH=@PATH@
@CONFIG_ENV@
# The Claude Code binary that managed sessions and the manifest's
# claudeVersion run, pinned at install (`--claude PATH`, default
# `command -v claude`). Without it they run `claude` from PATH above.
@@ -21,7 +22,7 @@ Environment=PATH=@PATH@
# operator writes: CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY, or a proxy's
# ANTHROPIC_AUTH_TOKEN with ANTHROPIC_BASE_URL. The `-` lets the unit start
# without it, and a Claude Code launch then refuses.
EnvironmentFile=-%h/.config/mosaic-dev/secrets/claude-session.env
EnvironmentFile=-@SESSION_ENV@
# The host reads the system config and the business file, boots the broker
# (packages/bus/src/process.mjs) and starts the notifier. Config problems and
# refusals exit 3, usage 4, invalid input 2: none of those is retried. A
+9 -4
View File
@@ -837,7 +837,11 @@ test("bus start --no-launch says launches are disabled in its host-up line and s
test("bus-service.sh renders the unit and installs it into a given directory", (t) => {
const dir = tmp(t);
const script = join(REPO, "scripts", "bus-service.sh");
const render = spawnSync(script, ["render"], { encoding: "utf8" });
// Row 190: the script selects an instance from the system config first; a
// scratch path keeps it off ~/.config/mosaic-dev. A missing config selects
// the default names, as before.
const cfgEnv = { ...process.env, MOSAIC_CONFIG: join(dir, "absent", "config.json") };
const render = spawnSync(script, ["render"], { encoding: "utf8", env: cfgEnv });
assert.equal(render.status, 0, render.stderr);
assert.match(render.stdout, new RegExp(`ExecStart=${REPO.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/scripts/mosaic bus start %i`));
assert.match(render.stdout, /RestartPreventExitStatus=2 3 4/);
@@ -855,7 +859,7 @@ test("bus-service.sh renders the unit and installs it into a given directory", (
const fakeBin=join(dir,"bin");mkdirSync(fakeBin);
writeFileSync(join(fakeBin,"systemctl"),"#!/bin/sh\ncase \"$*\" in *list-units*) exit 0;; *)exit 99;; esac\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 unitEnv={...cfgEnv,PATH:`${fakeBin}:${process.env.PATH}`};
const first = spawnSync(script, ["install", "--dir", dir, "--no-reload"], { encoding: "utf8",env:unitEnv });
assert.equal(first.status, 0, first.stderr);
assert.match(first.stdout, /written: /);
@@ -863,7 +867,7 @@ test("bus-service.sh renders the unit and installs it into a given directory", (
assert.equal(readFileSync(join(dir, "[email protected]"), "utf8"), render.stdout);
assert.match(spawnSync(script, ["install", "--dir", dir, "--no-reload"], { encoding: "utf8",env:unitEnv }).stdout, /unchanged: /);
assert.match(spawnSync(script, ["uninstall", "--dir", dir, "--no-reload"], { encoding: "utf8",env:unitEnv }).stdout, /removed: /);
assert.equal(spawnSync(script, ["bogus"], { encoding: "utf8" }).status, 4);
assert.equal(spawnSync(script, ["bogus"], { encoding: "utf8", env: cfgEnv }).status, 4);
});
// Row 68: the unit pins the Claude Code binary as MOSAIC_CLAUDE_BIN, from
@@ -882,7 +886,8 @@ test("bus-service.sh pins the claude binary, refuses one the unit can't carry, a
const onPath = stand(join(dir, "bin", "claude"));
const named = stand(join(dir, "claude-named"));
symlinkSync(named, join(dir, "claude-link"));
const env = { ...process.env, PATH: `${join(dir, "bin")}:${dirname(process.execPath)}:/usr/bin:/bin` };
// Row 190: a scratch MOSAIC_CONFIG keeps the instance selection off ~/.config/mosaic-dev.
const env = { ...process.env, MOSAIC_CONFIG: join(dir, "absent", "config.json"), PATH: `${join(dir, "bin")}:${dirname(process.execPath)}:/usr/bin:/bin` };
const run = (args, e = env) => spawnSync(script, args, { encoding: "utf8", env: e });
const pinned = (out) => out.match(/^Environment=MOSAIC_CLAUDE_BIN=(.*)$/m)?.[1];
+17
View File
@@ -66,6 +66,23 @@ test('wrapper preserves Q81 lease and marker, rejects foreign/symlink; check use
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('row 190 N6: check --instance passes only when the wrapper exports the instance\'s MOSAIC_CONFIG',t=>{
const s=setup(t);assert.equal(s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]).status,0);
const boot=s.run('bootstrap.sh',['--instance','alpha']);assert.equal(boot.status,0,boot.stderr);
const copy=join(s.copies,s.id),wrapper=join(s.bin,'mosaic-alpha'),cfg=join(s.root,'.config','mosaic-alpha','config.json');
const w=s.run('stack-copy.sh',['wrapper',s.id,'--instance','alpha','--copies',s.copies,'--bin',s.bin]);assert.equal(w.status,0,w.stderr);
s.fake(`echo 'LoadState=loaded';echo 'WorkingDirectory=${copy}';case "$*" in *mosaic-bus-alpha@demo*) echo 'ExecStart={ path=${copy}/scripts/mosaic ; argv[]=${copy}/scripts/mosaic bus start demo ; }';; *mosaic-webui-alpha*) echo 'ExecStart={ path=/usr/bin/node ; argv[]=/usr/bin/node ${copy}/packages/webui/src/cli.mjs serve ; }';; *) exit 1;; esac\n`);
const check=()=>s.run('stack-copy.sh',['check','--instance','alpha','--copies',s.copies,'--bin',s.bin,'--business','demo']);
const good=readFileSync(wrapper,'utf8');assert.equal(good.split('\n')[3],`export MOSAIC_CONFIG='${cfg}'`);
let r=check();assert.equal(r.status,0,r.stdout+r.stderr);assert.equal(r.stdout,`bus: ${copy}\nConsole: ${copy}\nCLI: ${copy}\n`);
for(const [label,text] of [
['another config',good.replace(`export MOSAIC_CONFIG='${cfg}'`,`export MOSAIC_CONFIG='${join(s.root,'.config','mosaic-beta','config.json')}'`)],
['no config line',good.replace(`export MOSAIC_CONFIG='${cfg}'\n`,'')],
]){
writeFileSync(wrapper,text);r=check();
assert.equal(r.status,1,label);assert.equal(r.stdout,`bus: ${copy}\nConsole: ${copy}\nCLI: ${copy} (MOSAIC_CONFIG mismatch)\n`,label);
}
});
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'];
+469
View File
@@ -0,0 +1,469 @@
// Row 190 (#1688): several stack instances on one host. The config and bootstrap cases (T-I1, T-I2,
// the config part of T-I7), two real bus hosts side by side (T-I5) and the guard (T-I10). Design and
// T table: agents/darkwing/work/queue-190/DESIGN.md D7. The unit, port and release cases are T56 in
// stack-release.test.mjs. HOME is a scratch directory in every test, so ~/.config/mosaic-* and
// ~/.mosaic-* resolve inside it; nothing here reads or writes ~/.mosaic-dev or ~/.config/mosaic-dev.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { once } from "node:events";
import { chmodSync, cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { LiveSessionGuard } from "../../conversation/src/guard.mjs";
import { businessDoc, rolesCopy, writeJson } from "../../business/tests/helpers.mjs";
import { REPO_SKILLS } from "../../harness/tests/helpers.mjs";
import { humanEnv, notifyConfig, tmp } from "./helpers.mjs";
const SCRIPTS = fileURLToPath(new URL("../../../scripts/", import.meta.url));
const CONFIG = join(SCRIPTS, "mosaic-config.mjs");
const BOOTSTRAP = join(SCRIPTS, "bootstrap.sh");
const BUS_SERVICE = join(SCRIPTS, "bus-service.sh");
const WEBUI_SERVICE = join(SCRIPTS, "webui-service.sh");
const STACK_COPY = join(SCRIPTS, "stack-copy.sh");
const MOSAIC = join(SCRIPTS, "mosaic");
// The environment for a scratch home: no MOSAIC_CONFIG unless a test sets one.
function homeEnv(home, extra = {}) {
const env = humanEnv({ HOME: home, ...extra });
if (!("MOSAIC_CONFIG" in extra)) delete env.MOSAIC_CONFIG;
delete env.XDG_CONFIG_HOME;
return env;
}
function run(cmd, args, env) {
const r = spawnSync(cmd, args, { env, encoding: "utf8", timeout: 30000 });
return { code: r.status, out: r.stdout, err: r.stderr };
}
const bootstrap = (home, ...args) => run(BOOTSTRAP, args, homeEnv(home));
const config = (home, extra, ...args) => run(process.execPath, [CONFIG, ...args], homeEnv(home, extra));
const cfgOf = (home, name) => join(home, ".config", `mosaic-${name}`, "config.json");
// The instance op's exports as an object.
function exportsOf(out) {
return Object.fromEntries(out.trim().split("\n").map((line) => {
const m = line.match(/^(\w+)='(.*)'$/);
assert.ok(m, `not a shell-safe export: ${line}`);
return [m[1], m[2]];
}));
}
// Every entry under `dir`: relative path, type, mode, size, mtime and content hash.
function snapshot(dir) {
const out = {};
const walk = (d) => {
for (const name of readdirSync(d).sort()) {
const p = join(d, name);
const s = lstatSync(p);
const key = relative(dir, p);
if (s.isDirectory()) {
out[key] = { dir: true, mode: s.mode, mtime: s.mtimeMs };
walk(p);
} else {
out[key] = { mode: s.mode, size: s.size, mtime: s.mtimeMs, sha: createHash("sha256").update(readFileSync(p)).digest("hex") };
}
}
};
if (existsSync(dir)) walk(dir);
return out;
}
const mode = (p) => statSync(p).mode & 0o777;
test("T-I1 bootstrap --instance creates each instance's config, root and marker once, and refuses what it must not claim", async (t) => {
await t.test("alpha and beta get a 0700 config dir, a config naming ~/.mosaic-NAME, a 0700 root and a 0644 marker", (t) => {
const home = tmp(t, "si-");
for (const name of ["alpha", "beta"]) {
const r = bootstrap(home, "--instance", name);
assert.equal(r.code, 0, r.err);
const cfg = cfgOf(home, name), root = join(home, `.mosaic-${name}`);
assert.equal(r.err, `mosaic-config: created configuration for instance ${name}: ${cfg}, data root ${root}\n`);
assert.equal(mode(join(home, ".config", `mosaic-${name}`)), 0o700);
assert.equal(mode(cfg), 0o644);
assert.equal(JSON.parse(readFileSync(cfg, "utf8")).dataRoot, root);
assert.equal(mode(root), 0o700);
const marker = join(root, "instance.json");
assert.equal(readFileSync(marker, "utf8"), `{"instanceVersion":1,"name":"${name}"}\n`);
assert.equal(mode(marker), 0o644);
assert.deepEqual(readdirSync(root), ["instance.json"]);
assert.deepEqual(readdirSync(join(home, ".config", `mosaic-${name}`)), ["config.json"]);
}
assert.equal(existsSync(join(home, ".config", "mosaic-dev")), false);
assert.equal(existsSync(join(home, ".mosaic-dev")), false);
});
await t.test("a second bootstrap of each changes no byte, mode or mtime", (t) => {
const home = tmp(t, "si-");
for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0);
const before = snapshot(home);
for (const name of ["alpha", "beta"]) {
const r = bootstrap(home, "--instance", name);
assert.equal(r.code, 0, r.err);
assert.equal(r.err, `mosaic-config: configuration already present, validated without changes: ${cfgOf(home, name)}\n`);
}
assert.deepEqual(snapshot(home), before);
});
await t.test("a non-empty root without its own marker refuses before anything is written, worktrees included", (t) => {
const home = tmp(t, "si-");
mkdirSync(join(home, ".mosaic-gamma"));
writeFileSync(join(home, ".mosaic-gamma", "notes.txt"), "someone's data\n");
mkdirSync(join(home, ".mosaic-worktrees", "row190"), { recursive: true });
mkdirSync(join(home, ".mosaic-delta"));
writeFileSync(join(home, ".mosaic-delta", "instance.json"), `{"instanceVersion":1,"name":"alpha"}\n`);
writeFileSync(join(home, ".mosaic-delta", "other"), "x");
const before = snapshot(home);
for (const name of ["gamma", "worktrees", "delta"]) {
const r = bootstrap(home, "--instance", name);
assert.equal(r.code, 3, r.err);
assert.match(r.err, new RegExp(`instance-root-unclaimed: ${join(home, `.mosaic-${name}`)} exists, is not empty and holds no instance.json naming ${name}; nothing was written`));
assert.equal(existsSync(join(home, ".config", `mosaic-${name}`)), false);
}
assert.deepEqual(snapshot(home), before);
// A root that is a file refuses the same way.
writeFileSync(join(home, ".mosaic-file"), "x");
const r = bootstrap(home, "--instance", "file");
assert.equal(r.code, 3);
assert.match(r.err, /instance-root-unclaimed: .* exists and is not a directory; nothing was written/);
assert.equal(existsSync(join(home, ".config", "mosaic-file")), false);
});
await t.test("an empty existing root is claimed, and an existing config whose root lost its marker refuses, not repairs", (t) => {
const home = tmp(t, "si-");
mkdirSync(join(home, ".mosaic-alpha"), { mode: 0o700 });
assert.equal(bootstrap(home, "--instance", "alpha").code, 0);
assert.equal(readFileSync(join(home, ".mosaic-alpha", "instance.json"), "utf8"), `{"instanceVersion":1,"name":"alpha"}\n`);
rmSync(join(home, ".mosaic-alpha", "instance.json"));
const before = snapshot(home);
const r = bootstrap(home, "--instance", "alpha");
assert.equal(r.code, 3);
assert.match(r.err, /instance-root-unclaimed: .*\.mosaic-alpha holds no instance\.json naming alpha/);
assert.deepEqual(snapshot(home), before);
});
await t.test("a dangling symlink at the config path is never written through, named or default", (t) => {
const home = tmp(t, "si-");
for (const [name, args] of [["alpha", ["--instance", "alpha"]], ["dev", []]]) {
const dir = join(home, ".config", `mosaic-${name}`), target = join(home, `elsewhere-${name}.json`);
mkdirSync(dir, { recursive: true, mode: 0o700 });
symlinkSync(target, join(dir, "config.json"));
const r = bootstrap(home, ...args);
assert.equal(r.code, 2, `${name}: ${r.err}`);
assert.match(r.err, /configuration path must be a regular, non-symbolic-link file/);
assert.equal(existsSync(target), false, `${name}: the link's target was created`);
assert.deepEqual(readdirSync(dir), ["config.json"]);
assert.ok(lstatSync(join(dir, "config.json")).isSymbolicLink());
}
assert.deepEqual(readdirSync(home).sort(), [".config"]);
});
await t.test("bad names and stray arguments exit 4 and write nothing", (t) => {
const home = tmp(t, "si-");
for (const name of ["A", "a-b", "x", "a", "abcdefghijklmnopq", "../x", "1ab", "a.b", ""]) {
const r = bootstrap(home, "--instance", name);
assert.equal(r.code, 4, `${name}: ${r.err}`);
assert.match(r.err, /instance-name-invalid/);
}
assert.equal(bootstrap(home, "--instance", "abcdefghijklmnop").code, 0, "16 characters pass");
rmSync(join(home, ".config"), { recursive: true });
rmSync(join(home, ".mosaic-abcdefghijklmnop"), { recursive: true });
for (const args of [["--instance"], ["--instance", "alpha", "extra"], ["extra"], ["--name", "alpha"]]) {
const r = bootstrap(home, ...args);
assert.equal(r.code, 4, `${args.join(" ")}: ${r.err}`);
assert.match(r.err, /usage: mosaic-config\.mjs bootstrap \[--instance NAME\]/);
}
assert.deepEqual(readdirSync(home), []);
});
await t.test("--instance dev writes exactly what no flag writes", (t) => {
const plain = tmp(t, "si-"), dev = tmp(t, "si-");
const a = bootstrap(plain), b = bootstrap(dev, "--instance", "dev");
assert.equal(a.code, 0, a.err);
assert.equal(b.code, 0, b.err);
assert.equal(b.err.replaceAll(dev, "HOME"), a.err.replaceAll(plain, "HOME"));
const shape = (home) => Object.fromEntries(Object.entries(snapshot(home)).map(([k, v]) => [k, { ...v, mtime: 0, sha: v.sha && createHash("sha256").update(readFileSync(join(home, k), "utf8").replaceAll(home, "HOME")).digest("hex") }]));
assert.deepEqual(shape(dev), shape(plain));
assert.deepEqual(Object.keys(snapshot(plain)), [".config", ".config/mosaic-dev", ".config/mosaic-dev/config.json"]);
});
});
test("T-I2 selection: the four rows of D1 through the instance op and through an entry point", async (t) => {
const home = tmp(t, "si-");
for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0);
const scratch = join(home, "scratch-config.json");
writeJson(scratch, { configVersion: 1, environment: "development", dataRoot: join(home, "scratch-data"), execution: { backend: "docker", provider: "zai", model: "glm-5.3-flash" } });
await t.test("row 1: no flag and no MOSAIC_CONFIG is the default; a missing default config leaves the data root empty", () => {
const r = config(home, {}, "instance");
assert.equal(r.code, 0, r.err);
assert.deepEqual(exportsOf(r.out), {
MOSAIC_INSTANCE: "dev",
MOSAIC_INSTANCE_CONFIG: cfgOf(home, "dev"),
MOSAIC_INSTANCE_CONFIG_DIR: join(home, ".config", "mosaic-dev"),
MOSAIC_INSTANCE_DATA_ROOT: "",
MOSAIC_UNIT_SUFFIX: "",
});
assert.deepEqual(exportsOf(config(home, {}, "instance", "--instance", "dev").out), exportsOf(r.out));
});
await t.test("row 2: MOSAIC_CONFIG alone is that config with the default names", () => {
const r = config(home, { MOSAIC_CONFIG: scratch }, "instance");
assert.equal(r.code, 0, r.err);
assert.deepEqual(exportsOf(r.out), {
MOSAIC_INSTANCE: "dev",
MOSAIC_INSTANCE_CONFIG: scratch,
MOSAIC_INSTANCE_CONFIG_DIR: home,
MOSAIC_INSTANCE_DATA_ROOT: join(home, "scratch-data"),
MOSAIC_UNIT_SUFFIX: "",
});
});
await t.test("row 3: --instance NAME alone is NAME's config, root and suffix", () => {
for (const name of ["alpha", "beta"]) {
const r = config(home, {}, "instance", "--instance", name);
assert.equal(r.code, 0, r.err);
assert.deepEqual(exportsOf(r.out), {
MOSAIC_INSTANCE: name,
MOSAIC_INSTANCE_CONFIG: cfgOf(home, name),
MOSAIC_INSTANCE_CONFIG_DIR: join(home, ".config", `mosaic-${name}`),
MOSAIC_INSTANCE_DATA_ROOT: join(home, `.mosaic-${name}`),
MOSAIC_UNIT_SUFFIX: `-${name}`,
});
}
});
await t.test("row 4: both must name one file; a symlink to it agrees, any other file exits 4", () => {
const link = join(home, "alpha-link.json");
symlinkSync(cfgOf(home, "alpha"), link);
for (const given of [cfgOf(home, "alpha"), link]) {
const r = config(home, { MOSAIC_CONFIG: given }, "instance", "--instance", "alpha");
assert.equal(r.code, 0, r.err);
assert.equal(exportsOf(r.out).MOSAIC_INSTANCE_CONFIG, cfgOf(home, "alpha"));
}
for (const given of [cfgOf(home, "beta"), scratch, join(home, "missing.json")]) {
for (const op of ["instance", "validate", "env", "bootstrap"]) {
const r = config(home, { MOSAIC_CONFIG: given }, op, "--instance", "alpha");
assert.equal(r.code, 4, `${op} ${given}: ${r.err}`);
assert.equal(r.out, "");
assert.match(r.err, new RegExp(`instance-config-mismatch: --instance alpha names ${cfgOf(home, "alpha")}, but MOSAIC_CONFIG names ${given}`));
}
}
});
await t.test("a named instance with no config exits 3 with its own bootstrap hint", () => {
const r = config(home, {}, "instance", "--instance", "gamma");
assert.equal(r.code, 3);
assert.match(r.err, /configuration not found: .*mosaic-gamma\/config\.json \(run scripts\/bootstrap\.sh --instance gamma to create it\)/);
});
await t.test("an entry point takes --instance in any position, once, and refuses a mismatch before it prints", () => {
const first = run(BUS_SERVICE, ["--instance", "alpha", "render"], homeEnv(home));
assert.equal(first.code, 0, first.err);
assert.match(first.out, new RegExp(`^Environment=MOSAIC_CONFIG=${cfgOf(home, "alpha")}$`, "m"));
assert.equal(run(BUS_SERVICE, ["render", "--instance", "alpha"], homeEnv(home)).out, first.out);
assert.equal(run(BUS_SERVICE, ["render", "--instance", "alpha"], homeEnv(home, { MOSAIC_CONFIG: cfgOf(home, "alpha") })).out, first.out);
const mismatch = run(BUS_SERVICE, ["--instance", "alpha", "render"], homeEnv(home, { MOSAIC_CONFIG: cfgOf(home, "beta") }));
assert.equal(mismatch.code, 4);
assert.equal(mismatch.out, "");
assert.match(mismatch.err, /instance-config-mismatch/);
for (const args of [["--instance", "alpha", "render", "--instance", "alpha"], ["render", "--instance"]]) {
const r = run(BUS_SERVICE, args, homeEnv(home));
assert.equal(r.code, 4, args.join(" "));
assert.equal(r.out, "");
assert.match(r.err, /instance: usage: --instance NAME, given once/);
}
const bad = run(BUS_SERVICE, ["--instance", "a-b", "render"], homeEnv(home));
assert.equal(bad.code, 4);
assert.match(bad.err, /instance-name-invalid/);
});
await t.test("copy takes no --instance in either script: every instance shares the copies", () => {
const copies = join(home, "copies-probe");
const before = snapshot(home);
for (const [cmd, args, err] of [
[STACK_COPY, ["--instance", "alpha", "copy", "HEAD", "--copies", copies], /^usage: stack-copy\.sh copy COMMIT/],
[STACK_COPY, ["copy", "HEAD", "--copies", copies, "--instance", "alpha"], /^usage: stack-copy\.sh copy COMMIT/],
[WEBUI_SERVICE, ["--instance", "alpha", "copy", "HEAD", "--copies", copies], /^webui-service: copy takes no --instance; every instance shares the copies\n$/],
[WEBUI_SERVICE, ["copy", "HEAD", "--copies", copies, "--instance", "alpha"], /^webui-service: copy takes no --instance; every instance shares the copies\n$/],
]) {
const r = run(cmd, args, homeEnv(home));
assert.equal(r.code, 4, `${args.join(" ")}: ${r.err}`);
assert.equal(r.out, "");
assert.match(r.err, err, args.join(" "));
}
assert.deepEqual(snapshot(home), before);
});
});
test("T-I7 claims: the instance op refuses a root another instance claims, and a named root with no marker", async (t) => {
const home = tmp(t, "si-");
for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0);
const edit = (name, dataRoot) => writeJson(cfgOf(home, name), { ...JSON.parse(readFileSync(cfgOf(home, name), "utf8")), dataRoot });
const scratch = join(home, "scratch-config.json");
const scratchRoot = join(home, "scratch-data");
writeJson(scratch, { configVersion: 1, environment: "development", dataRoot: scratchRoot, execution: { backend: "docker", provider: "zai", model: "glm-5.3-flash" } });
await t.test("alpha's config pointing at beta's root refuses instance-root-mismatch in every op", () => {
edit("alpha", join(home, ".mosaic-beta"));
for (const op of ["instance", "validate", "env", "bootstrap"]) {
const r = config(home, {}, op, "--instance", "alpha");
assert.equal(r.code, 3, `${op}: ${r.err}`);
assert.equal(r.out, "");
assert.match(r.err, new RegExp(`instance-root-mismatch: ${join(home, ".mosaic-beta")} is claimed by instance beta, not alpha`));
}
edit("alpha", join(home, ".mosaic-alpha"));
assert.equal(config(home, {}, "instance", "--instance", "alpha").code, 0);
});
await t.test("MOSAIC_CONFIG naming alpha's config with no flag refuses: the root is alpha's", () => {
for (const op of ["instance"]) {
const r = config(home, { MOSAIC_CONFIG: cfgOf(home, "alpha") }, op);
assert.equal(r.code, 3, r.err);
assert.match(r.err, new RegExp(`instance-root-mismatch: ${join(home, ".mosaic-alpha")} is claimed by instance alpha; select it with --instance alpha`));
}
});
await t.test("a default config whose root carries alpha's marker refuses; one naming dev, or none, passes", () => {
mkdirSync(scratchRoot, { recursive: true });
assert.equal(config(home, { MOSAIC_CONFIG: scratch }, "instance").code, 0, "no marker passes as today");
writeFileSync(join(scratchRoot, "instance.json"), `{"instanceVersion":1,"name":"alpha"}\n`);
const r = config(home, { MOSAIC_CONFIG: scratch }, "instance");
assert.equal(r.code, 3);
assert.match(r.err, /claimed by instance alpha; select it with --instance alpha/);
writeFileSync(join(scratchRoot, "instance.json"), `{"instanceVersion":1,"name":"dev"}\n`);
assert.equal(config(home, { MOSAIC_CONFIG: scratch }, "instance").code, 0);
rmSync(join(scratchRoot, "instance.json"));
});
await t.test("a named root whose marker is missing, a symlink, unparsable or the wrong shape refuses instance-root-unclaimed", () => {
const marker = join(home, ".mosaic-alpha", "instance.json");
const good = readFileSync(marker, "utf8");
const cases = [
["missing", () => {}, /holds no instance\.json naming alpha/],
["a symlink", () => { writeFileSync(join(home, "real-marker.json"), good); symlinkSync(join(home, "real-marker.json"), marker); }, /must be a regular, non-symbolic-link file/],
["a directory", () => mkdirSync(marker), /must be a regular, non-symbolic-link file/],
["unparsable", () => writeFileSync(marker, "{"), /is not a valid marker/],
["an extra key", () => writeFileSync(marker, `{"instanceVersion":1,"name":"alpha","x":1}\n`), /is not a valid marker/],
["version 2", () => writeFileSync(marker, `{"instanceVersion":2,"name":"alpha"}\n`), /is not a valid marker/],
["a bad name", () => writeFileSync(marker, `{"instanceVersion":1,"name":"Alpha"}\n`), /is not a valid marker/],
];
for (const [name, change, why] of cases) {
rmSync(marker, { recursive: true, force: true });
change();
const r = config(home, {}, "instance", "--instance", "alpha");
assert.equal(r.code, 3, `${name}: ${r.err}`);
assert.match(r.err, /instance-root-unclaimed/, name);
assert.match(r.err, why, name);
rmSync(marker, { recursive: true, force: true });
writeFileSync(marker, good);
}
assert.equal(config(home, {}, "instance", "--instance", "alpha").code, 0);
});
});
async function until(check, ms) {
const end = Date.now() + ms;
while (!check()) {
if (Date.now() > end) throw new Error("timed out");
await new Promise((r) => setTimeout(r, 50));
}
}
// One named instance in `home`, ready for `bus start acme --no-launch`: bootstrap, a business file in
// its config dir, its own roles copy, the skills its roles name, and a notifier with no binding.
function hostInstance(home, root, name) {
assert.equal(bootstrap(home, "--instance", name).code, 0);
const cfg = cfgOf(home, name), dataRoot = join(home, `.mosaic-${name}`);
const files = join(root, `${name}-files`);
const roles = rolesCopy(files);
const doc = businessDoc(files, "acme");
for (const r of Object.values(doc.roles)) r.vars = { harness: "pi", model: "glm-5.3-flash", thinking: "high", ...r.vars };
writeJson(join(home, ".config", `mosaic-${name}`, "businesses", "acme.json"), { ...doc, plugins: { ...doc.plugins, harness: ["pi", "claude-code"] } });
cpSync(REPO_SKILLS, join(dataRoot, "skills-enabled"), { recursive: true });
notifyConfig(dataRoot, "acme", null);
return { name, cfg, dataRoot, env: homeEnv(home, { MOSAIC_CONFIG: cfg, MOSAIC_ROLES_DIR: roles }) };
}
test("T-I5 two real bus hosts, one per instance, run at once and each answers from its own socket under its own root", async (t) => {
const root = tmp(t, "si-");
const home = join(root, "h");
mkdirSync(home);
const both = ["alpha", "beta"].map((name) => hostInstance(home, root, name));
for (const inst of both) {
const sock = join(inst.dataRoot, "bus", "broker.sock");
// The kernel's limit for a Unix socket path is 107 bytes (the row 177 lesson).
assert.ok(Buffer.byteLength(sock) <= 107, `socket path is ${Buffer.byteLength(sock)} bytes: ${sock}`);
inst.sock = sock;
inst.child = spawn(MOSAIC, ["bus", "start", "acme", "--no-launch"], { env: inst.env, stdio: ["ignore", "pipe", "pipe"] });
t.after(() => inst.child.exitCode === null && inst.child.kill("SIGKILL"));
inst.out = "";
inst.child.stdout.on("data", (b) => (inst.out += b));
inst.child.stderr.on("data", (b) => (inst.out += b));
}
for (const inst of both) await until(() => /bus host up: business acme.*\n/.test(inst.out) || inst.child.exitCode !== null, 30000);
for (const inst of both) {
assert.match(inst.out, new RegExp(`bus host up: business acme, socket ${inst.sock}, notifier off, launches disabled\n`), inst.out);
assert.ok(lstatSync(inst.sock).isSocket());
}
for (const inst of both) {
const r = run(MOSAIC, ["bus", "status", "--json"], inst.env);
assert.equal(r.code, 0, r.err);
const s = JSON.parse(r.out);
assert.equal(s.host.business, "acme");
assert.equal(s.host.pid, inst.child.pid);
assert.equal(s.host.live, true);
assert.equal(s.socket, true);
// The broker process under the host holds the writer lock.
assert.equal(s.writerLock.live, true);
inst.lockPid = s.writerLock.pid;
}
assert.notEqual(both[0].child.pid, both[1].child.pid);
assert.notEqual(both[0].lockPid, both[1].lockPid);
// Stopping alpha leaves beta up.
const [alpha, beta] = both;
const exitA = once(alpha.child, "exit");
const stopA = run(MOSAIC, ["bus", "stop"], alpha.env);
assert.equal(stopA.code, 0, stopA.err);
assert.match(stopA.out, new RegExp(`stopped bus host for acme \\(pid ${alpha.child.pid}\\)`));
assert.equal((await exitA)[0], 0, alpha.out);
assert.equal(beta.child.exitCode, null);
const still = JSON.parse(run(MOSAIC, ["bus", "status", "--json"], beta.env).out);
assert.equal(still.host.pid, beta.child.pid);
assert.equal(still.writerLock.pid, beta.lockPid);
assert.equal(still.writerLock.live, true);
assert.equal(existsSync(join(alpha.dataRoot, "bus", "writer.lock")), false);
const exitB = once(beta.child, "exit");
assert.equal(run(MOSAIC, ["bus", "stop"], beta.env).code, 0);
assert.equal((await exitB)[0], 0, beta.out);
});
test("T-I10 the guard protects every instance's config dir and data roots, even under a corrupt config", (t) => {
const root = tmp(t, "si-");
const home = join(root, "h");
mkdirSync(home);
for (const name of ["alpha", "beta"]) assert.equal(bootstrap(home, "--instance", name).code, 0);
// alpha's root moved by its operator; beta's config is broken.
const moved = join(root, "alpha-data");
mkdirSync(moved);
writeJson(cfgOf(home, "alpha"), { ...JSON.parse(readFileSync(cfgOf(home, "alpha"), "utf8")), dataRoot: moved });
writeFileSync(cfgOf(home, "beta"), "{ not json");
for (const dir of ["mosaic.bak", "mosaic", "mosaic-a-b", "mosaic-Alpha", "mosaic-x"]) mkdirSync(join(home, ".config", dir));
const guard = new LiveSessionGuard({ fixtureRoot: root, homes: [home] });
const refuses = [
[join(home, ".config", "mosaic-alpha", "x"), "stack instance alpha's config dir"],
[join(home, ".mosaic-alpha", "x"), "stack instance alpha's default data root"],
[join(moved, "sessions", "x"), "stack instance alpha's configured data root"],
[join(home, ".config", "mosaic-beta", "x"), "stack instance beta's config dir"],
[join(home, ".mosaic-beta", "x"), "stack instance beta's default data root"],
[join(home, ".config"), "stack instance alpha's config dir"],
];
for (const [p, why] of refuses) {
assert.throws(() => guard.check({ workspace: p }, "bind"), (e) => e.message === `workspace overlaps ${why} (bind)`, p);
}
for (const dir of ["mosaic.bak", "mosaic", "mosaic-a-b", "mosaic-Alpha", "mosaic-x"]) {
assert.equal(guard.check({ workspace: join(home, ".config", dir, "x") }, "bind"), true, dir);
}
assert.equal(guard.check({ workspace: join(root, "work") }, "bind"), true);
});
+234 -39
View File
@@ -25,7 +25,8 @@ const script=(fn)=>`#!${NODE}\n(${fn})();\n`;
// systemctl --user: unit state in $SD_STATE/units.json, every call in calls.log. A bus start runs
// holder.cjs (the store held open in WAL mode, writer.lock, host.json) and, with --pm in the unit's
// ExecStart, writes the `launched` journal line and the launches entry unless a pm-<copy> fault
// names another case. Faults are files in $SD_STATE/faults named for the copy they hit.
// names another case. Faults are files in $SD_STATE/faults named for the copy they hit. Units of a
// named instance (row 190) run on their own data root and host files beside the default's.
function systemctl(){
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),cp=process.getBuiltinModule('node:child_process'),crypto=process.getBuiltinModule('node:crypto');
const sd=process.env.SD_STATE,data=process.env.SD_DATA,unitDir=path.join(process.env.XDG_CONFIG_HOME,'systemd','user');
@@ -37,12 +38,16 @@ function systemctl(){
const save=(s)=>{fs.writeFileSync(stFile+'.tmp',JSON.stringify(s));fs.renameSync(stFile+'.tmp',stFile)};
const sleep=(ms)=>Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,ms);
const alive=(pid)=>{try{const s=fs.readFileSync(`/proc/${pid}/stat`,'utf8');return s[s.lastIndexOf(')')+2]!=='Z'}catch{return false}};
const isBus=(u)=>/^mosaic-bus@[a-z0-9-]+\.service$/.test(u),inst=(u)=>u.slice('mosaic-bus@'.length,-'.service'.length);
// Row 190: a bus unit is the default's mosaic-bus@B or instance NAME's mosaic-bus-NAME@B; %i is B.
const isBus=(u)=>/^mosaic-bus(-[a-z][a-z0-9]{1,15})?@[a-z0-9-]+\.service$/.test(u),inst=(u)=>u.slice(u.indexOf('@')+1,-'.service'.length);
const unitFile=(u)=>{
const main=isBus(u)?'mosaic-bus@.service':u,file=path.join(unitDir,main);
const main=isBus(u)?u.replace(/@[^@]*$/,'@.service'):u,file=path.join(unitDir,main);
if(!fs.existsSync(file))return null;
const drops=[];
for(const d of isBus(u)?[main+'.d',u+'.d']:[u+'.d']){const dir=path.join(unitDir,d);if(fs.existsSync(dir))for(const f of fs.readdirSync(dir).sort())if(f.endsWith('.conf'))drops.push(path.join(dir,f))}
// The drop-in directories in systemd's order: service.d, the name cut after each hyphen, then
// the template's and the unit's own.
const stem=main.slice(0,main.search(/@?\.service$/)),dirs=['service.d'],drops=[];
for(let i=0;i<stem.length;i++)if(stem[i]==='-')dirs.push(stem.slice(0,i+1)+'.service.d');
for(const d of [...dirs,...(isBus(u)?[main+'.d',u+'.d']:[u+'.d'])]){const dir=path.join(unitDir,d);if(fs.existsSync(dir))for(const f of fs.readdirSync(dir).sort())if(f.endsWith('.conf'))drops.push(path.join(dir,f))}
const r={wd:'',exec:'',env:[],drops};
for(const f of [file,...drops])for(let line of fs.readFileSync(f,'utf8').split('\n')){
line=line.replace(/%i/g,isBus(u)?inst(u):'').replace(/%h/g,process.env.HOME);
@@ -51,17 +56,21 @@ function systemctl(){
}
return r;
};
const snap=(ev)=>{
const dir=path.join(data,'bus'),files={};
// A named instance's unit sets MOSAIC_CONFIG, and its bus works on that config's data root. A unit
// without it is the default's, on $SD_DATA. Each data root has its own host and launches files.
const dataOf=(f)=>{const c=f&&f.env.find((v)=>v.startsWith('MOSAIC_CONFIG='));return c?JSON.parse(fs.readFileSync(c.slice('MOSAIC_CONFIG='.length),'utf8')).dataRoot:data};
const perData=(d,n)=>path.join(sd,d===data?`${n}.json`:`${n}-${path.basename(d)}.json`);
const snap=(ev,d)=>{
const dir=path.join(d,'bus'),files={};
try{for(const n of fs.readdirSync(dir).sort()){const p=path.join(dir,n),st=fs.statSync(p);if(st.isFile())files[n]=[st.size,st.mtimeMs,crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex')]}}catch{}
fs.appendFileSync(path.join(sd,'snap.jsonl'),JSON.stringify({ev,files})+'\n');
};
const pm=(b,id,pid,off)=>{
const pm=(unit,d,id,pid,off)=>{
let mode='';try{mode=fs.readFileSync(faultFile(`pm-${id}`),'utf8').trim()}catch{}
const now=Date.now()-off*1000,epoch=Math.floor(now/1000),at=new Date(now).toISOString(),run=`run-${pid}`,unit=`mosaic-bus@${b}.service`;
const b=inst(unit),now=Date.now()-off*1000,epoch=Math.floor(now/1000),at=new Date(now).toISOString(),run=`run-${pid}`;
const line=(text)=>fs.appendFileSync(path.join(sd,'journal.jsonl'),JSON.stringify({epoch,unit,text})+'\n');
const ok=()=>line(`launched pm-${b}: run ${run}, claude opus`);
const lf=path.join(sd,'launches.json');let list=[];try{list=JSON.parse(fs.readFileSync(lf,'utf8'))}catch{}
const lf=perData(d,'launches');let list=[];try{list=JSON.parse(fs.readFileSync(lf,'utf8'))}catch{}
const entry=(o)=>list.push({run,business:b,live:true,startedAt:at,...o});
line(`bus host up: business ${b}, socket /fixture/bus.sock, notifier off`);
switch(mode){
@@ -107,26 +116,26 @@ function systemctl(){
}
if(verb==='start'){
const u=rest[0],f=unitFile(u);if(!f){console.error(`Unit ${u} not found.`);process.exit(5)}
const s=load(),id=path.basename(f.wd);
const s=load(),id=path.basename(f.wd),d=dataOf(f);
if(!isBus(u)){s[u]={state:'active',pid:null,wd:f.wd};save(s);process.exit(0)}
if(fault(`start-dies-${id}`)){s[u]={state:'failed',pid:null,wd:f.wd};save(s);process.exit(0)}
// Row 185 (T48): the copy's start writes one event before the holder opens the store, so the
// store differs from a backup the run took before the start. The close leaves no -wal.
if(fault(`start-writes-row-${id}`)){
const db=new (process.getBuiltinModule('node:sqlite').DatabaseSync)(path.join(data,'bus','bus.sqlite'));
const db=new (process.getBuiltinModule('node:sqlite').DatabaseSync)(path.join(d,'bus','bus.sqlite'));
db.prepare('INSERT INTO events (at) VALUES (?)').run('2999-01-01T00:00:00.000Z');db.close();
}
snap('start');
snap('start',d);
const off=process.env.SD_CLOCK_OFFSET||'0';
// The holder must not inherit stack-release.sh's lock (fd 9) or any other fd above 2.
const close='for fd in /proc/self/fd/*; do n=${fd##*/}; [ "$n" -gt 2 ] && eval "exec $n>&-" 2>/dev/null; done; exec "$@"';
const ch=cp.spawn('bash',['-c',close,'holder',process.execPath,path.join(__dirname,'..','holder.cjs'),data,inst(u),sd,off],{detached:true,stdio:'ignore'});
const ch=cp.spawn('bash',['-c',close,'holder',process.execPath,path.join(__dirname,'..','holder.cjs'),d,inst(u),sd,off,perData(d,'host')],{detached:true,stdio:'ignore'});
ch.unref();
fs.appendFileSync(path.join(sd,'pids.log'),ch.pid+'\n');
const ready=path.join(sd,`ready-${ch.pid}`),end=Date.now()+10000;
while(!fs.existsSync(ready)){if(Date.now()>end){console.error('fixture holder did not start');process.exit(1)}sleep(20)}
s[u]={state:'active',pid:ch.pid,wd:f.wd};save(s);
if(f.exec.split(' ').slice(1).includes('--pm'))pm(inst(u),id,ch.pid,Number(off));
if(f.exec.split(' ').slice(1).includes('--pm'))pm(u,d,id,ch.pid,Number(off));
process.exit(0);
}
if(verb==='stop'){
@@ -138,17 +147,18 @@ function systemctl(){
}
if(st){s[u]={...st,state:'inactive',pid:null};save(s)}
if(isBus(u)){
const d=dataOf(unitFile(u));
// One-shot: the stop leaves an empty -wal or -shm beside the store, as an unclean close would.
for(const [name,ext] of [['stop-leaves-wal','-wal'],['stop-leaves-shm','-shm']])if(fault(name)){fs.rmSync(faultFile(name));fs.writeFileSync(path.join(data,'bus','bus.sqlite'+ext),'')}
for(const [name,ext] of [['stop-leaves-wal','-wal'],['stop-leaves-shm','-shm']])if(fault(name)){fs.rmSync(faultFile(name));fs.writeFileSync(path.join(d,'bus','bus.sqlite'+ext),'')}
// One-shot (T45): the stop writes one row after any STAMP into the table the fault names, as a
// write the bus made between rollback's count and its stop would. The close leaves no -wal.
if(fault('stop-writes-row')){
const t=fs.readFileSync(faultFile('stop-writes-row'),'utf8');fs.rmSync(faultFile('stop-writes-row'));
const db=new (process.getBuiltinModule('node:sqlite').DatabaseSync)(path.join(data,'bus','bus.sqlite'));
const db=new (process.getBuiltinModule('node:sqlite').DatabaseSync)(path.join(d,'bus','bus.sqlite'));
db.exec(`CREATE TABLE IF NOT EXISTS ${t} (seq INTEGER PRIMARY KEY, at TEXT NOT NULL)`);
db.prepare(`INSERT INTO ${t} (at) VALUES (?)`).run('2999-01-01T00:00:00.000Z');db.close();
}
snap('stop');
snap('stop',d);
}
process.exit(0);
}
@@ -178,10 +188,11 @@ function systemdAnalyze(){
if(fs.existsSync(extra))console.log(fs.readFileSync(extra,'utf8'));
}
// The bus host: holds the store open in WAL mode until SIGTERM, as the broker does.
// The bus host: holds the store open in WAL mode until SIGTERM, as the broker does. It writes its
// status to the host file systemctl names for its data root.
function holder(){
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),{DatabaseSync}=process.getBuiltinModule('node:sqlite');
const [data,business,sd,offset]=process.argv.slice(2),bus=path.join(data,'bus');
const [data,business,sd,offset,host]=process.argv.slice(2),bus=path.join(data,'bus');
fs.mkdirSync(bus,{recursive:true,mode:0o700});
const db=new DatabaseSync(path.join(bus,'bus.sqlite'));
if(!db.prepare("SELECT count(*) AS n FROM sqlite_master WHERE name='meta'").get().n){
@@ -191,7 +202,7 @@ function holder(){
db.prepare("INSERT INTO meta VALUES ('schema_version', '3g')").run();
}
fs.writeFileSync(path.join(bus,'writer.lock'),String(process.pid));
const at=new Date(Date.now()-Number(offset||0)*1000).toISOString(),host=path.join(sd,'host.json');
const at=new Date(Date.now()-Number(offset||0)*1000).toISOString();
fs.writeFileSync(host+'.tmp',JSON.stringify({host:{business,pid:process.pid,live:true,startedAt:at,notifier:'ok'},boot:{ok:true,business,at}}));
fs.renameSync(host+'.tmp',host);
process.on('SIGTERM',()=>{db.close();fs.rmSync(path.join(bus,'writer.lock'),{force:true});process.exit(0)});
@@ -199,21 +210,24 @@ function holder(){
setInterval(()=>{},1<<30);
}
// A copy's scripts/mosaic: bus status and launches list from what the holder and systemctl wrote.
// A copy's scripts/mosaic: bus status and launches list from what the holder and systemctl wrote
// for the data root of the config it runs under (row 190), the default's being $SD_DATA.
function mosaicStub(){
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path');
const id=path.basename(path.resolve(__dirname,'..')),sd=process.env.SD_STATE,a=process.argv.slice(2).join(' ');
const fault=(n)=>fs.existsSync(path.join(sd,'faults',n));
let d=process.env.SD_DATA;try{d=JSON.parse(fs.readFileSync(process.env.MOSAIC_CONFIG,'utf8')).dataRoot}catch{}
const perData=(n)=>path.join(sd,d===process.env.SD_DATA?`${n}.json`:`${n}-${path.basename(d)}.json`);
const alive=(pid)=>{try{const s=fs.readFileSync(`/proc/${pid}/stat`,'utf8');return s[s.lastIndexOf(')')+2]!=='Z'}catch{return false}};
if(a==='bus status --json'){
if(fault(`status-${id}`))process.exit(1);
let s={host:null,boot:null};
try{s=JSON.parse(fs.readFileSync(path.join(sd,'host.json'),'utf8'));s.host.live=alive(s.host.pid)}catch{}
try{s=JSON.parse(fs.readFileSync(perData('host'),'utf8'));s.host.live=alive(s.host.pid)}catch{}
console.log(JSON.stringify(s));
}else if(a==='bus status')console.log(`bus: fixture status for ${id}`);
else if(a==='launches list --json'){
if(fault(`launches-${id}`))process.exit(1);
let l=[];try{l=JSON.parse(fs.readFileSync(path.join(sd,'launches.json'),'utf8'))}catch{}
let l=[];try{l=JSON.parse(fs.readFileSync(perData('launches'),'utf8'))}catch{}
console.log(JSON.stringify(l));
}else{console.error(`fixture mosaic: ${a}`);process.exit(4)}
}
@@ -289,7 +303,7 @@ const posts=(e)=>readdirSync(e.data).filter((n)=>n.startsWith('bus.post-attempt-
const backupSum=(e,act)=>readFileSync(join(e.data,'backups','stack',act,'SHA256SUMS'),'utf8').split(' ')[0];
const commitLines=(range)=>git(repo,'log','--oneline','--no-merges','--max-count=200',range).split('\n').map((l)=>` ${l}`);
// The units, the wrapper, the pointer and the store, by sha256.
const fp=(e)=>({bus:sha(join(e.units,'[email protected]')),console:sha(join(e.units,CON)),wrapper:sha(join(e.bin,'mosaic')),pointer:existsSync(e.pointer)?sha(e.pointer):null,store:sha(e.store)});
const fp=(e)=>({bus:sha(join(e.units,e.tmpl)),console:sha(join(e.units,e.con)),wrapper:sha(join(e.bin,e.wrapper)),pointer:existsSync(e.pointer)?sha(e.pointer):null,store:sha(e.store)});
// The schema version: readOnly while the holder has the store open, immutable when it is stopped.
function storeVersion(file,held){
const db=held?new DatabaseSync(file,{readOnly:true}):new DatabaseSync(pathToFileURL(file).href+'?immutable=1',{readOnly:true});
@@ -317,6 +331,8 @@ function biz(e,plugins){
async function mk(t){
const root=realpathSync(mkdtempSync(join(tmpdir(),'sr-')));
const e={root,data:join(root,'data'),bin:join(root,'bin'),sd:join(root,'sd'),units:join(root,'xcfg','systemd','user'),cfg:join(root,'cfg','config.json'),claude:join(root,'claude-bin','claude')};
// The default instance's names; named() sets NAME's (row 190).
Object.assign(e,{flags:[],bus:BUS,tmpl:'[email protected]',con:CON,wrapper:'mosaic'});
Object.assign(e,{state:join(e.data,'state'),pointer:join(e.data,'state','stack-active.json'),log:join(e.data,'state','stack-activation-log.jsonl'),store:join(e.data,'bus','bus.sqlite'),lock:join(e.data,'state','stack-release.lock')});
for(const d of [join(root,'home'),dirname(e.cfg),join(e.data,'bus'),e.bin,dirname(e.claude),join(e.sd,'faults'),e.units,join(root,'xdata')])mkdirSync(d,{recursive:true});
chmodSync(join(e.data,'bus'),0o700);
@@ -326,25 +342,47 @@ async function mk(t){
writeFileSync(join(e.bin,'mosaic-old'),'#!/bin/sh\nexit 0\n',{mode:0o755});
writeFileSync(e.claude,'#!/bin/sh\necho "9.9.9 (Claude Code fixture)"\n',{mode:0o755});
e.env={PATH:`${SBIN}:${dirname(NODE)}:/usr/bin:/bin`,HOME:join(root,'home'),XDG_CONFIG_HOME:join(root,'xcfg'),XDG_DATA_HOME:join(root,'xdata'),MOSAIC_CONFIG:e.cfg,SD_STATE:e.sd,SD_DATA:e.data,TMPDIR:tmpdir()};
// The Console's /healthz: 200 while the stand-in has it active and no healthz-<copy> fault names its copy.
const server=createServer((q,r)=>{
let st={};try{st=units(e)[CON]||{}}catch{}
const ok=q.url==='/healthz'&&st.state==='active'&&!existsSync(join(e.sd,'faults',`healthz-${basename(st.wd||'')}`));
r.writeHead(ok?200:500);r.end();
});
server.listen(0,'127.0.0.1');await once(server,'listening');e.port=String(server.address().port);
const server=await healthz(e);
t.after(()=>{
for(const pid of lines(join(e.sd,'pids.log')).map(Number)){
try{if(readFileSync(`/proc/${pid}/cmdline`,'utf8').includes(join(base,'holder.cjs')))process.kill(pid,'SIGKILL')}catch{}
}
server.close();execFileSync('chmod',['-R','u+w',root]);rmSync(root,{recursive:true,force:true});
for(const s of [server,...(e.servers??[])])s.close();
execFileSync('chmod',['-R','u+w',root]);rmSync(root,{recursive:true,force:true});
});
return e;
}
// The Console's /healthz on e.port: 200 while the stand-in has e.con active and no healthz-<copy>
// fault names its copy.
async function healthz(e){
const server=createServer((q,r)=>{
let st={};try{st=units(e)[e.con]||{}}catch{}
const ok=q.url==='/healthz'&&st.state==='active'&&!existsSync(join(e.sd,'faults',`healthz-${basename(st.wd||'')}`));
r.writeHead(ok?200:500);r.end();
});
server.listen(0,'127.0.0.1');await once(server,'listening');e.port=String(server.address().port);
return server;
}
// Row 190: stack instance NAME on e's host. It shares e's stand-ins, unit directory and $BIN, and has
// its own config and data root from bootstrap --instance, its own Console port and business acme.
// Its runs pass --instance NAME and no MOSAIC_CONFIG.
async function named(e,name){
const env={...e.env};delete env.MOSAIC_CONFIG;
execFileSync('bash',[join(repo,'scripts','bootstrap.sh'),'--instance',name],{env,stdio:'ignore'});
const data=join(env.HOME,`.mosaic-${name}`);
const n={...e,env,data,cfg:join(env.HOME,'.config',`mosaic-${name}`,'config.json'),flags:['--instance',name],bus:`mosaic-bus-${name}@acme.service`,tmpl:`mosaic-bus-${name}@.service`,con:`mosaic-webui-${name}.service`,wrapper:`mosaic-${name}`,name};
Object.assign(n,{state:join(data,'state'),pointer:join(data,'state','stack-active.json'),log:join(data,'state','stack-activation-log.jsonl'),store:join(data,'bus','bus.sqlite'),lock:join(data,'state','stack-release.lock')});
mkdirSync(join(data,'bus'),{mode:0o700});
biz(n);
(e.servers??=[]).push(await healthz(n));
return n;
}
async function run(e,...args){
const before=existsSync(e.data)?kept(e):[];
const ch=spawn('bash',[e.sr??SR,...args,'--copies',COPIES,'--bin',e.bin],{env:e.env,stdio:['ignore','pipe','pipe']});
const ch=spawn('bash',[e.sr??SR,...args,...e.flags,'--copies',COPIES,'--bin',e.bin],{env:e.env,stdio:['ignore','pipe','pipe']});
let out='',err='';ch.stdout.on('data',(d)=>{out+=d});ch.stderr.on('data',(d)=>{err+=d});
const timer=setTimeout(()=>ch.kill('SIGKILL'),120000);
const [code]=await once(ch,'close');clearTimeout(timer);
@@ -364,12 +402,12 @@ async function seed(e,{copy=B,pm=false,store='3g',pointer:how='adopt'}={}){
for(let i=0;i<3;i++)db.prepare('INSERT INTO events (at) VALUES (?)').run(`2000-01-01T00:00:0${i}.000Z`);
db.close();
const sh=(name,...a)=>execFileSync('bash',[join(repo,'scripts',name),...a],{env:e.env,encoding:'utf8',stdio:['ignore','pipe','pipe']});
sh('bus-service.sh','install','--copy',copy,'--copies',COPIES,...(pm?['--pm']:[]),'--claude',e.claude);
sh('webui-service.sh','install','--copy',copy,'--copies',COPIES,'--port',e.port,'--business','acme');
sh('stack-copy.sh','wrapper',copy,'--copies',COPIES,'--bin',e.bin);
sh('bus-service.sh','install',...e.flags,'--copy',copy,'--copies',COPIES,...(pm?['--pm']:[]),'--claude',e.claude);
sh('webui-service.sh','install',...e.flags,'--copy',copy,'--copies',COPIES,'--port',e.port,'--business','acme');
sh('stack-copy.sh','wrapper',copy,...e.flags,'--copies',COPIES,'--bin',e.bin);
// The seeded host started two minutes ago, so its boot status and PM run predate any run's start.
execFileSync(join(SBIN,'systemctl'),['--user','start',BUS],{env:{...e.env,SD_CLOCK_OFFSET:'120'}});
sysctl(e,'start',CON);
execFileSync(join(SBIN,'systemctl'),['--user','start',e.bus],{env:{...e.env,SD_CLOCK_OFFSET:'120'}});
sysctl(e,'start',e.con);
if(how==='adopt'){const r=await run(e,'adopt','acme');assert.equal(r.code,0,r.err)}
else if(how==='hand'){
mkdirSync(e.state,{recursive:true});
@@ -2191,3 +2229,160 @@ test('R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops t
assert.equal(existsSync(`/proc/${pid}`),false,`${sig}: host pid ${pid} is still there`);
}
});
// --- Row 190 (#1688): stack instances on one host. Design and the T-I table:
// agents/darkwing/work/queue-190/DESIGN.md. T-I1, T-I2, T-I5, T-I10 and the loader half of T-I7 are in
// stack-instances.test.mjs; these need the units, the stand-ins and stack-release.sh. Every existing
// test above is D1's "no flag, no marker" case and passes unchanged.
const shOut=(env,name,...a)=>spawnSync('bash',[join(repo,'scripts',name),...a],{env,encoding:'utf8'});
// The Console template's comment on the variables its unit sets, as the default and a named instance render it.
const CONSOLE_VARS={
default:'# and 94). The unit sets no MOSAIC_*, CLAUDE*, CODEX* or PI_* variable.',
named:'# and 94). The unit sets MOSAIC_CONFIG and no other MOSAIC_*, CLAUDE*, CODEX* or PI_* variable.',
};
const SHARED_DROPINS=['mosaic-bus-.service.d','mosaic-webui-.service.d','mosaic-discord-.service.d'];
// Every path under each of ps with its mode, size, mtime and, for a file, its sha256.
function stamp(...ps){
const out=[];
const walk=(q)=>{let st;try{st=lstatSync(q)}catch{out.push(`${q} absent`);return}
out.push(`${q} ${st.mode.toString(8)} ${st.size} ${st.mtimeMs}${st.isFile()?' '+sha(q):''}`);
if(st.isDirectory())for(const n of readdirSync(q).sort())walk(join(q,n))};
for(const p of ps)walk(p);return out;
}
const dirsUnder=(p)=>stamp(p).filter((l)=>!l.endsWith(' absent')).map((l)=>l.split(' ')[0]).filter((q)=>lstatSync(q).isDirectory());
// The T53 inotify watch on a directory: it counts each open or read of the directory or a file in it.
async function watchDir(e,d){
const ch=spawn('python3',['-I','-c',WATCH,d],{stdio:['pipe','pipe','inherit']});
let out='';ch.stdout.on('data',(b)=>{out+=b});
await new Promise((res,rej)=>{ch.stdout.on('data',()=>{if(out.startsWith('ready\n'))res()});ch.on('error',rej);ch.on('close',(c)=>rej(new Error(`inotify watch exited ${c}`)))});
WATCHERS.add(ch);ch.on('close',()=>WATCHERS.delete(ch));
(e.watches??=[]).push(async()=>{ch.stdin.end();if(ch.exitCode===null)await once(ch,'close');return [d,Number(out.split('\n')[1])]});
}
test('T56 row 190: named instances side by side on one unit directory, $BIN and set of stand-ins',async(t)=>{
await t.test('T-I3 each instance renders its own config, session env and Description, and nothing of another',async(t)=>{
const e=await mk(t),a=await named(e,'alpha'),b=await named(e,'beta');
for(const [x,y] of [[a,b],[b,a]]){
const renders={
bus:shOut(x.env,'bus-service.sh','render','--instance',x.name,'--copy',B,'--copies',COPIES,'--claude',e.claude),
console:shOut(x.env,'webui-service.sh','render','--instance',x.name,'--copy',B,'--copies',COPIES,'--port',x.port,'--business','acme'),
discord:shOut(x.env,'discord-service.sh','render','--instance',x.name),
};
for(const [k,r] of Object.entries(renders)){
assert.equal(r.status,0,`${x.name} ${k}: ${r.stderr}`);
assert.ok(r.stdout.includes(`\nEnvironment=MOSAIC_CONFIG=${x.cfg}\n`),`${x.name} ${k}`);
assert.match(r.stdout,new RegExp(`\\nDescription=[^\\n]* \\[${x.name}\\]\\n`),`${x.name} ${k}`);
for(const not of [y.name,'mosaic-dev'])assert.ok(!r.stdout.includes(not),`${x.name} ${k} holds ${not}`);
}
assert.ok(renders.bus.stdout.includes(`\nEnvironmentFile=-${dirname(x.cfg)}/secrets/claude-session.env\n`),x.name);
assert.ok(renders.console.stdout.includes(`\n${CONSOLE_VARS.named}\n`),x.name);
assert.ok(!renders.console.stdout.includes('sets no MOSAIC_'),x.name);
assert.equal(renders.bus.stdout.match(/^Environment(File)?=.*$/gm).filter((l)=>l.includes('MOSAIC_CONFIG')||l.startsWith('EnvironmentFile')).length,2,x.name);
}
// Sage's N3 ruling: the default Console keeps the template's comment byte for byte.
const dflt=shOut(e.env,'webui-service.sh','render','--copy',B,'--copies',COPIES,'--business','acme');
assert.equal(dflt.status,0,dflt.stderr);
assert.ok(dflt.stdout.includes(`\n${CONSOLE_VARS.default}\n`),dflt.stdout);
assert.ok(!dflt.stdout.includes('MOSAIC_CONFIG'),dflt.stdout);
// Filbert round 2: under --sso the Console reads console.sso from the config MOSAIC_CONFIG names (row 192),
// so a named --sso render carries both.
const sso=shOut(a.env,'webui-service.sh','render','--instance',a.name,'--copy',B,'--copies',COPIES,'--port',a.port,'--business','acme','--sso');
assert.equal(sso.status,0,sso.stderr);
assert.match(sso.stdout,/^ExecStart=.* --sso(?: |$)/m,sso.stdout);
assert.ok(sso.stdout.includes(`\nEnvironment=MOSAIC_CONFIG=${a.cfg}\n`),sso.stdout);
await seed(a);await seed(b);
for(const x of [a,b]){
const r=shOut(x.env,'discord-service.sh','install','--instance',x.name,'--dir',e.units,'--no-reload');assert.equal(r.status,0,r.stderr);
for(const u of [x.tmpl,x.con,`mosaic-discord-${x.name}@.service`])assert.ok(existsSync(join(e.units,u)),u);
}
assert.deepEqual(readdirSync(e.units).sort(),[a.tmpl,a.con,`[email protected]`,b.tmpl,b.con,`[email protected]`].sort());
for(const x of [a,b])assert.equal(readFileSync(join(e.bin,x.wrapper),'utf8').split('\n')[1],`# mosaic-cli wrapper, instance ${x.name} (row 190)`);
});
await t.test('T-I4 a Console port another installed Console takes refuses port-claimed, and a named instance keeps off the default ports',async(t)=>{
const e=await mk(t),a=await named(e,'alpha'),b=await named(e,'beta');await seed(a);
const held=`port ${a.port} is taken by ${join(e.units,a.con)}`,free=await freePort();
const install=(x,...flags)=>shOut(x.env,'webui-service.sh','install',...x.flags,'--copy',B,'--copies',COPIES,...flags,'--business','acme');
const unitsBefore=stamp(e.units),binBefore=stamp(e.bin);
for(const [label,x,flags,code,msg] of [
['beta on alpha\'s port',b,['--port',a.port],1,`webui-service: refusing port-claimed: ${held}; nothing was changed\n`],
['beta\'s tester port on alpha\'s port',b,['--port',free,'--tester-port',a.port],1,`webui-service: refusing port-claimed: ${held}; nothing was changed\n`],
['the default on alpha\'s port',e,['--port',a.port],1,`webui-service: refusing port-claimed: ${held}; nothing was changed\n`],
['beta with no --port',b,[],4,'webui-service: stack instance beta needs --port; it has no default port\n'],
...['7330','7332','7333'].map((p)=>[`beta on ${p}`,b,['--port',p],4,`webui-service: port-reserved: ${p} is reserved for the default instance (7330 7332 7333)\n`]),
['beta\'s tester port on 7333',b,['--port',free,'--tester-port','7333'],4,'webui-service: port-reserved: 7333 is reserved for the default instance (7330 7332 7333)\n'],
]){
const r=install(x,...flags);assert.equal(r.status,code,`${label}: ${r.stderr}`);assert.equal(r.stderr,msg,label);
assert.deepEqual(stamp(e.units),unitsBefore,label);assert.deepEqual(stamp(e.bin),binBefore,label);
}
// beta on its own port passes: the seed installs both its units and adopts.
await seed(b);assert.ok(existsSync(join(e.units,b.con)));
// stack-release: a target Console port another Console takes refuses before anything stops.
const refused=async(label,...args)=>{
const before=fp(b),n=calls(e).length,logs=logLines(b).length;
const r=await run(b,'activate',C1,'acme',...args);assert.equal(r.code,3,`${label}: ${r.err}`);
assert.equal(r.err,`stack-release: refusing port-claimed: ${held}\n`,label);
assert.deepEqual(fp(b),before,label);assert.deepEqual(startsStops(e,n),[],label);
assert.equal(logLines(b).length,logs+1,label);assert.deepEqual([logLines(b).at(-1).result,logLines(b).at(-1).code],['refused','port-claimed'],label);
};
await refused('--tester-port','--tester-port',a.port);
const p=pointer(b);p.console[1]=a.port;writeFileSync(b.pointer,JSON.stringify(p)+'\n');
await refused('the pointer\'s --port');
const plan=await run(b,'activate',C1,'acme','--plan');assert.equal(plan.code,3,plan.err);
assert.ok(plan.out.split('\n').includes(`refuses port-claimed: ${held}`),plan.out);
});
// T-I6: activate and rollback on x leave y's every file, directory and the shared prefix drop-in
// directories as they were, and open nothing of y's config dir or data root.
const noCrossReach=async(t,xn,yn)=>{
const e=await mk(t),x=await named(e,xn),y=await named(e,yn);
for(const d of [...SHARED_DROPINS,`${x.tmpl}.d`,`${x.con}.d`,`${y.tmpl}.d`,`${y.con}.d`]){mkdirSync(join(e.units,d));writeFileSync(join(e.units,d,'t-i6.conf'),`# row 190 T-I6: ${d}\n`)}
await seed(x);await seed(y);
const mine=()=>stamp(dirname(y.cfg),y.data,...[y.tmpl,y.con,`${y.tmpl}.d`,`${y.con}.d`,...SHARED_DROPINS].map((u)=>join(e.units,u)),join(e.bin,y.wrapper),join(e.bin,'mosaic'));
const before=mine(),n=calls(e).length;
const watched=[...dirsUnder(dirname(y.cfg)),...dirsUnder(y.data)];
for(const d of [dirname(y.cfg),y.data,join(y.data,'bus'),y.state])assert.ok(watched.includes(d),d);
for(const d of watched)await watchDir(e,d);
const act=await run(x,'activate',C1,'acme');assert.equal(act.code,0,act.out+act.err);
const back=await run(x,'rollback','acme');assert.equal(back.code,0,back.out+back.err);
assert.deepEqual((await Promise.all(e.watches.map((s)=>s()))).filter(([,c])=>c!==0),[],`${xn}'s runs opened ${yn}'s files`);
assert.deepEqual(mine(),before);
assert.deepEqual(calls(e).slice(n).filter((c)=>c.includes(yn)),[]);
const sets=readdirSync(join(x.state,'stack-rollback')).sort();assert.equal(sets.length,2,sets.join(' '));
for(const s of sets){
const rs=join(x.state,'stack-rollback',s);
assert.deepEqual(readdirSync(rs).sort(),[x.tmpl,`${x.tmpl}.d`,x.con,`${x.con}.d`,'wrapper','WRAPPER_MODE',`show-${x.bus}.txt`,`show-${x.con}.txt`,'SHA256SUMS','steps.log'].sort(),s);
assert.equal(readFileSync(join(rs,'wrapper'),'utf8').split('\n')[1],`# mosaic-cli wrapper, instance ${xn} (row 190)`);
}
assert.equal(pointer(x).copy,B);assert.equal(pointer(y).copy,B);
};
await t.test('T-I6 alpha\'s activate and rollback reach nothing of beta\'s',(t)=>noCrossReach(t,'alpha','beta'));
await t.test('T-I6 swapped: beta\'s reach nothing of alpha\'s',(t)=>noCrossReach(t,'beta','alpha'));
await t.test('T-I7 MOSAIC_CONFIG naming alpha\'s config with no flag refuses instance-root-mismatch and changes nothing',async(t)=>{
const e=await mk(t),a=await named(e,'alpha');await seed(a);
const x={...a,env:{...a.env,MOSAIC_CONFIG:a.cfg},flags:[]};
const before=fp(a),n=calls(e).length,logs=logLines(a).length,unitsBefore=stamp(e.units),binBefore=stamp(e.bin);
const r=await run(x,'activate',C1,'acme');assert.equal(r.code,3,r.err);assert.match(r.err,/instance-root-mismatch/);assert.equal(r.out,'');
const i=shOut(x.env,'bus-service.sh','install','--copy',C1,'--copies',COPIES,'--claude',e.claude);
assert.equal(i.status,3,i.stderr);assert.match(i.stderr,/instance-root-mismatch/);
assert.deepEqual(calls(e).slice(n),[]);assert.deepEqual(fp(a),before);assert.equal(logLines(a).length,logs);
assert.deepEqual(stamp(e.units),unitsBefore);assert.deepEqual(stamp(e.bin),binBefore);
});
await t.test('T-I9 another instance\'s bus active refuses nothing; one of this instance\'s does',async(t)=>{
const e=await mk(t);await seed(e);
const a=await named(e,'alpha'),b=await named(e,'beta');await seed(a);await seed(b);
for(const x of [e,b]){const r=await run(x,'activate',C1,'acme');assert.equal(r.code,0,`${x.name??'default'}: ${r.out+r.err}`)}
const setUnit=(u,state)=>{const s=units(e);if(state)s[u]={...(s[u]||{pid:null,wd:''}),state};else delete s[u];writeFileSync(join(e.sd,'units.json'),JSON.stringify(s))};
for(const [u,state,msg] of [['[email protected]','active','[email protected] is active; the copy and wrapper switch reach it too'],[a.bus,'activating',`${a.bus} is activating`]]){
const was=units(e)[u];setUnit(u,state);
const before=fp(a),n=calls(e).length;
const r=await run(a,'activate',C1,'acme');assert.equal(r.code,3,r.err);
assert.equal(r.err,`stack-release: refusing instance-busy: ${msg}\n`);assert.deepEqual(fp(a),before);assert.deepEqual(startsStops(e,n),[]);
setUnit(u,was&&was.state);if(was){const s=units(e);s[u]=was;writeFileSync(join(e.sd,'units.json'),JSON.stringify(s))}
}
const r=await run(a,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
});
});
+28 -4
View File
@@ -4,8 +4,9 @@
// every session path are constructor arguments, with no default. At
// construction and again at bind, their real paths must lie inside the
// explicit fixture root and outside every protected location: the
// repository's .pi/state/, ~/.pi, ~/.claude, the configured data root and any
// path a seat registration names. A path is refused when it is inside a
// repository's .pi/state/, ~/.pi, ~/.claude, the configured data root, every
// stack instance's config dir and data roots (row 190) and any path a seat
// registration names. A path is refused when it is inside a
// protected location or contains one. The real protections always apply;
// options only add to them, so a test can't switch them off.
//
@@ -44,9 +45,9 @@ export function realPath(p) {
const inside = (child, parent) => child === parent || child.startsWith(parent.endsWith(sep) ? parent : parent + sep);
const overlaps = (a, b) => inside(a, b) || inside(b, a);
function configuredDataRoot(home) {
function configuredDataRoot(home, name = "dev") {
try {
const cfg = JSON.parse(readFileSync(join(home, ".config", "mosaic-dev", "config.json"), "utf8"));
const cfg = JSON.parse(readFileSync(join(home, ".config", `mosaic-${name}`, "config.json"), "utf8"));
if (typeof cfg?.dataRoot === "string" && cfg.dataRoot) return cfg.dataRoot.replace(/^~(?=$|\/)/, home);
} catch {
// An unreadable config adds no location; the default data root still applies.
@@ -54,6 +55,21 @@ function configuredDataRoot(home) {
return null;
}
// Row 190: the stack instances in `home`, one per ~/.config/mosaic-NAME whose
// NAME passes the instance name rule (scripts/mosaic-config.mjs), so
// ~/.config/mosaic.bak adds nothing. Only the names are read here.
const INSTANCE_NAME = /^[a-z][a-z0-9]{1,15}$/;
function instanceNames(home) {
try {
return readdirSync(join(home, ".config"))
.filter((entry) => entry.startsWith("mosaic-") && INSTANCE_NAME.test(entry.slice("mosaic-".length)))
.map((entry) => entry.slice("mosaic-".length))
.sort();
} catch {
return [];
}
}
// Registrations under <dataRoot>/seats/<layout>/<seat>/registration.json. An
// unreadable one adds nothing; the data root itself is protected anyway.
function registrationsUnder(dataRoot) {
@@ -111,6 +127,14 @@ export class LiveSessionGuard {
protectedPaths.push({ path: join(h, ".mosaic-dev"), why: "the default data root" });
const configured = configuredDataRoot(h);
if (configured) protectedPaths.push({ path: configured, why: "the configured data root" });
// Each instance's config dir and ~/.mosaic-NAME whatever its config
// says, so a broken config can't drop them, then its config's dataRoot.
for (const name of instanceNames(h)) {
protectedPaths.push({ path: join(h, ".config", `mosaic-${name}`), why: `stack instance ${name}'s config dir` });
protectedPaths.push({ path: join(h, `.mosaic-${name}`), why: `stack instance ${name}'s default data root` });
const root = configuredDataRoot(h, name);
if (root) protectedPaths.push({ path: root, why: `stack instance ${name}'s configured data root` });
}
}
for (const d of dataRoots) protectedPaths.push({ path: d, why: "the data root" });
const roots = protectedPaths.filter((p) => p.why.includes("data root")).map((p) => p.path);
+6
View File
@@ -133,6 +133,12 @@ the copy, and retains the unit's restart, brake and stop behavior. Without
pinning. Status reads effective WorkingDirectory, including drop-ins, and
prints the tree and copy id or checkout path.
`--instance NAME` (row 190, any verb) renders `[email protected]`,
which sets `Environment=MOSAIC_CONFIG=` to NAME's config, and status reads
that config. Uninstall's active check covers only `mosaic-discord-NAME@*`.
Without the flag the unit is as before. See
[TOOLS.md, Stack instances](../../docs/TOOLS.md#stack-instances---instance-name-row-190).
Every live step requires Mos's go after the SetSpark freeze lifts. Re-pin:
make the new pushed copy with stack-copy.sh; run
`node <copy>/packages/discord/src/cli.mjs check <binding> --repo <copy> --offline`;
@@ -14,6 +14,7 @@ StartLimitBurst=5
Type=simple
WorkingDirectory=@REPO@
Environment=PATH=@PATH@
@CONFIG_ENV@
# A supervised run first clears a lock left by a crash or a reboot, and
# refuses with exit 3 while STOP is present or the binding is held. Exit 3
# is never retried: the brake belongs to the operator. This happens inside
+2 -1
View File
@@ -14,7 +14,8 @@ test('pinned Discord render/install and missing prerequisites; checkout bytes un
for(const line of r.stdout.split('\n').filter(l=>/^(ExecStart|WorkingDirectory)=/.test(l)))assert.ok(!line.includes(ROOT),line);
const legacy=s.run(['render']);assert.equal(legacy.status,0,legacy.stderr);
const nodeDir=dirname(process.execPath),originalPath='/usr/local/bin:/usr/bin:/bin',path=originalPath.split(':').includes(nodeDir)?originalPath:nodeDir+':'+originalPath;
const expected=readFileSync(join(ROOT,'packages/discord/systemd/[email protected]'),'utf8').replaceAll('@REPO@',ROOT).replaceAll('@PATH@',path);assert.equal(legacy.stdout,expected);
// Row 190: the default instance deletes the @CONFIG_ENV@ line.
const expected=readFileSync(join(ROOT,'packages/discord/systemd/[email protected]'),'utf8').replace('@CONFIG_ENV@\n','').replaceAll('@REPO@',ROOT).replaceAll('@PATH@',path);assert.equal(legacy.stdout,expected);
assert.equal(r.stdout.split('\n').filter(l=>!/^(ExecStart|WorkingDirectory)=/.test(l)).join('\n'),legacy.stdout.split('\n').filter(l=>!/^(ExecStart|WorkingDirectory)=/.test(l)).join('\n'));
const installed=s.run(['install',...args,'--dir',s.units,'--no-reload']);assert.equal(installed.status,0,installed.stderr);assert.equal(readFileSync(join(s.units,'[email protected]'),'utf8'),r.stdout);
for(const id of ['','short','b'.repeat(40)])assert.equal(s.run(['install','--copy',id,'--copies',s.copies,'--dir',join(s.root,'missing'),'--no-reload']).status,1);
+10
View File
@@ -122,6 +122,16 @@ allow-user must be the 64-character Authentik uid (decision 101). Status
names the copy and refactor drift. Files and directories are read-only;
copies are retained. Existing Console-only copies are not migrated or pruned.
With `--instance NAME` (row 190) every verb works on
`mosaic-webui-NAME.service`, which sets NAME's `MOSAIC_CONFIG`; install
checks NAME's `mosaic-bus-NAME@` units for the copy. render and install then
need `--port`, and 7330, 7332 or 7333 as `--port` or `--tester-port` exits 4
with port-reserved. For every instance, install refuses a `--port` or
`--tester-port` that another installed Console unit in the unit directory
already takes. That refusal is port-claimed, exit 1, and writes nothing. A
unit with no `--port` holds 7332. See
[TOOLS.md, Stack instances](../../docs/TOOLS.md#stack-instances---instance-name-row-190).
The single update and rollback sequence is
[TOOLS.md, Pinned stack update](../../docs/TOOLS.md#pinned-stack-update).
A bus restart ends managed sessions, so no PM turn may be in flight.
@@ -23,6 +23,7 @@ StartLimitBurst=5
Type=simple
WorkingDirectory=@REPO@
Environment=PATH=@PATH@
@CONFIG_ENV@
# The server binds 127.0.0.1 only. Usage, config and listen refusals
# (a port already in use included) exit 2 and are not retried; fix the
# flags or free the port, then start it again.
+4 -2
View File
@@ -146,8 +146,10 @@ test('copy refuses a commit off refactor, an unknown one, a worktree and a bad c
test('copy writes nothing when an archived path is missing at the commit', async (t) => {
const { dir, repo, script } = fixtureRepo(t);
const copies = join(dir, 'copies');
git(repo, 'rm', '-q', 'scripts/mosaic-config.mjs');
git(repo, 'commit', '-q', '-m', 'drop the config check');
// Row 190: the entry point runs scripts/mosaic-config.mjs to select the
// instance before it copies, so the missing path is one only the copy reads.
git(repo, 'rm', '-q', 'packages/webui/README.md');
git(repo, 'commit', '-q', '-m', 'drop the Console README');
git(repo,'update-ref','refs/remotes/origin/refactor',git(repo,'rev-parse','HEAD'));
const r = await run(dir, script, ['copy', 'refactor', '--copies', copies]);
assert.equal(r.status, 1);
+3 -2
View File
@@ -35,11 +35,12 @@ const TEMPLATE = readFileSync(join(REPO, 'packages', 'webui', 'systemd', 'mosaic
// The unit as the base script renders it (row 75): the template with its four
// placeholders filled in, nothing else. Row 83 adds --send and must leave
// every unit rendered without it byte for byte as this.
// every unit rendered without it byte for byte as this. Row 190: the
// default instance deletes the @CONFIG_ENV@ line, so the unit is unchanged.
function filled(flags, root = REPO) {
const base = '/usr/local/bin:/usr/bin:/bin';
const path = `:${base}:`.includes(`:${NODE_DIR}:`) ? base : `${NODE_DIR}:${base}`;
return TEMPLATE.replaceAll('@REPO@', root).replaceAll('@PATH@', path).replaceAll('@NODE@', process.execPath).replaceAll('@FLAGS@', flags);
return TEMPLATE.replace('@CONFIG_ENV@\n', '').replaceAll('@REPO@', root).replaceAll('@PATH@', path).replaceAll('@NODE@', process.execPath).replaceAll('@FLAGS@', flags);
}
// A loopback port with nothing listening, so status's /healthz request
+3 -1
View File
@@ -3,9 +3,11 @@
#
# Creates ~/.config/mosaic-dev/config.json (or $MOSAIC_CONFIG) only when
# absent. An existing configuration is validated, never modified.
# `--instance NAME` (row 190) creates ~/.config/mosaic-NAME/config.json
# instead, with the data root ~/.mosaic-NAME claimed by its instance.json.
# Normal run paths (build/hello/verify) deliberately do NOT auto-bootstrap:
# missing configuration is an error there, not something to invent.
set -euo pipefail
cd "$(dirname "$0")/.."
exec node scripts/mosaic-config.mjs bootstrap
exec node scripts/mosaic-config.mjs bootstrap "$@"
+31 -14
View File
@@ -19,12 +19,24 @@
# (MOSAIC_CLAUDE_BIN); without it, `command -v claude` here. The path is
# kept as given, so a symlink that tracks updates keeps tracking them.
#
# Exit codes: 0 ok, 1 operation failed, 4 usage.
# --instance NAME (row 190, any position) selects stack instance NAME: the
# unit is [email protected], it sets MOSAIC_CONFIG to
# ~/.config/mosaic-NAME/config.json, and its session env file sits in that
# config directory. Busy and drop-in checks then cover only NAME's bus units
# and the prefix directories systemd applies to them. See scripts/instance.sh.
#
# Exit codes: 0 ok, 1 operation failed, 3 the data root is claimed by another
# instance or not claimed (scripts/instance.sh), 4 usage.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMPLATE="$REPO/packages/cli/systemd/mosaic-bus.service.in"
UNIT_NAME="[email protected]"
USAGE="usage: scripts/bus-service.sh render [--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>"
# shellcheck source=scripts/instance.sh
. "$REPO/scripts/instance.sh"
instance_args "$@"
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
instance_select
UNIT_NAME="$BUS_TEMPLATE"
USAGE="usage: scripts/bus-service.sh [--instance NAME] render [--copy ID [--copies DIR]] [--pm] [--claude PATH] | install [--copy ID [--copies DIR]] [--pm] [--dir DIR] [--no-reload] [--claude PATH] | uninstall [--dir DIR] [--no-reload] | status <business>"
die() { echo "bus-service: $*" >&2; exit 1; }
usage() { echo "$USAGE" >&2; exit 4; }
@@ -76,7 +88,7 @@ render() {
case "$REPO" in *@*|*'|'*) die "repository path contains a character the template cannot carry: $REPO" ;; esac
local claude
claude="$(claude_line)" || exit 1
sed -e "s|@REPO@|$root|g" -e "s|@PATH@|$path|g" -e "s|@PM_FLAGS@|$pm_flags|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|" "${INSTANCE_SED[@]}" "$TEMPLATE"
}
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
@@ -99,9 +111,10 @@ parse_flags() {
# 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"
out="$(systemctl --user list-units --all --no-legend --plain "$BUS_GLOB" 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
# shellcheck disable=SC2053
[[ "$unit" == $BUS_GLOB ]] || 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
@@ -114,8 +127,12 @@ install_guard() {
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 "$directory"/mosaic-.service.d "$directory"/service.d; do
# * includes the empty instance, i.e. [email protected] (or
# mosaic-bus-NAME@.service.d). The globs come from scripts/instance.sh.
local glob dropdirs=()
# shellcheck disable=SC2206
for glob in "${BUS_DROPIN_GLOBS[@]}"; do dropdirs+=("$directory"/$glob); done
for dropdir in "${dropdirs[@]}"; 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
@@ -161,10 +178,10 @@ next, for one business (one per data root):
mkdir -m 0700 -p <dataRoot>/notify/<business> the notifier refuses a looser directory
write <dataRoot>/notify/<business>/notify.json, mode 0600:
{"notifyVersion": 1, "binding": "<discord binding>"} or "binding": null for no DMs
systemctl --user enable --now mosaic-bus@<business> start now and at login
systemctl --user status mosaic-bus@<business>
journalctl --user -u mosaic-bus@<business> -f the host's log
systemctl --user stop mosaic-bus@<business> SIGTERM; restartable
systemctl --user enable --now ${BUS_TEMPLATE%.service}<business> start now and at login
systemctl --user status ${BUS_TEMPLATE%.service}<business>
journalctl --user -u ${BUS_TEMPLATE%.service}<business> -f the host's log
systemctl --user stop ${BUS_TEMPLATE%.service}<business> SIGTERM; restartable
survive logout and reboot only with lingering on: loginctl enable-linger ${USER:-$(id -un)}
MSG
}
@@ -174,7 +191,7 @@ uninstall_unit() {
[ "$CLAUDE_SET" = 0 ] && [ -z "$COPY_ID" ] && [ "$PM" = 0 ] || usage
local target="$UNIT_DIR/$UNIT_NAME"
local out active
out="$(systemctl --user list-units --all --no-legend --plain 'mosaic-bus@*.service' 2>/dev/null)" || die "refusing: systemctl read failed; nothing was changed"
out="$(systemctl --user list-units --all --no-legend --plain "$BUS_GLOB" 2>/dev/null)" || die "refusing: systemctl read failed; nothing was changed"
active="$(awk '$3 != "inactive" && $3 != "failed" {print $1}' <<< "$out")"
[ -z "$active" ] || die "refusing: instance(s) still running: $active; Jason or Mos stops them first"
if [ -f "$target" ]; then
@@ -193,7 +210,7 @@ status_unit() {
[ $# -eq 1 ] || usage
local business="$1"
case "$business" in ''|*[!a-z0-9-]*) die "business id must be [a-z0-9-]+" ;; esac
local unit="mosaic-bus@$business.service"
local unit="${BUS_TEMPLATE%.service}$business.service"
echo "unit: $unit"
echo " enabled: $(systemctl --user is-enabled "$unit" 2>&1 || true)"
echo " active: $(systemctl --user is-active "$unit" 2>&1 || true)"
+20 -10
View File
@@ -14,12 +14,21 @@
# remove the unit; refuses while an instance is active
# status <binding> unit state, STOP and run.lock for one binding
#
# Exit codes: 0 ok, 1 operation failed, 4 usage.
# Every verb takes --instance NAME (row 190, scripts/instance.sh): the unit is
# then [email protected], it sets NAME's MOSAIC_CONFIG, and status
# reads NAME's config. Without it, everything is as before.
#
# Exit codes: 0 ok, 1 operation failed, 3 instance claim refused, 4 usage.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMPLATE="$REPO/packages/discord/systemd/[email protected]"
UNIT_NAME="[email protected]"
USAGE="usage: scripts/discord-service.sh render [--copy ID [--copies DIR]] | install [--copy ID [--copies DIR]] [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <binding>"
. "$REPO/scripts/instance.sh"
instance_args "$@"
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
instance_select
UNIT_NAME="$DISCORD_TEMPLATE"
UNIT_PREFIX="${DISCORD_TEMPLATE%.service}"
USAGE="usage: scripts/discord-service.sh [--instance NAME] render [--copy ID [--copies DIR]] | install [--copy ID [--copies DIR]] [--dir DIR] [--no-reload] | uninstall [--dir DIR] [--no-reload] | status <binding>"
die() { echo "discord-service: $*" >&2; exit 1; }
usage() { echo "$USAGE" >&2; exit 4; }
@@ -48,7 +57,7 @@ render() {
case ":$path:" in *":$node_dir:"*) ;; *) path="$node_dir:$path" ;; esac
case "$REPO" in *@*|*'|'*) die "repository path contains a character the template cannot carry: $REPO" ;; esac
local unit
unit="$(sed -e "s|@REPO@|$REPO|g" -e "s|@PATH@|$path|g" "$TEMPLATE")" || return 1
unit="$(sed -e "s|@REPO@|$REPO|g" -e "s|@PATH@|$path|g" "${INSTANCE_SED[@]}" "$TEMPLATE")" || return 1
if [ -n "$root" ]; then
local node
node="$(command -v node)"
@@ -101,10 +110,10 @@ install_unit() {
fi
cat <<MSG
next, for one binding:
systemctl --user enable --now mosaic-discord@<binding> start now and at login
systemctl --user status mosaic-discord@<binding>
journalctl --user -u mosaic-discord@<binding> -f the connector's log
systemctl --user stop mosaic-discord@<binding> SIGTERM; restartable
systemctl --user enable --now $UNIT_PREFIX<binding> start now and at login
systemctl --user status $UNIT_PREFIX<binding>
journalctl --user -u $UNIT_PREFIX<binding> -f the connector's log
systemctl --user stop $UNIT_PREFIX<binding> SIGTERM; restartable
scripts/discord.sh stop <binding> the brake: writes STOP, the unit stays down until STOP is removed
survive logout and reboot only with lingering on: loginctl enable-linger ${USER:-$(id -un)}
MSG
@@ -115,7 +124,7 @@ uninstall_unit() {
local target="$UNIT_DIR/$UNIT_NAME"
if [ "$RELOAD" = 1 ]; then
local active
active="$(systemctl --user list-units --no-legend --plain 'mosaic-discord@*' 2>/dev/null | awk '$3 == "active" || $3 == "activating" || $3 == "deactivating" {print $1}')"
active="$(systemctl --user list-units --no-legend --plain "$UNIT_PREFIX*" 2>/dev/null | awk '$3 == "active" || $3 == "activating" || $3 == "deactivating" {print $1}')"
[ -z "$active" ] || die "refusing: instance(s) still running: $(echo "$active" | tr '\n' ' ')stop them first"
fi
if [ -f "$target" ]; then
@@ -134,7 +143,7 @@ status_unit() {
[ $# -eq 1 ] || usage
local binding="$1"
case "$binding" in ''|*[!A-Za-z0-9_-]*) die "binding name must be [A-Za-z0-9_-]+" ;; esac
local unit="mosaic-discord@$binding.service"
local unit="$UNIT_PREFIX$binding.service"
echo "unit: $unit"
echo " enabled: $(systemctl --user is-enabled "$unit" 2>&1 || true)"
echo " active: $(systemctl --user is-active "$unit" 2>&1 || true)"
@@ -151,6 +160,7 @@ status_unit() {
echo " checkout: $working"
fi
local config="${XDG_CONFIG_HOME:-$HOME/.config}/mosaic-dev/config.json"
[ -z "$MOSAIC_UNIT_SUFFIX" ] || config="$MOSAIC_INSTANCE_CONFIG"
local data_root
data_root="$(node -e 'const c=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")); process.stdout.write(String(c.dataRoot||""))' "$config" 2>/dev/null || true)"
if [ -z "$data_root" ]; then
+142
View File
@@ -0,0 +1,142 @@
# Sourced, not run: stack instance selection for the shell entry points (row 190).
#
# instance_args "$@" take `--instance NAME` out of the arguments. Sets
# INSTANCE_FLAG (NAME, or empty) and INSTANCE_ARGS (the
# rest, in order). A second --instance or a missing
# NAME exits 4.
# instance_select run `mosaic-config.mjs instance [--instance NAME]`,
# which maps NAME to ~/.config/mosaic-NAME/config.json
# and checks the data root's instance.json claim, then
# set the names below. A refusal exits with the
# loader's code (3 claim or missing config, 4 name or
# mismatch) before the caller reads or writes anything.
#
# With no flag, every name is today's and MOSAIC_CONFIG is left as the caller
# had it. With --instance NAME (other than dev), MOSAIC_CONFIG is exported as
# NAME's config, so everything the entry point runs reads that config.
#
# BUS_TEMPLATE [email protected] | [email protected]
# BUS_GLOB 'mosaic-bus@*.service' | 'mosaic-bus-NAME@*.service'
# BUS_DROPIN_GLOBS unit-path globs whose .conf files can override the bus
# ExecStart: mosaic-bus@*.service.d for the default;
# mosaic-bus-NAME@*.service.d and the shared prefix
# mosaic-bus-.service.d for NAME; then mosaic-.service.d
# and service.d for both
# CONSOLE_UNIT mosaic-webui.service | mosaic-webui-NAME.service
# DISCORD_TEMPLATE [email protected] | [email protected]
# WRAPPER_NAME mosaic | mosaic-NAME
# SESSION_ENV %h/.config/mosaic-dev/secrets/claude-session.env
# | <config dir>/secrets/claude-session.env
# INSTANCE_FLAGS () or (--instance NAME), for the entry points this one runs
# INSTANCE_RESERVED ports a named instance's Console never takes: 7330 (the
# operator's bare Console), 7332 (the tunnel) and 7333
# (the default's tester); empty for the default
# INSTANCE_SED sed arguments that fill @CONFIG_ENV@ and @SESSION_ENV@
# in a unit template: the default deletes the
# @CONFIG_ENV@ line, so its render is byte-identical to
# the template before row 190; NAME gets a comment and
# Environment=MOSAIC_CONFIG=<config>, " [NAME]" after
# the Description, and the Console comment's "sets no
# MOSAIC_*" says it sets MOSAIC_CONFIG (Sage, N3)
#
# Also set: MOSAIC_INSTANCE (dev or NAME), MOSAIC_INSTANCE_CONFIG,
# MOSAIC_INSTANCE_CONFIG_DIR, MOSAIC_INSTANCE_DATA_ROOT (empty when no flag
# was given and the config is missing or invalid; the caller's own load
# refuses that as before) and MOSAIC_UNIT_SUFFIX (empty or -NAME).
INSTANCE_FLAG=""
INSTANCE_ARGS=()
INSTANCE_FLAGS=()
INSTANCE_RESERVED=()
instance_args() {
local seen=0
INSTANCE_FLAG=""
INSTANCE_ARGS=()
while [ $# -gt 0 ]; do
if [ "$1" = "--instance" ]; then
if [ "$seen" = 1 ] || [ $# -lt 2 ]; then
echo "instance: usage: --instance NAME, given once" >&2
exit 4
fi
INSTANCE_FLAG="$2"
seen=1
shift 2
else
INSTANCE_ARGS+=("$1")
shift
fi
done
}
instance_select() {
local here out
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [ -n "$INSTANCE_FLAG" ]; then
out="$(node "$here/mosaic-config.mjs" instance --instance "$INSTANCE_FLAG")" || exit $?
else
out="$(node "$here/mosaic-config.mjs" instance)" || exit $?
fi
eval "$out"
local s="$MOSAIC_UNIT_SUFFIX"
BUS_TEMPLATE="mosaic-bus$s@.service"
BUS_GLOB="mosaic-bus$s@*.service"
CONSOLE_UNIT="mosaic-webui$s.service"
DISCORD_TEMPLATE="mosaic-discord$s@.service"
WRAPPER_NAME="mosaic$s"
if [ -z "$s" ]; then
BUS_DROPIN_GLOBS=('mosaic-bus@*.service.d' 'mosaic-.service.d' 'service.d')
SESSION_ENV='%h/.config/mosaic-dev/secrets/claude-session.env'
INSTANCE_SED=(-e '/^@CONFIG_ENV@$/d' -e "s|@SESSION_ENV@|$SESSION_ENV|g")
return 0
fi
# systemd expands % and splits Environment= on spaces, and the sed
# expressions use |, so a named instance's paths keep to a plain set.
case "$MOSAIC_INSTANCE_CONFIG" in
*[!A-Za-z0-9/._+-]*)
echo "instance: config path contains a character a unit cannot carry: $MOSAIC_INSTANCE_CONFIG" >&2
exit 4 ;;
esac
export MOSAIC_CONFIG="${MOSAIC_CONFIG:-$MOSAIC_INSTANCE_CONFIG}"
INSTANCE_FLAGS=(--instance "$MOSAIC_INSTANCE")
INSTANCE_RESERVED=(7330 7332 7333)
BUS_DROPIN_GLOBS=("mosaic-bus$s@*.service.d" 'mosaic-bus-.service.d' 'mosaic-.service.d' 'service.d')
SESSION_ENV="$MOSAIC_INSTANCE_CONFIG_DIR/secrets/claude-session.env"
INSTANCE_SED=(
-e "s|^@CONFIG_ENV@\$|# Stack instance $MOSAIC_INSTANCE (row 190): the system config its processes read.\\nEnvironment=MOSAIC_CONFIG=$MOSAIC_INSTANCE_CONFIG|"
-e "s|@SESSION_ENV@|$SESSION_ENV|g"
-e "s|^\\(Description=.*\\)\$|\\1 [$MOSAIC_INSTANCE]|"
-e 's|The unit sets no MOSAIC_\*, CLAUDE\*, CODEX\* or PI_\* variable\.$|The unit sets MOSAIC_CONFIG and no other MOSAIC_*, CLAUDE*, CODEX* or PI_* variable.|'
)
}
# instance_port_holder DIR PORT: the installed Console unit in DIR, other than
# this instance's, whose ExecStart takes PORT as --port or --tester-port.
# A unit with no --port holds 7332, serve's default. Prints the unit and
# returns 0, or prints nothing and returns 1. Unit files are host
# configuration, so reading another instance's is not reading its state.
instance_port_holder() {
local dir="$1" want="$2" file unit line tok next port
want="$((10#$want))"
for file in "$dir"/mosaic-webui.service "$dir"/mosaic-webui-*.service; do
[ -f "$file" ] || continue
unit="${file##*/}"
[ "$unit" != "$CONSOLE_UNIT" ] || continue
line="$(sed -n 's/^ExecStart=//p' "$file" | tail -n 1)"
port=7332 next=""
local -a ports=() toks=()
read -r -a toks <<< "$line"
for tok in ${toks[@]+"${toks[@]}"}; do
case "$next" in
--port) [[ "$tok" =~ ^[0-9]{1,5}$ ]] && port="$((10#$tok))" ;;
--tester-port) [[ "$tok" =~ ^[0-9]{1,5}$ ]] && ports+=("$((10#$tok))") ;;
esac
next="$tok"
done
ports+=("$port")
for port in "${ports[@]}"; do
if [ "$port" = "$want" ]; then echo "$unit"; return 0; fi
done
done
return 1
}
+290 -65
View File
@@ -8,13 +8,30 @@
* validate Load and strictly validate; print resolved config on stdout.
* env Print shell-safe exports for launcher scripts:
* MOSAIC_DATA_ROOT, MOSAIC_PROVIDER, MOSAIC_MODEL.
* instance Print shell-safe exports naming the selected stack instance
* (row 190): MOSAIC_INSTANCE, MOSAIC_INSTANCE_CONFIG,
* MOSAIC_INSTANCE_CONFIG_DIR, MOSAIC_INSTANCE_DATA_ROOT and
* MOSAIC_UNIT_SUFFIX. scripts/instance.sh reads them.
*
* Config location: $MOSAIC_CONFIG or ~/.config/mosaic-dev/config.json
* Each operation takes an optional `--instance NAME`. NAME matches
* ^[a-z][a-z0-9]{1,15}$ and selects ~/.config/mosaic-NAME/config.json;
* `dev` is the default instance, so `--instance dev` is the same as no flag.
* Without the flag the config is $MOSAIC_CONFIG or
* ~/.config/mosaic-dev/config.json, as before.
*
* A named instance's data root carries <dataRoot>/instance.json, written by
* `bootstrap --instance NAME`, naming the instance. With the flag every
* operation refuses a root whose marker is missing or names another
* instance; `instance` without the flag refuses a root that a named
* instance claims.
*
* Exit codes:
* 0 success
* 2 configuration exists but is invalid (never modified by this tool)
* 3 configuration is missing for a read operation (validate/env)
* 3 configuration is missing for a read operation (validate/env/instance),
* or the data root's instance marker refuses the selection
* 4 usage: a bad instance name (instance-name-invalid), or --instance and
* $MOSAIC_CONFIG naming different files (instance-config-mismatch)
*
* Invariants (docs/plans/2026-09-02_atomic-mosaic-foundation.md):
* - Existing configuration is never overwritten.
@@ -33,16 +50,112 @@ const SUPPORTED_ENVIRONMENTS = new Set(["development", "production"]);
const SUPPORTED_BACKENDS = new Set(["docker"]);
const SUPPORTED_ADAPTERS = new Set(["pi", "mock"]); // mock: test-only, see adapters/README.md
const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/;
// Row 190: stack instance names. No hyphen, so systemd's prefix drop-ins
// for mosaic-bus-NAME@ stay a fixed list and no name prefixes another's units.
const INSTANCE_PATTERN = /^[a-z][a-z0-9]{1,15}$/;
const DEFAULT_INSTANCE = "dev";
const MARKER = "instance.json";
function fail(exitCode, message) {
process.stderr.write(`mosaic-config: ${message}\n`);
process.exit(exitCode);
class Failure extends Error {
constructor(exitCode, message) {
super(message);
this.exitCode = exitCode;
}
}
function configPath() {
return process.env.MOSAIC_CONFIG
? path.resolve(process.env.MOSAIC_CONFIG)
: path.join(os.homedir(), ".config", "mosaic-dev", "config.json");
function fail(exitCode, message) {
throw new Failure(exitCode, message);
}
function instancePath(name) {
return path.join(os.homedir(), ".config", `mosaic-${name}`, "config.json");
}
function realOrNull(file) {
try {
return fs.realpathSync(file);
} catch {
return null;
}
}
// The config file for a selection. Without --instance, as before. With it,
// NAME's file; a $MOSAIC_CONFIG beside it must be the same file.
function configPath(instance) {
if (instance === null) {
return process.env.MOSAIC_CONFIG
? path.resolve(process.env.MOSAIC_CONFIG)
: instancePath(DEFAULT_INSTANCE);
}
const named = instancePath(instance);
if (process.env.MOSAIC_CONFIG) {
const given = path.resolve(process.env.MOSAIC_CONFIG);
const same = given === named || (realOrNull(given) !== null && realOrNull(given) === realOrNull(named));
if (!same) {
fail(4, `instance-config-mismatch: --instance ${instance} names ${named}, but MOSAIC_CONFIG names ${given}`);
}
}
return named;
}
// Operation arguments: nothing, or `--instance NAME` once.
function parseArgs(args) {
if (args.length === 0) return null;
if (args.length !== 2 || args[0] !== "--instance") {
fail(4, `usage: mosaic-config.mjs ${operation ?? "OP"} [--instance NAME]`);
}
const name = args[1];
if (!INSTANCE_PATTERN.test(name)) {
fail(4, `instance-name-invalid: ${JSON.stringify(name)} must match ${INSTANCE_PATTERN}`);
}
return name;
}
function isNamed(instance) {
return instance !== null && instance !== DEFAULT_INSTANCE;
}
// The marker in a data root: null when absent, else its name. Anything else
// at the path refuses.
function readMarker(dataRoot) {
const file = path.join(dataRoot, MARKER);
let stat;
try {
stat = fs.lstatSync(file);
} catch (error) {
if (error.code === "ENOENT") return null;
fail(3, `instance-root-unclaimed: cannot read ${file} (${error.code})`);
}
if (!stat.isFile() || stat.isSymbolicLink()) {
fail(3, `instance-root-unclaimed: ${file} must be a regular, non-symbolic-link file`);
}
let marker;
try {
marker = JSON.parse(fs.readFileSync(file, "utf8"));
} catch (error) {
fail(3, `instance-root-unclaimed: ${file} is not a valid marker (${error.message})`);
}
if (!isPlainObject(marker) || Object.keys(marker).sort().join(",") !== "instanceVersion,name"
|| marker.instanceVersion !== 1 || typeof marker.name !== "string" || !INSTANCE_PATTERN.test(marker.name)) {
fail(3, `instance-root-unclaimed: ${file} is not a valid marker ({"instanceVersion":1,"name":NAME})`);
}
return marker.name;
}
// The claim check (row 190 D2). A named instance needs its own marker; the
// default needs none, or one naming dev.
function checkClaim(dataRoot, instance) {
const owner = readMarker(dataRoot);
if (isNamed(instance)) {
if (owner === null) {
fail(3, `instance-root-unclaimed: ${dataRoot} holds no ${MARKER} naming ${instance} (scripts/bootstrap.sh --instance ${instance} claims a new root)`);
}
if (owner !== instance) {
fail(3, `instance-root-mismatch: ${dataRoot} is claimed by instance ${owner}, not ${instance}`);
}
} else if (owner !== null && owner !== DEFAULT_INSTANCE) {
fail(3, `instance-root-mismatch: ${dataRoot} is claimed by instance ${owner}; select it with --instance ${owner}`);
}
}
function readRaw(file) {
@@ -182,10 +295,11 @@ async function validate(document, file) {
};
}
async function load(file) {
async function load(file, instance = null) {
const raw = readRaw(file);
if (raw === null) {
fail(3, `configuration not found: ${file} (run scripts/bootstrap.sh to create it)`);
const bootstrap = isNamed(instance) ? `scripts/bootstrap.sh --instance ${instance}` : "scripts/bootstrap.sh";
fail(3, `configuration not found: ${file} (run ${bootstrap} to create it)`);
}
let document;
try {
@@ -200,67 +314,178 @@ function shellQuote(value) {
return `'${String(value).replaceAll("'", `'\\''`)}'`;
}
const DEFAULT_CONFIG = {
configVersion: 1,
environment: "development",
dataRoot: path.join(os.homedir(), ".mosaic-dev"),
execution: {
backend: "docker",
provider: "zai",
model: "glm-5.3-flash",
},
};
function defaultConfig(dataRoot) {
return {
configVersion: 1,
environment: "development",
dataRoot,
execution: {
backend: "docker",
provider: "zai",
model: "glm-5.3-flash",
},
};
}
const DEFAULT_CONFIG = defaultConfig(path.join(os.homedir(), ".mosaic-dev"));
// 'wx': creation is exclusive; an existing file is never overwritten, and a
// symbolic link at the path is never followed.
function createExclusive(file, text, label = file) {
let fd;
try {
fd = fs.openSync(file, "wx", 0o644);
fs.writeFileSync(fd, text);
return true;
} catch (error) {
if (error.code === "EEXIST") return false;
fail(1, `unable to create ${label}: ${error.message}`);
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
}
async function bootstrapDefault(file) {
if (fs.existsSync(file)) {
await load(file); // validate only; never rewrite
process.stderr.write(`mosaic-config: configuration already present, validated without changes: ${file}\n`);
return;
}
fs.mkdirSync(path.dirname(file), { recursive: true });
if (!createExclusive(file, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`, "configuration")) {
await load(file);
return;
}
await load(file);
process.stderr.write(`mosaic-config: created default configuration: ${file}\n`);
}
// Row 190 D2: NAME's config dir (0700), its config, then the claimed data
// root. The root is checked before anything is written, so a refusal
// writes nothing.
async function bootstrapNamed(file, instance) {
if (fs.existsSync(file)) {
const resolved = await load(file, instance); // validate only; never rewrite
checkClaim(resolved.dataRoot, instance);
process.stderr.write(`mosaic-config: configuration already present, validated without changes: ${file}\n`);
return;
}
const dataRoot = path.join(os.homedir(), `.mosaic-${instance}`);
let entries = null;
try {
const stat = fs.lstatSync(dataRoot);
if (!stat.isDirectory()) fail(3, `instance-root-unclaimed: ${dataRoot} exists and is not a directory; nothing was written`);
entries = fs.readdirSync(dataRoot);
} catch (error) {
if (error instanceof Failure) throw error;
if (error.code !== "ENOENT") fail(3, `instance-root-unclaimed: cannot read ${dataRoot} (${error.code}); nothing was written`);
}
if (entries !== null && entries.length > 0 && readMarker(dataRoot) !== instance) {
fail(3, `instance-root-unclaimed: ${dataRoot} exists, is not empty and holds no ${MARKER} naming ${instance}; nothing was written`);
}
const configDir = path.dirname(file);
fs.mkdirSync(path.dirname(configDir), { recursive: true });
try {
fs.mkdirSync(configDir, { mode: 0o700 });
} catch (error) {
if (error.code !== "EEXIST") fail(1, `unable to create ${configDir}: ${error.message}`);
}
if (!createExclusive(file, `${JSON.stringify(defaultConfig(dataRoot), null, 2)}\n`)) {
const resolved = await load(file, instance);
checkClaim(resolved.dataRoot, instance);
return;
}
const resolved = await load(file, instance);
try {
fs.mkdirSync(resolved.dataRoot, { mode: 0o700 });
} catch (error) {
if (error.code !== "EEXIST") fail(1, `unable to create ${resolved.dataRoot}: ${error.message}`);
}
createExclusive(path.join(resolved.dataRoot, MARKER), `${JSON.stringify({ instanceVersion: 1, name: instance })}\n`);
checkClaim(resolved.dataRoot, instance);
process.stderr.write(`mosaic-config: created configuration for instance ${instance}: ${file}, data root ${resolved.dataRoot}\n`);
}
// Without --instance, a missing or invalid config is left to the caller's
// own load, as before; only a loaded config's root is checked.
async function selected(instance) {
const file = configPath(instance);
let dataRoot = "";
if (isNamed(instance)) {
dataRoot = (await load(file, instance)).dataRoot;
checkClaim(dataRoot, instance);
} else {
let resolved = null;
try {
resolved = readRaw(file) === null ? null : await load(file);
} catch (error) {
if (!(error instanceof Failure)) throw error;
}
if (resolved !== null) {
dataRoot = resolved.dataRoot;
checkClaim(dataRoot, instance);
}
}
const name = isNamed(instance) ? instance : DEFAULT_INSTANCE;
return [
`MOSAIC_INSTANCE=${shellQuote(name)}`,
`MOSAIC_INSTANCE_CONFIG=${shellQuote(path.resolve(file))}`,
`MOSAIC_INSTANCE_CONFIG_DIR=${shellQuote(path.dirname(path.resolve(file)))}`,
`MOSAIC_INSTANCE_DATA_ROOT=${shellQuote(dataRoot)}`,
`MOSAIC_UNIT_SUFFIX=${shellQuote(isNamed(instance) ? `-${instance}` : "")}`,
"",
].join("\n");
}
async function loadSelected(instance) {
const file = configPath(instance);
const resolved = await load(file, instance);
if (isNamed(instance)) checkClaim(resolved.dataRoot, instance);
return resolved;
}
const operation = process.argv[2];
const file = configPath();
switch (operation) {
case "bootstrap": {
if (fs.existsSync(file)) {
await load(file); // validate only; never rewrite
process.stderr.write(`mosaic-config: configuration already present, validated without changes: ${file}\n`);
try {
if (!["bootstrap", "validate", "env", "instance"].includes(operation)) {
fail(1, `unknown operation: ${JSON.stringify(operation ?? "")} (expected bootstrap | validate | env | instance)`);
}
const instance = parseArgs(process.argv.slice(3));
switch (operation) {
case "bootstrap": {
const file = configPath(instance);
if (isNamed(instance)) await bootstrapNamed(file, instance);
else await bootstrapDefault(file);
process.exit(0);
}
fs.mkdirSync(path.dirname(file), { recursive: true });
let fd;
try {
// 'wx': creation is exclusive; an existing file is never overwritten.
fd = fs.openSync(file, "wx", 0o644);
fs.writeFileSync(fd, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`);
} catch (error) {
if (error.code === "EEXIST") {
await load(file);
process.exit(0);
}
fail(1, `unable to create configuration: ${error.message}`);
} finally {
if (fd !== undefined) fs.closeSync(fd);
case "validate": {
const resolved = await loadSelected(instance);
process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
process.exit(0);
}
await load(file);
process.stderr.write(`mosaic-config: created default configuration: ${file}\n`);
process.exit(0);
}
case "validate": {
const resolved = await load(file);
process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
process.exit(0);
}
case "env": {
const resolved = await loadSelected(instance);
process.stdout.write(
[
`MOSAIC_DATA_ROOT=${shellQuote(resolved.dataRoot)}`,
`MOSAIC_PROVIDER=${shellQuote(resolved.execution.provider)}`,
`MOSAIC_MODEL=${shellQuote(resolved.execution.model)}`,
`MOSAIC_ADAPTER=${shellQuote(resolved.execution.adapter)}`,
"",
].join("\n"),
);
process.exit(0);
}
case "env": {
const resolved = await load(file);
process.stdout.write(
[
`MOSAIC_DATA_ROOT=${shellQuote(resolved.dataRoot)}`,
`MOSAIC_PROVIDER=${shellQuote(resolved.execution.provider)}`,
`MOSAIC_MODEL=${shellQuote(resolved.execution.model)}`,
`MOSAIC_ADAPTER=${shellQuote(resolved.execution.adapter)}`,
"",
].join("\n"),
);
process.exit(0);
case "instance": {
process.stdout.write(await selected(instance));
process.exit(0);
}
}
default:
fail(1, `unknown operation: ${JSON.stringify(operation ?? "")} (expected bootstrap | validate | env)`);
} catch (error) {
if (!(error instanceof Failure)) throw error;
process.stderr.write(`mosaic-config: ${error.message}\n`);
process.exit(error.exitCode);
}
+36 -9
View File
@@ -1,20 +1,22 @@
#!/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.
# wrapper and check take --instance NAME (row 190): the units mosaic-bus-NAME@ and mosaic-webui-NAME
# and the wrapper $BIN/mosaic-NAME, which sets NAME's MOSAIC_CONFIG. check needs --business for NAME.
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
BUSINESS=mosaic-stack BUSINESS_SET=0
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}"; }
usage() { echo 'usage: stack-copy.sh copy COMMIT [--copies DIR] [--allow-worktree] | wrapper ID [--instance NAME] [--copies DIR] [--bin DIR] | check [--instance NAME] [--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;;
--business) [ "$CMD" = check ] && [ $# -ge 2 ] || usage; BUSINESS="$2";BUSINESS_SET=1;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"; }
@@ -64,6 +66,7 @@ copy_commit() {
echo "copied: $target"
}
wrapper() {
[ -z "$MOSAIC_UNIT_SUFFIX" ] || { wrapper_instance; return; }
plain_path "$BIN" 'bin directory'
local root file lease tmp
root="$(copy_root)";file="$BIN/mosaic"
@@ -77,6 +80,22 @@ wrapper() {
chmod 0755 "$tmp";mv -f "$tmp" "$file";trap - EXIT
echo "wrapper: $root"
}
# Row 190: $BIN/mosaic-NAME. It creates the file or replaces one with its own marker, never another.
# NAME's config path is plain (instance.sh refuses any other), so it goes in single quotes as is.
wrapper_instance() {
plain_path "$BIN" 'bin directory'
local root file tmp
root="$(copy_root)";file="$BIN/$WRAPPER_NAME"
if [ -e "$file" ] || [ -L "$file" ]; then
[ -f "$file" ] && [ ! -L "$file" ] || die "refusing: wrapper is not regular, or a symlink: $file"
[ "$(sed -n '2p' "$file")" = "$INSTANCE_MARKER" ] || die "refusing: foreign wrapper (missing the instance $MOSAIC_INSTANCE marker on line 2): $file"
fi
tmp="$(mktemp "$BIN/.$WRAPPER_NAME.XXXXXX")"
trap 'rm -f "$tmp"' EXIT
printf '#!/usr/bin/env bash\n%s\n# CLI runs from pinned copy: %s\nexport MOSAIC_CONFIG='"'"'%s'"'"'\nexec "%s/scripts/mosaic" "$@"\n' "$INSTANCE_MARKER" "$root" "$MOSAIC_INSTANCE_CONFIG" "$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; }
@@ -84,29 +103,37 @@ unit_root() {
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-bus@*|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")"
local bus console cli=absent line path marker="$MARKER" wfile="$BIN/$WRAPPER_NAME"
[ -z "$MOSAIC_UNIT_SUFFIX" ] || marker="$INSTANCE_MARKER"
bus="$(unit_root "${BUS_TEMPLATE%.service}$BUSINESS.service")";console="$(unit_root "$CONSOLE_UNIT")"
if [ -f "$wfile" ] && [ ! -L "$wfile" ] && [ "$(sed -n '2p' "$wfile")" = "$marker" ]; then
line="$(tail -n 1 "$wfile")"
path="${line#exec \"}";path="${path%\" \"\$@\"}"
if [ "$line" = "exec \"$path\" \"\$@\"" ] && [[ "$path" == */scripts/mosaic ]];then cli="${path%/scripts/mosaic}";fi
# A named wrapper must also carry NAME's config, as wrapper_instance writes it.
if [ -n "$MOSAIC_UNIT_SUFFIX" ] && [ "$cli" != absent ] && [ "$(sed -n '4p' "$wfile")" != "export MOSAIC_CONFIG='$MOSAIC_INSTANCE_CONFIG'" ];then cli="$cli (MOSAIC_CONFIG mismatch)";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" ]
}
# Sourced (stack-release.sh): define the functions, run nothing.
[ "${BASH_SOURCE[0]}" = "$0" ] || return 0
. "$REPO/scripts/instance.sh"
instance_args "$@"
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
[ $# -gt 0 ] || usage
CMD="$1";shift
case "$CMD" in check|wrapper) instance_select;; *) [ -z "$INSTANCE_FLAG" ] || usage;; esac
INSTANCE_MARKER="# mosaic-cli wrapper, instance ${MOSAIC_INSTANCE:-dev} (row 190)"
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;;
check) parse "$@";[ -z "$MOSAIC_UNIT_SUFFIX" ] || [ "$BUSINESS_SET" = 1 ] || usage;check;;
-h|--help) usage 0;;
*) usage;;esac
+67 -31
View File
@@ -24,11 +24,11 @@ set -euo pipefail
cd "$(dirname "$0")/.."
REPO="$(pwd)"
source scripts/common.sh
source scripts/instance.sh
COPIES="${XDG_DATA_HOME:-$HOME/.local/share}/mosaic-stack/copies"
BIN="$HOME/.npm-global/bin"
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
CONSOLE_UNIT=mosaic-webui.service
TIMEOUT=60 WANT_PM=0 AFTER_HARNESS=0 MIGRATE=0 PLAN=0 RESTORE="" EVENTS="" TESTER_PORT=""
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0 NO_SEND=0 R_BACKUP="" R_PORT=""
@@ -41,7 +41,9 @@ usage: stack-release.sh adopt BUSINESS
stack-release.sh rollback BUSINESS [--restore ACTIVATION [--events N]] [--timeout S] [--plan]
stack-release.sh status BUSINESS
stack-release.sh rehearse ID BUSINESS --backup ACTIVATION|PATH --port N [--timeout S]
common options: --copies DIR, --bin DIR
common options: --copies DIR, --bin DIR, --instance NAME
--instance NAME (row 190) runs on stack instance NAME: its config, data root, units and the wrapper
mosaic-NAME. Without it, the default instance, as before.
--events N is the total of the per-table counts the post-backup-events refusal prints: events and
every table in COUNTED.
USAGE
@@ -69,7 +71,10 @@ refuse() {
# Reject tunnel/main ports and anything other than a whole high port (exit 4 via usage).
valid_tester_port() {
[[ "$1" =~ ^[0-9]{1,5}$ ]] && [ "$((10#$1))" -ge 1024 ] && [ "$((10#$1))" -le 65535 ] &&
[ "$((10#$1))" -ne 7332 ] && [ "$((10#$1))" -ne "$2" ]
[ "$((10#$1))" -ne 7332 ] && [ "$((10#$1))" -ne "$2" ] || return 1
# Row 190: a named instance's Console never takes 7330, 7332 or 7333.
local r
for r in "${INSTANCE_RESERVED[@]}"; do [ "$((10#$1))" -ne "$r" ] || return 1; done
}
parse_opts() {
@@ -416,16 +421,17 @@ capture() {
mkdir -m 0700 "$RS" 2>/dev/null || return 1
STEPS="$RS/steps.log"
local u d
for u in [email protected] "$CONSOLE_UNIT"; do
for u in "$BUS_TEMPLATE" "$CONSOLE_UNIT"; do
[ -f "$UNIT_DIR/$u" ] || { say "capture: no $UNIT_DIR/$u"; return 1; }
cp -p "$UNIT_DIR/$u" "$RS/$u" || return 1
done
for d in [email protected] "mosaic-bus@$BUSINESS.service.d" "$CONSOLE_UNIT.d"; do
# This instance's own directories only; the shared prefix ones are never captured (row 190).
for d in "$BUS_TEMPLATE.d" "$BUS_UNIT.d" "$CONSOLE_UNIT.d"; do
[ ! -d "$UNIT_DIR/$d" ] || cp -pr "$UNIT_DIR/$d" "$RS/$d" || return 1
done
[ -f "$BIN/mosaic" ] || { say "capture: no wrapper $BIN/mosaic"; return 1; }
cp -p "$BIN/mosaic" "$RS/wrapper" || return 1
stat -c %a "$BIN/mosaic" > "$RS/WRAPPER_MODE" || return 1
[ -f "$BIN/$WRAPPER_NAME" ] || { say "capture: no wrapper $BIN/$WRAPPER_NAME"; return 1; }
cp -p "$BIN/$WRAPPER_NAME" "$RS/wrapper" || return 1
stat -c %a "$BIN/$WRAPPER_NAME" > "$RS/WRAPPER_MODE" || return 1
for u in "$BUS_UNIT" "$CONSOLE_UNIT"; do show "$u" > "$RS/show-$u.txt" || return 1; done
sums "$RS" > "$RS/SHA256SUMS" || return 1
say "captured the rollback set: $RS"
@@ -458,9 +464,9 @@ install_stack() {
local id="$1" pm="$2" claude="$3"; shift 3
local -a pmflag=()
[ "$pm" = 0 ] || pmflag=(--pm)
scripts/bus-service.sh install --copy "$id" --copies "$COPIES" "${pmflag[@]}" --claude "$claude" >> "$STEPS" 2>&1 || { FAIL=install-bus; return 1; }
scripts/webui-service.sh install --copy "$id" --copies "$COPIES" "$@" >> "$STEPS" 2>&1 || { FAIL=install-console; return 1; }
scripts/stack-copy.sh wrapper "$id" --copies "$COPIES" --bin "$BIN" >> "$STEPS" 2>&1 || { FAIL=wrapper; return 1; }
scripts/bus-service.sh install "${INSTANCE_FLAGS[@]}" --copy "$id" --copies "$COPIES" "${pmflag[@]}" --claude "$claude" >> "$STEPS" 2>&1 || { FAIL=install-bus; return 1; }
scripts/webui-service.sh install "${INSTANCE_FLAGS[@]}" --copy "$id" --copies "$COPIES" "$@" >> "$STEPS" 2>&1 || { FAIL=install-console; return 1; }
scripts/stack-copy.sh wrapper "$id" "${INSTANCE_FLAGS[@]}" --copies "$COPIES" --bin "$BIN" >> "$STEPS" 2>&1 || { FAIL=wrapper; return 1; }
say "installed $id"
}
@@ -486,7 +492,7 @@ process.stdout.write(why);' -- "$out" "$BUSINESS" "$main" "$START_MS" 2>&1)" ||
node -e '
fetch(`http://127.0.0.1:${process.argv[1]}/healthz`,{signal:AbortSignal.timeout(5000)}).then((r)=>process.exit(r.status===200?0:1),()=>process.exit(1));' -- "$(console_port)" || return 1
GATE=copy-check
out="$(scripts/stack-copy.sh check --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>/dev/null)" || return 1
out="$(scripts/stack-copy.sh check "${INSTANCE_FLAGS[@]}" --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>/dev/null)" || return 1
# check passes when the three agree on one copy; this run needs it to be the target (row 166 item 3).
out="$(sed -n 's/^bus: //p' <<< "$out")"
[ "$out" = "$copy" ] || { GATE_WHY="the bus, Console and CLI agree on $out, not $copy"; return 1; }
@@ -533,10 +539,10 @@ gate() {
# Puts back the captured unit files, drop-ins and wrapper, checked against SHA256SUMS.
put_back() {
local u f rel tmp
for u in [email protected] "$CONSOLE_UNIT"; do
for u in "$BUS_TEMPLATE" "$CONSOLE_UNIT"; do
tmp="$(mktemp "$UNIT_DIR/.$u.XXXXXX")" && cp -p "$RS/$u" "$tmp" && mv -f "$tmp" "$UNIT_DIR/$u" || return 1
done
for u in [email protected] "mosaic-bus@$BUSINESS.service.d" "$CONSOLE_UNIT.d"; do
for u in "$BUS_TEMPLATE.d" "$BUS_UNIT.d" "$CONSOLE_UNIT.d"; do
[ -d "$RS/$u" ] || continue
while IFS= read -r -d '' f; do
rel="${f#"$RS/$u/"}"
@@ -545,12 +551,12 @@ put_back() {
tmp="$(mktemp "$UNIT_DIR/$u/.restore.XXXXXX")" && cp -p "$f" "$tmp" && mv -f "$tmp" "$UNIT_DIR/$u/$rel" || return 1
done < <(find "$RS/$u" -type f -print0)
done
tmp="$(mktemp "$BIN/.mosaic.XXXXXX")" && cp -p "$RS/wrapper" "$tmp" && chmod "$(cat "$RS/WRAPPER_MODE")" "$tmp" && mv -f "$tmp" "$BIN/mosaic" || return 1
for u in [email protected] "$CONSOLE_UNIT"; do
tmp="$(mktemp "$BIN/.$WRAPPER_NAME.XXXXXX")" && cp -p "$RS/wrapper" "$tmp" && chmod "$(cat "$RS/WRAPPER_MODE")" "$tmp" && mv -f "$tmp" "$BIN/$WRAPPER_NAME" || return 1
for u in "$BUS_TEMPLATE" "$CONSOLE_UNIT"; do
[ "$(sha256sum < "$UNIT_DIR/$u" | cut -d' ' -f1)" = "$(sum_of "$RS" "$u")" ] || return 1
done
[ "$(sha256sum < "$BIN/mosaic" | cut -d' ' -f1)" = "$(sum_of "$RS" wrapper)" ] || return 1
[ "$(stat -c %a "$BIN/mosaic")" = "$(cat "$RS/WRAPPER_MODE")" ] || return 1
[ "$(sha256sum < "$BIN/$WRAPPER_NAME" | cut -d' ' -f1)" = "$(sum_of "$RS" wrapper)" ] || return 1
[ "$(stat -c %a "$BIN/$WRAPPER_NAME")" = "$(cat "$RS/WRAPPER_MODE")" ] || return 1
systemctl --user daemon-reload || return 1
say "put back the unit files and the wrapper from $RS"
}
@@ -607,9 +613,9 @@ lock() {
instance_busy() {
local out unit load state rest
out="$(systemctl --user list-units --all --no-legend --plain 'mosaic-bus@*.service' 2>/dev/null)" || refuse instance-busy "systemctl --user list-units failed, so the bus state is unknown"
out="$(systemctl --user list-units --all --no-legend --plain "$BUS_GLOB" 2>/dev/null)" || refuse instance-busy "systemctl --user list-units failed, so the bus state is unknown"
while read -r unit load state rest; do
[[ "$unit" == mosaic-bus@*.service ]] || continue
[[ "$unit" == $BUS_GLOB ]] || continue
if [ "$unit" = "$BUS_UNIT" ]; then
case "$state" in activating|deactivating|reloading) refuse instance-busy "$unit is $state" ;; esac
else
@@ -621,14 +627,17 @@ instance_busy() {
# install_guard's drop-in scan (bus-service.sh), done before anything stops. A path it can't read
# refuses here, as install_guard would refuse it after the stop (row 166 item 1).
drop_in_override() {
local paths directory dropdir file content
local paths directory dropdir file content glob
local -a dropdirs
paths="$(systemd-analyze --user unit-paths 2>/dev/null)" && [ -n "$paths" ] || refuse drop-in-override "systemd unit paths are unknown"
while IFS= read -r directory; do
[ -n "$directory" ] || continue
case "$directory" in /*) ;; *) refuse drop-in-override "invalid systemd unit path $directory" ;; esac
[ -e "$directory" ] || [ -L "$directory" ] || continue
[ -d "$directory" ] && [ -r "$directory" ] && [ -x "$directory" ] || refuse drop-in-override "cannot inspect unit path $directory"
for dropdir in "$directory"/mosaic-bus@*.service.d "$directory"/mosaic-.service.d "$directory"/service.d; do
dropdirs=()
for glob in "${BUS_DROPIN_GLOBS[@]}"; do dropdirs+=("$directory"/$glob); done
for dropdir in "${dropdirs[@]}"; do
[ -e "$dropdir" ] || [ -L "$dropdir" ] || continue
[ -d "$dropdir" ] && [ -r "$dropdir" ] && [ -x "$dropdir" ] || refuse drop-in-override "cannot inspect drop-ins $dropdir"
for file in "$dropdir"/*.conf; do
@@ -684,6 +693,7 @@ console_for_target() {
[ "$PLAN" = 1 ] || usage
local other=""
[ "$main" = 7332 ] || other=" and the Console's --port $main"
[ "${#INSTANCE_RESERVED[@]}" = 0 ] || other+=" and instance $MOSAIC_INSTANCE's reserved ${INSTANCE_RESERVED[*]}"
check_refuse tester-port-invalid "--tester-port $TESTER_PORT is not a whole port from 1024 to 65535 other than 7332$other"
fi
[ "$supported" = 1 ] || check_refuse tester-port-target "$TO's Console does not support --tester-port"
@@ -813,10 +823,30 @@ proof_file_problem() {
[ "$uid" = "$(id -u)" ] || echo "is owned by uid $uid, not $(id -u), the uid this script runs as"
}
# Row 190: the target Console's --port (7332 when absent) and --tester-port against every other
# installed Console unit in UNIT_DIR, the default's and each named instance's.
ports_unclaimed() {
local main=7332 i holder
local -a ports=()
for ((i = 0; i < ${#RUN_CONSOLE[@]}; i++)); do
case "${RUN_CONSOLE[i]}" in
--port) main="${RUN_CONSOLE[i + 1]:-7332}" ;;
--tester-port) ports+=("${RUN_CONSOLE[i + 1]:-}") ;;
esac
done
for i in "$main" "${ports[@]}"; do
[[ "$i" =~ ^[0-9]{1,5}$ ]] || continue
if holder="$(instance_port_holder "$UNIT_DIR" "$i")"; then
check_refuse port-claimed "port $i is taken by $UNIT_DIR/$holder"
fi
done
}
# The item 3 checks after the pointer, shared by activate and rollback, for target $TO.
precheck() {
check_target
console_for_target
ports_unclaimed
instance_busy
drop_in_override
read_units || refuse unit-flags-drift "cannot read $BUS_UNIT or $CONSOLE_UNIT"
@@ -857,7 +887,7 @@ do_adopt() {
lock
[ ! -f "$POINTER" ] || { read_pointer; FROM="$P_COPY"; refuse already-adopted "$POINTER exists (copy $P_COPY)"; }
local out
out="$(scripts/stack-copy.sh check --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>&1)" || refuse check-failed "stack-copy.sh check did not pass:"$'\n'"$out"
out="$(scripts/stack-copy.sh check "${INSTANCE_FLAGS[@]}" --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>&1)" || refuse check-failed "stack-copy.sh check did not pass:"$'\n'"$out"
TO="$(sed -n 's/^bus: //p' <<< "$out")"
TO="${TO#"$COPIES/"}"
read_units || refuse check-failed "cannot read $BUS_UNIT or $CONSOLE_UNIT"
@@ -970,12 +1000,13 @@ plan() {
plan_commits
[ "$P_PENDING" = 0 ] || echo "pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM"
[ "$RUN_PENDING" = 0 ] || echo "pm: the bus starts without --pm and the PM stays pending; next: Jason's plugins set of harness, then pm-on"
echo "bus: scripts/bus-service.sh install --copy $TO --copies $COPIES$([ "$RUN_PM" = 0 ] || echo ' --pm') --claude $P_CLAUDE"
local flags="${INSTANCE_FLAGS[*]:+${INSTANCE_FLAGS[*]} }"
echo "bus: scripts/bus-service.sh install $flags--copy $TO --copies $COPIES$([ "$RUN_PM" = 0 ] || echo ' --pm') --claude $P_CLAUDE"
[ -z "$DROP_TESTER" ] || echo "drop $DROP_TESTER; the target Console does not support it"
echo "$PROOF_PLAN"
echo "$SEND_PLAN"
echo "Console: scripts/webui-service.sh install --copy $TO --copies $COPIES${RUN_CONSOLE[*]:+ ${RUN_CONSOLE[*]}}"
echo "CLI: scripts/stack-copy.sh wrapper $TO --copies $COPIES --bin $BIN"
echo "Console: scripts/webui-service.sh install $flags--copy $TO --copies $COPIES${RUN_CONSOLE[*]:+ ${RUN_CONSOLE[*]}}"
echo "CLI: scripts/stack-copy.sh wrapper $TO $flags--copies $COPIES --bin $BIN"
local r
for r in "${PLAN_REFUSE[@]}"; do echo "refuses $r"; done
[ "${#PLAN_REFUSE[@]}" = 0 ] || exit 3
@@ -1047,7 +1078,7 @@ do_activate() {
pointer_for_business
run_pm
[ "$P_PENDING" = 0 ] || check_refuse pm-pending "the pointer is pending (activate --pm-after-harness); run pm-on or rollback first"
if [ "$PLAN" = 1 ]; then check_target; console_for_target; plugin_checks; plan; exit 0; fi
if [ "$PLAN" = 1 ]; then check_target; console_for_target; ports_unclaimed; plugin_checks; plan; exit 0; fi
precheck
plugin_checks
new_activation
@@ -1059,7 +1090,7 @@ do_rollback() {
pointer_for_business
TO="$(from_log previous)" || refuse no-previous "the log names no earlier copy than $P_COPY"
run_pm
if [ "$PLAN" = 1 ]; then check_target; console_for_target; plugin_checks; plan; exit 0; fi
if [ "$PLAN" = 1 ]; then check_target; console_for_target; ports_unclaimed; plugin_checks; plan; exit 0; fi
precheck
plugin_checks
[ "$P_SCHEMA" = "$T_SCHEMA" ] || [ -n "$RESTORE" ] || refuse schema-differs "the store is at $P_SCHEMA and $TO expects $T_SCHEMA; pass --restore ACTIVATION"
@@ -1153,7 +1184,7 @@ do_pm_on() {
capture || refuse capture-failed "could not write the rollback set $RS; nothing was stopped"
trap 'pm_on_fail signal' INT TERM HUP
stop_bus || pm_on_fail stop-timeout
scripts/bus-service.sh install --copy "$P_COPY" --copies "$COPIES" --pm --claude "$P_CLAUDE" >> "$STEPS" 2>&1 || pm_on_fail install-bus
scripts/bus-service.sh install "${INSTANCE_FLAGS[@]}" --copy "$P_COPY" --copies "$COPIES" --pm --claude "$P_CLAUDE" >> "$STEPS" 2>&1 || pm_on_fail install-bus
say "installed $BUS_UNIT for $P_COPY with --pm"
start_bus || pm_on_fail start
gate "$P_COPY" 1 || pm_on_fail "$GATE"
@@ -1173,7 +1204,7 @@ do_status() {
open="$(interrupted | paste -sd' ' -)"
[ -z "$open" ] || echo "interrupted run: $open (started, no final line; Jason or Mos settles it)"
echo "check:"
if scripts/stack-copy.sh check --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>&1 | sed 's/^/ /'; then echo " result: pass"; else echo " result: FAIL"; fi
if scripts/stack-copy.sh check "${INSTANCE_FLAGS[@]}" --copies "$COPIES" --bin "$BIN" --business "$BUSINESS" 2>&1 | sed 's/^/ /'; then echo " result: pass"; else echo " result: FAIL"; fi
[ -n "$P_COPY" ] || return 0
echo "bus status:"
"$COPIES/$P_COPY/scripts/mosaic" bus status 2>&1 | sed 's/^/ /' || true
@@ -1437,6 +1468,7 @@ do_rehearse() {
local -a reserved=(7332 7333 "$(console_port)")
for ((i = 0; i < ${#P_CONSOLE[@]}; i++)); do [ "${P_CONSOLE[i]}" != --tester-port ] || reserved+=("${P_CONSOLE[i + 1]:-}"); done
for i in "${reserved[@]}"; do [ "$((10#$R_PORT))" != "$i" ] || r_abort port-reserved "--port $R_PORT is a live Console port (7332, 7333 and the pointer's: ${reserved[*]:2})"; done
if i="$(instance_port_holder "$UNIT_DIR" "$R_PORT")"; then r_abort port-reserved "--port $R_PORT is $UNIT_DIR/$i's Console port"; fi
r_port_free "$R_PORT" || r_abort port-in-use "127.0.0.1:$R_PORT is in use; the rehearsal takes no other port"
r_pass port "127.0.0.1:$R_PORT is free"
case "$R_BACKUP" in /*) R_BK="$R_BACKUP" ;; *) R_BK="$DATA/backups/stack/$R_BACKUP" ;; esac
@@ -1489,6 +1521,9 @@ do_rehearse() {
# --- Dispatch ---
# Row 190: --instance NAME may come anywhere; instance.sh takes it out and sets the names.
instance_args "$@"
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
[ $# -gt 0 ] || usage
OP="$1"; shift
ID=""
@@ -1501,13 +1536,14 @@ esac
[[ "$BUSINESS" =~ ^[a-z0-9][a-z0-9-]*$ ]] || usage
parse_opts "$@"
COPIES_DIR="$COPIES"
instance_select
load_config
DATA="$MOSAIC_DATA_ROOT"
STATE="$DATA/state"
POINTER="$STATE/stack-active.json"
LOG="$STATE/stack-activation-log.jsonl"
STORE="$DATA/bus/bus.sqlite"
BUS_UNIT="mosaic-bus@$BUSINESS.service"
BUS_UNIT="${BUS_TEMPLATE%.service}$BUSINESS.service"
FROM="" TO="" ACT="" RS="" POST="" BACKUP="" TAKEN="" RESTORED="" STEPS="" COUNT="" UNCLEAN="" FAIL=""
STORE_SCHEMA="" START_EPOCH="" START_MS=0 GATE="" GATE_WHY="" S_CHANGED=""
P_COPY="" P_PM=0 P_PENDING=0 P_SCHEMA="" P_CLAUDE="" P_CONSOLE=() RUN_PM=0 RUN_PENDING=0 RUN_CONSOLE=() DROP_TESTER="" PROOF_PLAN="" PROOF_STEP="" SEND_PLAN="" SEND_STEP="" PLAN_REFUSE=()
+5
View File
@@ -170,6 +170,10 @@ check "scripts/discord.sh reload without a binding exits 4" $?
# --- the service unit: rendered from the template, never touching systemd here ---
UNITS="$SANDBOX/units"
# Row 190: discord-service.sh reads the selected config first, so these runs name a scratch one
# that does not exist (the default's names, no data root) instead of ~/.config/mosaic-dev.
CALLER_MOSAIC_CONFIG="${MOSAIC_CONFIG-}"
export MOSAIC_CONFIG="$SANDBOX/absent/config.json"
scripts/discord-service.sh >/dev/null 2>&1
[ $? -eq 4 ]
check "scripts/discord-service.sh without a command exits 4" $?
@@ -246,6 +250,7 @@ HOME="$SANDBOX/home" XDG_CONFIG_HOME="$SANDBOX/config-home" PATH="$SANDBOX/fake-
check "status names the copy from fake systemctl effective cwd" $?
grep -qF "checkout: $REPO_DIR" "$SANDBOX/install.1" && grep -qF -- '--copy' "$SANDBOX/install.1"
check "checkout install explicitly recommends --copy" $?
if [ -n "$CALLER_MOSAIC_CONFIG" ]; then export MOSAIC_CONFIG="$CALLER_MOSAIC_CONFIG"; else unset MOSAIC_CONFIG; fi
# Offline wrapper entrypoint. Only a synthetic token/binding/config are used.
node - "$SANDBOX" <<'EOF_OFFLINE'
+68 -17
View File
@@ -65,12 +65,33 @@
# --no-reload: a unit rewritten under a running Console would take the new
# ExecStart at its next Restart=on-failure, with nobody starting it.
#
# Exit codes: 0 ok, 1 operation failed, 4 usage.
# --instance NAME (row 190, any position) selects stack instance NAME: the
# unit is mosaic-webui-NAME.service and sets MOSAIC_CONFIG to
# ~/.config/mosaic-NAME/config.json, its bus units are mosaic-bus-NAME@,
# and render and install need --port, which may not be 7330, 7332 or 7333
# (exit 4, port-reserved). For every instance, install refuses a --port or
# --tester-port that another installed Console unit in the unit directory
# already takes (port-claimed). copy refuses --instance (exit 4), as
# stack-copy.sh copy does: every instance shares the copies. See
# scripts/instance.sh.
#
# Exit codes: 0 ok, 1 operation failed, 3 the data root is claimed by another
# instance or not claimed (scripts/instance.sh), 4 usage.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMPLATE="$REPO/packages/webui/systemd/mosaic-webui.service.in"
UNIT_NAME="mosaic-webui.service"
USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-worktree] | render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR]
# shellcheck source=scripts/instance.sh
. "$REPO/scripts/instance.sh"
instance_args "$@"
set -- ${INSTANCE_ARGS[@]+"${INSTANCE_ARGS[@]}"}
if [ "${1:-}" = copy ] && [ -n "$INSTANCE_FLAG" ]; then
echo "webui-service: copy takes no --instance; every instance shares the copies" >&2
exit 4
fi
instance_select
UNIT_NAME="$CONSOLE_UNIT"
BUS_UNIT_PREFIX="${BUS_TEMPLATE%.service}"
USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-worktree] | [--instance NAME] render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR]
FLAGS: [--port N] [--business ID] [--board URL] [--sso [--send] | --public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-port N] [--proof-header NAME --proof-file PATH]
--tester-port: 1024..65535, different from --port and never 7332; loopback tester listener.
--allow-worktree is for the tests only: copy and install otherwise refuse outside the canonical checkout."
@@ -86,6 +107,7 @@ ALLOW_WORKTREE=0
RELOAD=1
DIR_FLAGS=0
PORT=7332
PORT_SET=0
TESTER_PORT=""
TESTER_SET=0
BUSINESS=""
@@ -108,7 +130,7 @@ parse_flags() {
--copies) [ $# -ge 2 ] || usage; COPIES="$2"; COPY_FLAGS=1; shift 2 ;;
--allow-worktree) ALLOW_WORKTREE=1; shift ;;
--no-reload) RELOAD=0; DIR_FLAGS=1; shift ;;
--port) [ $# -ge 2 ] || usage; PORT="$2"; SERVE_SET=1; shift 2 ;;
--port) [ $# -ge 2 ] || usage; PORT="$2"; PORT_SET=1; SERVE_SET=1; shift 2 ;;
--tester-port) [ $# -ge 2 ] || usage; TESTER_PORT="$2"; TESTER_SET=1; SERVE_SET=1; shift 2 ;;
--business) [ $# -ge 2 ] || usage; BUSINESS="$2"; SERVE_SET=1; shift 2 ;;
--board) [ $# -ge 2 ] || usage; BOARD="$2"; SERVE_SET=1; shift 2 ;;
@@ -127,6 +149,31 @@ parse_flags() {
[ "$TESTER_SET" = 0 ] || tester_port_ok
}
# Row 190: a named instance's Console names its own port, outside the
# default's ports. Called by render and install only.
instance_ports_ok() {
[ "${#INSTANCE_RESERVED[@]}" -gt 0 ] || return 0
[ "$PORT_SET" = 1 ] || { echo "webui-service: stack instance $MOSAIC_INSTANCE needs --port; it has no default port" >&2; exit 4; }
local p r
for p in "$PORT" ${TESTER_PORT:+"$TESTER_PORT"}; do
[[ "$p" =~ ^[0-9]{1,5}$ ]] || continue
for r in "${INSTANCE_RESERVED[@]}"; do
[ "$((10#$p))" != "$r" ] || { echo "webui-service: port-reserved: $p is reserved for the default instance (${INSTANCE_RESERVED[*]})" >&2; exit 4; }
done
done
}
# Row 190: refuse a port another installed Console unit already takes.
ports_unclaimed() {
local p holder
for p in "$PORT" ${TESTER_PORT:+"$TESTER_PORT"}; do
[[ "$p" =~ ^[0-9]{1,5}$ ]] || continue
if holder="$(instance_port_holder "$UNIT_DIR" "$p")"; then
die "refusing port-claimed: port $p is taken by $UNIT_DIR/$holder; nothing was changed"
fi
done
}
tester_port_usage() {
echo "webui-service: --tester-port must be 1024..65535, different from --port and never 7332: $TESTER_PORT" >&2
exit 4
@@ -259,7 +306,7 @@ render() {
local flags
flags="$(serve_flags)" || exit 1
[ -z "$COPY_ID" ] || flags="--checkout $REPO $flags"
sed -e "s|@REPO@|$root|g" -e "s|@PATH@|$path|g" -e "s|@NODE@|$node|g" -e "s|@FLAGS@|$flags|g" "$TEMPLATE"
sed -e "s|@REPO@|$root|g" -e "s|@PATH@|$path|g" -e "s|@NODE@|$node|g" -e "s|@FLAGS@|$flags|g" "${INSTANCE_SED[@]}" "$TEMPLATE"
}
# The unit's active state from list-units, printed when it is anything but
@@ -279,16 +326,17 @@ running() {
# only with a bus on that same copy: install refuses unless every bus unit
# it would call has an ExecStart path inside the copy. With --business the
# unit is [email protected]; without it, every mosaic-bus@ unit systemd
# has loaded, and none loaded refuses. Like the state read, a failed call
# has loaded, and none loaded refuses. A named instance reads its own
# mosaic-bus-NAME@ units only. Like the state read, a failed call
# refuses with one line.
bus_on_copy() {
local copy="$1" units unit out load paths p working
if [ -n "$BUSINESS" ]; then
units="mosaic-bus@$BUSINESS.service"
units="$BUS_UNIT_PREFIX$BUSINESS.service"
else
out="$(systemctl --user list-units --all --no-legend --plain 'mosaic-bus@*.service' 2>/dev/null)" || die "refusing: systemctl --user list-units --all --no-legend --plain mosaic-bus@*.service failed, so the bus unit is unknown; nothing was changed"
units="$(printf '%s\n' "$out" | awk '$1 ~ /^mosaic-bus@.+\.service$/ {print $1}')"
[ -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"
out="$(systemctl --user list-units --all --no-legend --plain "$BUS_GLOB" 2>/dev/null)" || die "refusing: systemctl --user list-units --all --no-legend --plain $BUS_GLOB failed, so the bus unit is unknown; nothing was changed"
units="$(printf '%s\n' "$out" | awk -v p="$BUS_UNIT_PREFIX" 'index($1, p) == 1 && length($1) > length(p) + 8 && substr($1, length($1) - 7) == ".service" {print $1}')"
[ -n "$units" ] || die "refusing: no $BUS_UNIT_PREFIX unit is loaded, and the Console on the copy $copy needs its bus on that copy; name the business with --business ID"
fi
for unit in $units; do
out="$(systemctl --user show "$unit" -p LoadState -p WorkingDirectory -p ExecStart 2>/dev/null)" || die "refusing: systemctl --user show $unit failed, so the bus unit is unknown; nothing was changed"
@@ -308,13 +356,15 @@ install_unit() {
parse_flags "$@"
canonical
[ -n "$COPY_ID" ] || die "install needs --copy ID, a copy made with: scripts/webui-service.sh copy COMMIT"
instance_ports_ok
local unit
unit="$(render)" || exit 1
ports_unclaimed
bus_on_copy "$COPIES/$COPY_ID"
# N1: a running unit would take the new ExecStart at its next restart.
local state
state="$(running)" || exit 1
[ -z "$state" ] || die "refusing: $UNIT_NAME is running ($state); Jason or Mos stops it first (systemctl --user stop mosaic-webui), then install, then start it"
[ -z "$state" ] || die "refusing: $UNIT_NAME is running ($state); Jason or Mos stops it first (systemctl --user stop ${UNIT_NAME%.service}), then install, then start it"
mkdir -p "$UNIT_DIR"
local target="$UNIT_DIR/$UNIT_NAME" tmp
tmp="$(mktemp "$UNIT_DIR/.$UNIT_NAME.XXXXXX")"
@@ -336,11 +386,11 @@ install_unit() {
this script starts nothing: the Console holds the human's capability. Jason
starts and restarts it, or Mos under lead decision 102 (sends then enter the
trail as Jason); no other agent does:
systemctl --user enable --now mosaic-webui start now and at login
systemctl --user status mosaic-webui
journalctl --user -u mosaic-webui -f the Console's log
systemctl --user stop mosaic-webui SIGTERM; restartable
scripts/webui-service.sh status unit state and /healthz
systemctl --user enable --now ${UNIT_NAME%.service} start now and at login
systemctl --user status ${UNIT_NAME%.service}
journalctl --user -u ${UNIT_NAME%.service} -f the Console's log
systemctl --user stop ${UNIT_NAME%.service} SIGTERM; restartable
scripts/webui-service.sh ${INSTANCE_FLAGS[*]:+${INSTANCE_FLAGS[*]} }status unit state and /healthz
survive logout and reboot only with lingering on: loginctl enable-linger ${USER:-$(id -un)}
MSG
}
@@ -352,7 +402,7 @@ uninstall_unit() {
# N3: checked with --no-reload too, which skips only daemon-reload.
local state
state="$(running)" || exit 1
[ -z "$state" ] || die "refusing: $UNIT_NAME is still running ($state); Jason or Mos stops it first (systemctl --user stop mosaic-webui)"
[ -z "$state" ] || die "refusing: $UNIT_NAME is still running ($state); Jason or Mos stops it first (systemctl --user stop ${UNIT_NAME%.service})"
if [ -f "$target" ]; then
rm -f "$target"
echo "removed: $target"
@@ -439,6 +489,7 @@ case "$cmd" in
parse_flags "$@"
[ "$DIR_FLAGS" = 0 ] && [ "$ALLOW_WORKTREE" = 0 ] || usage
{ [ -n "$COPY_ID" ] || [ "$COPY_FLAGS" = 0 ]; } || usage
instance_ports_ok
render ;;
install) install_unit "$@" ;;
uninstall) uninstall_unit "$@" ;;