Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
537a02392b | ||
|
|
ceaf93837c | ||
|
|
088087ac96 | ||
|
|
a0fbefd48d | ||
|
|
f24f2cd40d | ||
|
|
9ba31b5b3f | ||
|
|
9db578ff54 | ||
|
|
8eb9ddd12c | ||
|
|
ae3c36f248 | ||
|
|
a2af7ab495 | ||
|
|
aa1bd209e3 | ||
|
|
ace27dc5eb | ||
|
|
a4a42dbac2 | ||
|
|
a31f1a0f8b | ||
|
|
992650232f | ||
|
|
a7661ada06 | ||
|
|
f871cb3096 | ||
|
|
0bbd23c619 | ||
|
|
0217b71c8f | ||
|
|
c6f528a32f | ||
|
|
96f243fafc | ||
|
|
c862699352 | ||
|
|
48ebee0ffe | ||
|
|
66bdd84b8f | ||
|
|
6966164f0c |
@@ -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
|
||||
|
||||
@@ -19,6 +19,12 @@ not fleet seats. Keep changes in `/mnt/storage/src/mosaic-stack`; do not modify
|
||||
| Launchpad | Builder, MVP T1 row 192 (Q175 SSO phase 1) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
|
||||
| Morgana | Builder, MVP T1 row 193 (human users and RBAC) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
|
||||
| Honker | Builder, MVP T1 row 194 (agent lifecycle) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
|
||||
| Gander | Builder, the human boundary across uids (P19 333, #1694) | T3 session (Codex, gpt-6.1-sol), started by Sage under mos-0977 | none, T3 only |
|
||||
| Herb | Builder, a second gate lane (#1695) | T3 session (Codex, gpt-6.1-sol), started by Sage under mos-0977 | none, T3 only |
|
||||
| Bushroot | Builder, retire the control board writer on 7331 (#1696) | T3 session (Claude, claude-haiku-5-5), started by Sage under mos-1008 | none, T3 only |
|
||||
| Megavolt | Builder, MVP T1 item 6 (agent credentials and git identity, #1697) | T3 session (Claude, Opus 5.5), started by Sage under mos-0974/0977 | none, T3 only |
|
||||
| Quackerjack | Builder, federation R5 and R2 (#414) | T3 session (Claude, claude-haiku-5-5), started by Sage under mos-1023 | none, T3 only |
|
||||
| Gizmoduck | Federation design (#414) and builder of FED R1 | T3 session (Claude, Opus 5.5, thread 9312a896) | none, T3 only |
|
||||
| Webby | Reviewer: Case 2 designs, rows 192 and 194 | T3 session (Claude, Opus 5.5, thread 03086006), started by Mos (mos-0981) | none, T3 only |
|
||||
| Duckworth | Reviewer: federation designs, row 193 | T3 session (Claude, Opus 5.5, thread ffceafec), started by Mos (mos-0977) | none, T3 only |
|
||||
| Sage | Project lead: coordination, review, integration; earlier DYOR strategy records retained | T3 session (Claude Code); Pi launcher `zai/glm-5.3:high` retained | `agents/sage/launch.sh` |
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Bushroot
|
||||
|
||||
Builder seat, Claude Haiku 5.5, started by Sage under mos-1008 item 2 (a low-level model under Q177) for the row
|
||||
that retires the control board writer on 7331 (#1696).
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `bushroot/*`
|
||||
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -3,5 +3,5 @@
|
||||
Reviewer seat, Claude Opus 5.5, started by Mos (mos-0977, thread ffceafec). Reviews federation designs and MVP T1 row 193.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until row 195 settles per-agent identity, it pushes only its own `duckworth/*` branches
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `duckworth/*` branches
|
||||
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Gander
|
||||
|
||||
Builder seat, Codex gpt-6.1-sol, started by Sage under mos-0977 item 5 (more Codex Sol rows) for the human
|
||||
boundary row (P19 333, #1694, mos-0989).
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `gander/*`
|
||||
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Gizmoduck
|
||||
|
||||
Design and builder seat, Claude Opus 5.5, a T3 thread (9312a896). Author of the federation design
|
||||
(`agents/gizmoduck/work/federation/DESIGN.md` on `gizmoduck/federation-design`, #414). Builds FED R1, the
|
||||
access.json loader and validator, under Mos's order mos-1023 item 1.
|
||||
|
||||
No launcher in this directory. The seat works in its own worktree and has no credentials of its own; until
|
||||
the credentials row settles per-agent identity, it pushes only its own `gizmoduck/*` branches through the
|
||||
repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -0,0 +1,8 @@
|
||||
# Herb
|
||||
|
||||
Builder seat, Codex gpt-6.1-sol, started by Sage under mos-0977 item 5 (more Codex Sol rows) for a second gate lane
|
||||
design row (#1695, mos-0977 item 6).
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `herb/*`
|
||||
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -3,5 +3,5 @@
|
||||
Builder seat, Claude Opus 5.5, started by Sage for MVP T1 row 194 (agent lifecycle, #1692) under mos-0974 and mos-0977.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until row 195 settles per-agent identity, it pushes only its own `honker/*` branches
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `honker/*` branches
|
||||
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
# PRD 1.7 draft: the chief of staff's hire and fire authority, the coder's push to its fork, and agent
|
||||
# host access through the credential store
|
||||
|
||||
Draft by Honker for row 194 (#1692), under Mos's ruling mos-1017 (through Sage, sage-1365). Not approved.
|
||||
It changes nothing until Jason approves it as a `prd.approve` decision in the Console. Sage opens that
|
||||
decision and sends Mos its id. Base: `docs/prd/mosaic-stack.md` version 1.6 at 53bb59462.
|
||||
|
||||
## What it changes
|
||||
|
||||
Row 194 adds `role.hire` and `role.fire` to the vocabulary, and row 199 adds `git.push.remote`. Under
|
||||
REQ-WAIT-3 all three are hard waits, so today Jason resolves every hire, every fire and every push to a
|
||||
remote. This version routes hire and fire for the chief of staff within fixed bounds, so the chief hires
|
||||
and fires as needed and the user's per-agent forbid still holds. It routes `git.push.remote` for the coder
|
||||
only (Mos, mos-1028 item 3). It adds agent host access with sudo through the credential store for
|
||||
agents with a grant (Q183, Q184, Vikunja #424, due 2026-10-27). One `prd.approve` decision covers it all
|
||||
(Vikunja #420).
|
||||
|
||||
When this version lands, a reviewed commit:
|
||||
|
||||
1. adds `role.hire`, `role.fire` and `git.push.remote` to `ROUTED` in
|
||||
`packages/business/src/vocabulary.mjs` and in `packages/bus/src/broker.mjs`.
|
||||
2. adds `role.hire` and `role.fire` to `roles/chief-of-staff.json` withinRole, and `git.push.remote` to
|
||||
`roles/coder.json` withinRole. No other role file gets `git.push.remote`.
|
||||
3. switches the broker's check on `instance.hire` and `instance.fire` from a cited decision to the
|
||||
`reason` form, as `role.end` works for REQ-ROLE-2, and makes `publish`'s `decision` optional for a
|
||||
within-role class (row 199 D9 step 1).
|
||||
4. states, for a `git.push.remote` with no decision, where the source commit comes from: the latest work
|
||||
commit the bus recorded for the coder's task. The check still matches it against the work ref tip and
|
||||
the push sends it by oid (row 199, duckworth-0022 N9).
|
||||
5. adds the host credential grant check and the host access it allows (REQ-CRED-3, Vikunja #424).
|
||||
|
||||
Every other check stays as rows 194 and 199 build it. Filling `hireable` stays a separate reviewed role
|
||||
file commit in `roles/`.
|
||||
|
||||
## Text for the PRD
|
||||
|
||||
Status line, appended to the current paragraph:
|
||||
|
||||
> Version 1.7 routes the chief of staff's hire and fire actions and the coder's push to its own fork,
|
||||
> and adds agent host access through the credential store, approved by Jason in decision
|
||||
> <prd.approve decision id>.
|
||||
|
||||
Source, a new bullet above "Version 1.6 source":
|
||||
|
||||
> - Version 1.7 source, TARGET.md line 66: "Chief of staff agent has hire/fire authority for agents as
|
||||
> needed; unless user forbids per agent". And Jason through Mos (mos-1067), DECISIONS Q183, in part: "I will
|
||||
> still want to allow infra agent(s) on my .53 instance to access other servers, etc. However, not all
|
||||
> agents will need to have that access. I envision a credential store within Mosaic Stack that works
|
||||
> as some sort of variable for the agent to call. The system will enforce access to the credentials to
|
||||
> keep the agents sandboxed with proper security. I need to be able to trust my agents with assisting
|
||||
> me. This will require sudo access to several systems. If we can utilize the credential store in
|
||||
> Mosaic Stack and allow agents to perform their jobs with NOPASSWD sudo on various machines, I am a
|
||||
> happy man." DECISIONS Q184, on whether that is in place by 10-27: "Q5: by 10.27"
|
||||
|
||||
Under "Added in version 1.2", after REQ-COS-2:
|
||||
|
||||
> Added in version 1.7:
|
||||
>
|
||||
> - **REQ-COS-3.** The chief of staff hires and fires role instances without a decision each time. Hiring
|
||||
> adds a role instance to the organization file and lists it for launch. Firing ends the instance's
|
||||
> runs and removes it from the launch list. It stays in the organization file. Only the user deletes
|
||||
> an instance. These actions are `role.hire` and `role.fire`. They leave the hard waits and route
|
||||
> under this requirement, within these bounds:
|
||||
> - The chief acts only in its own organization.
|
||||
> - The chief hires only role definitions that need no credentials, until a later version adds
|
||||
> credentialed roles.
|
||||
> - The chief hires only the role kinds its role file lists. Changing that list is a reviewed commit.
|
||||
> - The chief hires only a role whose every action is on a fixed list of plain work actions: sending
|
||||
> messages, writing records, asking for review, updating its assigned tasks, and the scope and
|
||||
> working-push actions of an assigned task. A role holding any other action, such as launch, end,
|
||||
> organization, naming, hire, review verdict or merge authority, or any action added later, is never
|
||||
> hired. So the chief never hires a chief, never picks its own reviewers or merge gate, and the author
|
||||
> is never the reviewer. This holds whatever the role file's list says. Adding to that list is a PRD
|
||||
> change.
|
||||
> - The chief may set a hired instance's model and narrow its limits, and nothing else about it.
|
||||
> - The user can forbid per agent. The user marks an instance so the chief can't fire it, end its runs
|
||||
> or change its model, and marks role kinds the chief can't hire. Only the user changes these marks, through the
|
||||
> WebUI or the CLI.
|
||||
> - The kill switch stops every hire and fire while it is on.
|
||||
> - Every hire and fire is logged with the chief's instance, its run and the reason.
|
||||
|
||||
Under "Landing", after REQ-LAND-1:
|
||||
|
||||
> Added in version 1.7:
|
||||
>
|
||||
> - **REQ-LAND-2.** A coder pushes its own work to its own fork. The action is `git.push.remote`.
|
||||
> - What it pushes: one commit, by its oid, never a ref name. That commit is the latest work commit the
|
||||
> bus recorded for the coder's task, and the coder made that push. A decision that authorizes a push
|
||||
> names that commit, and it doesn't authorize any other.
|
||||
> - Where it lands: only the branch `refs/heads/<its Gitea user>/<branch>`. The host builds that
|
||||
> name, the agent never does. A push never forces.
|
||||
> - Which remote: the agent's own fork of the project's upstream. The business file names the upstream,
|
||||
> and the host never contacts it. For each push the host builds the fork's address over https from
|
||||
> the upstream's host, port and repository name and the agent's own Gitea user. The history check,
|
||||
> the push and the credential match all use that address. So the agent's own user owns the receiving
|
||||
> repository on every push, and the agent never names it.
|
||||
> - The host refuses an upstream on a host or port other than the agent's Gitea, and an upstream the
|
||||
> agent's own user owns. It refuses a fork that is missing, empty or doesn't share the project's
|
||||
> history. Gitea's push-to-create stays off, so a push never creates a repository.
|
||||
> - Who may: the action routes under REQ-DEC-2 and is granted within-role to the coder role only. No
|
||||
> other role file grants it.
|
||||
> - Before version 1.7 the action was a hard wait under REQ-WAIT-3, so each push needed a decision
|
||||
> the human resolved, and that decision named the commit.
|
||||
>
|
||||
> Every push is logged with the remote, the ref, the commit and, when one was given, the decision.
|
||||
|
||||
Under "Credentials", after REQ-CRED-2:
|
||||
|
||||
> Added in version 1.7:
|
||||
>
|
||||
> - **REQ-CRED-3.** An agent with a grant can run commands with sudo on another host. The agent asks
|
||||
> for the host credential by name. The stack checks that a grant exists for that agent and that host
|
||||
> before the credential is used. The agent never sees the key or any other secret value (REQ-CRED-1,
|
||||
> REQ-VAR-2). An agent without a grant for that host is refused. Not every agent gets a grant. Who
|
||||
> grants a host credential, and how, is set by the #424 design, which Jason approves.
|
||||
|
||||
REQ-WAIT-3, appended:
|
||||
|
||||
> `role.hire` and `role.fire` were added as hard waits and route under REQ-COS-3 since version 1.7.
|
||||
> `git.push.remote` was added as a hard wait and routes under REQ-LAND-2 since version 1.7.
|
||||
|
||||
## Notes for the reviewer and for Mos
|
||||
|
||||
- REQ-COS-1 (1.2) already says the chief "hires and terminates role instances". REQ-COS-3 gives that
|
||||
sentence its actions and bounds. It doesn't widen REQ-COS-1.
|
||||
- The forbid now names what r3 called "replace": ending a kept instance's run with a reason and changing
|
||||
its model (webby-0010 N2). A user-resolved decision still ends a kept run (row 194 D2.4).
|
||||
- The fixed list is `HIRE_ALLOWED` in row 194 D2.3, an allowlist since r4 (webby-0010 B1). r3's text
|
||||
named the refused authorities instead, which let merge-gate and any later action through.
|
||||
- The kill switch is row 191's. If row 191 hasn't landed when this version is approved, the bound still
|
||||
holds through row 194's chain on its candidate.
|
||||
- REQ-LAND-2 follows row 199's design r6 (4d3007ec9 on megavolt/row199, approved by Duckworth in
|
||||
duckworth-0027: D9 to D11, D9a, D9c, Q-M1 and Q-M2, and the oid binding from mos-1034 item 5) and
|
||||
Megavolt's wording in megavolt-0005, megavolt-0008 and megavolt-0014, through Sage (sage-1377,
|
||||
sage-1380, sage-1387). The fork bullet answers duckworth B5 and B6 and webby-0011 N1. Commit-email
|
||||
checks, the refusal codes and the URL parse rules (D9c) are host checks with tests in row 199, not
|
||||
bounds, so they stay out of the text (megavolt-0008).
|
||||
- Once the routing lands and the decision is optional for the within-role class, the commit is the task's
|
||||
latest recorded push at check time. It is still checked against the work ref tip and pushed by oid, so
|
||||
the binding holds without a decision (megavolt-0008). Step 4 of "When this version lands" puts that in
|
||||
the routing commit (duckworth-0022 N9, sage-1387).
|
||||
- REQ-LAND-2 and REQ-COS-3 meet at the coder. Coder declares Gitea credentials, so REQ-COS-3's
|
||||
credentials bound keeps the chief from hiring one until a later version lifts it. `git.push.remote` isn't
|
||||
on REQ-COS-3's fixed list either, so a coder holding it stays unhireable until a PRD version adds it
|
||||
(row 194 D2.3).
|
||||
- Adding to the fixed action list is a PRD change (mos-1035 item 3, sage-1395). The role file's
|
||||
`hireable` list stays a reviewed commit in `roles/`.
|
||||
- The credentials bound names no row number, so the text doesn't go stale when row 199 lands. A later
|
||||
version lifts it.
|
||||
- REQ-CRED-3 states only what Jason said in Q183 and Q184 (sage-1422). It names the outcome and the
|
||||
check, not how the stack reaches the host. Proxying the ssh and a signing socket are both designs, and
|
||||
the #424 design picks one (duckworth-0038 B1).
|
||||
- Open point: Q183 doesn't say who grants host access or how, so the text leaves it open and defines no
|
||||
mechanism. The #424 design settles it, Jason approves that design, and Duckworth reviews it because it
|
||||
gives agents sudo on hosts (Q184, Mos's reading, duckworth-0038 B2). The build is row 210 (#1707,
|
||||
owner Gizmoduck), a new row on top of row 199 (megavolt-0019, sage-1430).
|
||||
- This draft follows the PRD's own style: plain words, no semicolons in new sentences, and the status
|
||||
paragraph never edited in place except to append.
|
||||
@@ -3,5 +3,5 @@
|
||||
Builder seat, Claude Opus 5.5, started by Sage for MVP T1 row 192 (Q175 SSO phase 1, #1690) under mos-0974 and mos-0977.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until row 195 settles per-agent identity, it pushes only its own `launchpad/*` branches
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `launchpad/*` branches
|
||||
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Megavolt
|
||||
|
||||
Builder seat, Claude Opus 5.5, started by Sage under mos-0974 and mos-0977 item 5 for MVP T1 item 6, the
|
||||
credential store per agent with its own git identity (#1697).
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `megavolt/*`
|
||||
branches through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
@@ -3,5 +3,5 @@
|
||||
Builder seat, Claude Opus 5.5, started by Sage for MVP T1 row 193 (human users and RBAC, #1691) under mos-0974 and mos-0977.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until row 195 settles per-agent identity, it pushes only its own `morgana/*` branches
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `morgana/*` branches
|
||||
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Quackerjack
|
||||
|
||||
Builder seat, Claude Haiku 5.5 at high effort, started by Sage under mos-1023 item 1 and Q177 for federation
|
||||
rows R5 (`mosaic access list, grant, revoke`) and R2 (the `federation` config key), per Gizmoduck's design
|
||||
section 7 (#414).
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own
|
||||
`quackerjack/*` branches through the repository credential helper (mos-0981), never `refactor`, `main` or
|
||||
another seat's branch.
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
n=0
|
||||
for p in /proc/[0-9]*; do
|
||||
c=$(tr '\0' ' ' < $p/cmdline 2>/dev/null) || continue
|
||||
case "$c" in *shell-snapshots*|*chrome-devtools-mcp*|*sage-busy-now*) continue;; esac
|
||||
if echo "$c" | grep -qE 'node --test|gate\.sh|mutant|scripts/test-[a-z-]+\.sh|docker compose'; then echo "BUSY ${p#/proc/}: ${c:0:160}"; n=$((n+1)); fi
|
||||
done
|
||||
[ $n -eq 0 ] && echo CLEAR
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
set -o pipefail
|
||||
# Landing gate template (2026-10-14): sed the G= and OUT= lines and this comment.
|
||||
# Sequential suites, each teed to its own log; stops at the first failure.
|
||||
G=/mnt/storage/scratch/SET-ME-wt
|
||||
OUT=/mnt/storage/scratch/SET-ME-out
|
||||
cd "$G" || exit 9
|
||||
mkdir -p "$OUT" || exit 9 # a missing OUT failed the row 142 gate at step 1 (2026-10-15)
|
||||
# test-task.sh live cases run `docker compose` from $G, so without a name the project is "wt"
|
||||
# and leaves wt_default behind (row 142 gate, 2026-10-15). Name it per gate dir and remove it on exit.
|
||||
export COMPOSE_PROJECT_NAME="$(basename "$(dirname "$G")")"
|
||||
trap 'docker network rm "${COMPOSE_PROJECT_NAME}_default" >/dev/null 2>&1 && echo "removed network ${COMPOSE_PROJECT_NAME}_default"' EXIT
|
||||
# A gate tree without the node_modules link fails cli tests with ENOENT (2026-10-13).
|
||||
[ -d "$G/node_modules" ] || { echo "STOP no node_modules in $G"; exit 8; }
|
||||
echo "base $(git rev-parse --short HEAD) dirty $(git status --short | grep -vc node_modules) DOCKER_HOST=${DOCKER_HOST:-unset}"
|
||||
run() { local name="$1" dir="$2" cmd="$3"
|
||||
(cd "$G/$dir" && bash -c "$cmd") > "$OUT/$name.log" 2>&1; local rc=$?
|
||||
echo "$name rc=$rc $(grep -E '^ℹ (pass|fail) [0-9]+' "$OUT/$name.log" | tr '\n' ' ') | $(tail -n1 "$OUT/$name.log")"
|
||||
[ $rc -eq 0 ] || { echo "STOP $name"; exit $rc; }; }
|
||||
# No pkg-queue step: scripts/test-queue.sh runs packages/queue/tests/ itself (same 156 tests; CI debate item 2a, Vikunja #274).
|
||||
for d in packages/*/; do p=$(basename "$d"); [ -d "$d/tests" ] || continue; [ "$p" = queue ] && continue
|
||||
run "pkg-$p" "packages/$p" 'node --test tests/*.test.mjs'; done
|
||||
for s in scripts/test-*.sh; do n=$(basename "$s" .sh); run "script-${n#test-}" . "bash $s"; done
|
||||
echo GATE_PASSED
|
||||
@@ -0,0 +1,36 @@
|
||||
# Row 189 gate timing (for row 197, Herb)
|
||||
|
||||
Sage, 2026-10-20T19:25:04Z. Gate on 0c177e9ef (base 0217b71c8), one lane. The slot was mine. Seats may have run single test files at the same time, which the slot rules allow, and I did not record that.
|
||||
Start 2026-10-20T19:09:37Z, end 2026-10-20T19:20:36Z. Total wall time: 659 s.
|
||||
|
||||
Wall time per suite is the gap between one suite's log mtime and the one before it (the first suite starts at start.txt), so it includes node start-up. node's duration_ms is listed where the suite prints it. Scripts print no duration.
|
||||
|
||||
| Suite | Wall s | duration_ms s | Ended (UTC) |
|
||||
|---|---|---|---|
|
||||
| pkg-bus | 3 | 3.8 | 19:09:40 |
|
||||
| pkg-business | 3 | 2.2 | 19:09:43 |
|
||||
| pkg-cli | 388 | 388.5 | 19:16:11 |
|
||||
| pkg-control-board | 1 | 0.8 | 19:16:12 |
|
||||
| pkg-conversation | 35 | 35.4 | 19:16:47 |
|
||||
| pkg-discord | 3 | 2.6 | 19:16:50 |
|
||||
| pkg-harness | 12 | 11.6 | 19:17:02 |
|
||||
| pkg-ledger | 13 | 13.5 | 19:17:15 |
|
||||
| pkg-mosaic | 3 | 2.3 | 19:17:18 |
|
||||
| pkg-plugins | 0 | 0.3 | 19:17:18 |
|
||||
| pkg-project | 1 | 1.2 | 19:17:19 |
|
||||
| pkg-runs | 1 | 0.8 | 19:17:20 |
|
||||
| pkg-seat | 6 | 6.2 | 19:17:26 |
|
||||
| pkg-tasks | 1 | 1.2 | 19:17:27 |
|
||||
| pkg-webui | 58 | 57.9 | 19:18:25 |
|
||||
| script-auth | 1 | - | 19:18:26 |
|
||||
| script-conductor | 5 | - | 19:18:31 |
|
||||
| script-config | 2 | - | 19:18:33 |
|
||||
| script-discord | 8 | - | 19:18:41 |
|
||||
| script-extension-package | 1 | - | 19:18:42 |
|
||||
| script-foundation | 40 | - | 19:19:22 |
|
||||
| script-land-work | 11 | - | 19:19:33 |
|
||||
| script-queue | 21 | - | 19:19:54 |
|
||||
| script-release | 11 | - | 19:20:05 |
|
||||
| script-task | 31 | - | 19:20:36 |
|
||||
|
||||
Source: /mnt/storage/scratch/sage-g189/out/*.log mtimes and gate.log. The cli suite is about 59% of the run, and webui plus conversation add about 14%.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Row 189: `stack-release.sh activate --no-send`
|
||||
|
||||
Gitea #1687. Vikunja P19 #413 (Q175 interim, #412). Brief: docs/plans/2026-10-20_activate-no-send.md.
|
||||
Rulings: mos-0972 (the Console runs without `--send` until Q175 phase 2), mos-0973 (go; Sage builds,
|
||||
Dewey reviews; gate is the cli suite plus every scripts/test-*.sh). Base: def2e7932 (refactor, the
|
||||
brief commit on 9ae8c73ac).
|
||||
|
||||
## What it does
|
||||
|
||||
`activate ID BUSINESS --no-send` drops `--send` from `RUN_CONSOLE` in a new `send_for_target`, which
|
||||
`console_for_target` calls after `proof_for_target`. `install_stack` renders the unit from
|
||||
`RUN_CONSOLE` and `write_pointer` writes the pointer from it, so both lose the flag and `flags_drift`
|
||||
stays quiet afterwards. Only `activate` takes the flag; the parser sends every other op to usage
|
||||
(exit 4).
|
||||
|
||||
`check_target` lets a `--no-send` activate name the pointer's copy only when the pointer's Console
|
||||
flags hold `--send`. That run is a live-copy re-pin, the same path row 187 uses for the proof pair:
|
||||
the plan prints the live-copy note, the run stops both units, backs up the stopped store and starts
|
||||
them again. A plain same-copy activate, or a same-copy `--no-send` once `--send` is gone, still
|
||||
refuses `already-active`.
|
||||
|
||||
`--plan` prints one `send:` line after the `proof:` line, for activate and rollback:
|
||||
|
||||
- `send: dropped; --no-send was given`
|
||||
- `send: off; --no-send was given and the pointer holds no --send`
|
||||
- `send: carried` (the pointer's `--send` goes on unchanged)
|
||||
- `send: off`
|
||||
|
||||
The steps log gets `drop --send; --no-send was given`, or `--no-send was given and the pointer holds no
|
||||
--send, so there is nothing to drop`.
|
||||
|
||||
Rollback and pm-on carry the pointer's flags as they are, so once `--no-send` has run, nothing adds
|
||||
`--send` back. There is no re-enable flag (mos-0973). Q175 phase 2 changes the Console flags in its
|
||||
own row.
|
||||
|
||||
docs/TOOLS.md: `[--no-send]` in the usage block and one paragraph after the proof paragraph.
|
||||
|
||||
## Tests (packages/cli/tests/stack-release.test.mjs, T55)
|
||||
|
||||
1. Seeded preview pointer with `--send --tester-port 7333`: a same-copy `activate B acme --no-send`
|
||||
passes. The pointer and the unit ExecStart lack `--send` and keep the other flags in order, status
|
||||
says the unit flags match, the steps log has the drop line, the log line has from = to = B and the
|
||||
backup sum equals the stopped store's, and the units stop and start in order.
|
||||
2. Plain same-copy activate, with `--tester-port` and with `--no-proof`, refuses `already-active`. So does
|
||||
a same-copy `--no-send` once `--send` is gone, and on a pointer that never held it.
|
||||
3. The four `send:` lines, the live-copy note on the same-copy plan only, and the Console line right
|
||||
after `send:` without `--send`. The run on a pointer with no `--send` logs the nothing-to-drop line
|
||||
and no drop line.
|
||||
4. After `--no-send` on C1, a rollback to B leaves `--send` out of the pointer and the unit, the steps
|
||||
log never names `--send`, and status matches. pm-on after `activate --pm-after-harness --no-send`
|
||||
keeps it off.
|
||||
5. `--no-send` on rollback (with and without `--plan`), pm-on, status, adopt and rehearse is a usage
|
||||
error with the usage text, nothing written, started or logged. The usage text shows `[--no-send]`.
|
||||
6. A pointer holding both the proof pair and `--send`: the plans for `--no-proof`, `--no-send` and both
|
||||
name what each drops and carries. `--no-send` keeps the pair, `--no-send` with a new pair replaces it,
|
||||
`--no-proof` keeps `--send`, and both together leave neither. Status matches after each.
|
||||
|
||||
`planTail` gains `'send: off'` after `'proof: off'`, because every plan now prints the line. T36 takes
|
||||
`out.slice(-n)` around `planTail`, so its four slice counts grow by one.
|
||||
|
||||
## Mutants
|
||||
|
||||
mutants.sh in this packet, run on the candidate. Each mutates scripts/stack-release.sh and runs T55
|
||||
plus T1 and T2.
|
||||
|
||||
- M1: the same-copy exception ignores `--no-send`.
|
||||
- M2: the same-copy exception allows `--no-send` without `--send` in the pointer.
|
||||
- M3: with `--no-send`, the pointer is written from `P_CONSOLE`, so it keeps `--send` while the unit
|
||||
drops it.
|
||||
- M4: rollback re-adds `--send`.
|
||||
- M5: the parser accepts `--no-send` for rollback.
|
||||
|
||||
Results: mutants-run.log, all five killed (M1 5 failures, M2 2, M3 7, M4 2, M5 2).
|
||||
|
||||
## Deviations from the brief
|
||||
|
||||
- The brief's line numbers were taken before row 163 landed. The candidate corrects them to the
|
||||
9ae8c73ac lines (check_target :249-256, flags_drift :646-657, console_for_target :664-693, adopt
|
||||
:828).
|
||||
- Test 5 also covers rehearse and `rollback --plan`, which the brief did not list.
|
||||
|
||||
## Outbound calls
|
||||
|
||||
None. The change edits an array before the existing unit render and pointer write. Every test uses
|
||||
the stand-in systemctl, units, stores and data root the file already builds; no test touches
|
||||
`~/.mosaic-dev`, the live units or a slot file.
|
||||
@@ -0,0 +1,5 @@
|
||||
M1 KILLED fail=5
|
||||
M2 KILLED fail=2
|
||||
M3 KILLED fail=7
|
||||
M4 KILLED fail=2
|
||||
M5 KILLED fail=2
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Row 189 mutants M1-M5: each mutates scripts/stack-release.sh in the sage-b189 worktree, runs T55 and
|
||||
# the plan-tail tests, and restores the file from a checked backup.
|
||||
set -uo pipefail
|
||||
WT=/mnt/storage/scratch/sage-b189
|
||||
OUT=/mnt/storage/scratch/sage-b189-mut/out
|
||||
S=$WT/scripts/stack-release.sh
|
||||
mkdir -p "$OUT" || exit 1
|
||||
cp -p "$S" "$OUT/stack-release.sh.orig" || { echo "backup failed"; exit 1; }
|
||||
ORIG=$(sha256sum "$S" | cut -d' ' -f1)
|
||||
[ "$(sha256sum "$OUT/stack-release.sh.orig" | cut -d' ' -f1)" = "$ORIG" ] || { echo "backup differs"; exit 1; }
|
||||
restore() { cp -p "$OUT/stack-release.sh.orig" "$S"; [ "$(sha256sum "$S" | cut -d' ' -f1)" = "$ORIG" ] || { echo "RESTORE FAILED"; exit 1; }; }
|
||||
trap restore EXIT
|
||||
|
||||
mutate() {
|
||||
case "$1" in
|
||||
M1) sed -i '/{ \[ "\$NO_SEND" = 1 \] && \[\[ " \${P_CONSOLE\[\*\]} " == \*" --send "\* \]\]; } ||/d' "$S" ;;
|
||||
M2) sed -i 's/{ \[ "\$NO_SEND" = 1 \] && \[\[ " \${P_CONSOLE\[\*\]} " == \*" --send "\* \]\]; } ||/{ [ "$NO_SEND" = 1 ]; } ||/' "$S" ;;
|
||||
M3) sed -i 's/^ write_pointer "\$TO" "\$RUN_PM" "\$RUN_PENDING" "\$P_CLAUDE" "\$T_SCHEMA" "\${RUN_CONSOLE\[@\]}"$/ if [ "$NO_SEND" = 1 ]; then write_pointer "$TO" "$RUN_PM" "$RUN_PENDING" "$P_CLAUDE" "$T_SCHEMA" "${P_CONSOLE[@]}"; else write_pointer "$TO" "$RUN_PM" "$RUN_PENDING" "$P_CLAUDE" "$T_SCHEMA" "${RUN_CONSOLE[@]}"; fi/' "$S" ;;
|
||||
M4) sed -i 's/^ SEND_PLAN="send: off"$/ SEND_PLAN="send: off"; [ "$OP" != rollback ] || RUN_CONSOLE+=(--send)/' "$S" ;;
|
||||
M5) sed -i 's/--no-send) \[ "\$OP" = activate \] || usage;/--no-send) { [ "$OP" = activate ] || [ "$OP" = rollback ]; } || usage;/' "$S" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
for m in M1 M2 M3 M4 M5; do
|
||||
restore
|
||||
mutate "$m"
|
||||
if [ "$(sha256sum "$S" | cut -d' ' -f1)" = "$ORIG" ]; then echo "$m NOT-APPLIED"; continue; fi
|
||||
bash -n "$S" || { echo "$m SYNTAX"; continue; }
|
||||
diff "$OUT/stack-release.sh.orig" "$S" > "$OUT/$m.diff"
|
||||
(cd "$WT" && TMPDIR=/mnt/storage/scratch/t189 timeout 900 node --test --test-reporter=tap --test-name-pattern='T55|T1 |T2 ' packages/cli/tests/stack-release.test.mjs) > "$OUT/$m.log" 2>&1
|
||||
fails=$(awk '/^# fail /{print $3}' "$OUT/$m.log")
|
||||
if [ "${fails:-0}" -gt 0 ]; then echo "$m KILLED fail=$fails"; else echo "$m SURVIVED"; fi
|
||||
done
|
||||
@@ -3,5 +3,5 @@
|
||||
Reviewer seat, Claude Opus 5.5, started by Mos (mos-0981, thread 03086006). Reviews Case 2 designs and MVP T1 rows 192 and 194.
|
||||
|
||||
No launcher in this directory. The seat runs as a T3 thread and works in its own worktree. It has no
|
||||
credentials of its own; until row 195 settles per-agent identity, it pushes only its own `webby/*` branches
|
||||
credentials of its own; until the credentials row settles per-agent identity, it pushes only its own `webby/*` branches
|
||||
through the repository credential helper (mos-0981), never `refactor`, `main` or another seat's branch.
|
||||
|
||||
@@ -1361,3 +1361,5 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-10-20T18:34:53Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design review r2 (DESIGN.md blob 3eb718b5e131 at 1a268249e), static | changes: C1 config.json console key fails old loaders (mosaic-config.mjs:113 rejects unknown keys; key add inside phase 2 window, rollback removes it, loader test), C2 app.js signs out only on opaqueredirect so 401 never shows sign-in; notes on tests 15/7/9a, Q-B 200-HTML classification, scratch Console flags; Q-E keep iss/sub escaped and capped; verdict sent to Sage, no Gitea/queue record
|
||||
2026-10-20T18:40:20Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design review r3 (DESIGN.md blob a152fd58e18e at 817e8f51b), static | approve on condition D1 (slot file checks stay out of shared loader, shape-only there) as a line edit; D2 notes: slot root from configDir, scratch dataRoot, name discovery GETs for Mos; C1/C2 and notes verified; Mos C1 sequence confirm still open; no Gitea/queue record
|
||||
2026-10-20T18:41:20Z | Filbert (Opus 5.5, 048f3c63) | Q175 SSO design D1/D2 check (DESIGN.md blob eba861f5d2bb at d55d86bb0), static git diff 817e8f51b..d55d86bb0 | ack: D1 and D2 match; design approved; Mos C1 sequence confirm still open; no Gitea/queue record
|
||||
2026-10-20T18:57:10Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r2 (#1688, DESIGN.md sha256 5493c9df3ec0) | CHANGES to Darkwing, cc Sage: B1 claim check on MOSAIC_CONFIG-only selection, B2 shared prefix drop-in dirs; N1-N7 notes; static read, no slot, no Gitea or queue record
|
||||
2026-10-20T19:00:04Z | Filbert (Opus 5.5, 048f3c63) | row 190 design review r3 (#1688, DESIGN.md sha256 98236a1aff98) | APPROVE to Darkwing, cc Sage: B1 and B2 closed with M13 and M14, N7 settled by Sage's 48ebee0ff; static read, no slot; manifest round gets a queue review record (sage-1337)
|
||||
|
||||
+98
-11
@@ -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,
|
||||
@@ -368,11 +445,11 @@ reports checkout or copy identity and refactor drift, and writes nothing.
|
||||
```
|
||||
scripts/stack-release.sh adopt BUSINESS
|
||||
scripts/stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N]
|
||||
[--proof-header NAME --proof-file PATH | --no-proof] [--timeout S] [--plan]
|
||||
[--proof-header NAME --proof-file PATH | --no-proof] [--no-send] [--timeout S] [--plan]
|
||||
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.
|
||||
```
|
||||
@@ -480,6 +557,14 @@ copies. It checks the file with `stat` and `realpath` and never reads the value.
|
||||
or `off`. On the live copy it also notes that the run re-pins the pointer's copy
|
||||
and only the Console flags change.
|
||||
|
||||
`activate ... --no-send` (row 189, mos-0972) drops `--send` from the pointer's
|
||||
Console flags and the unit, so the Console serves reads and no human sends. It
|
||||
may name the live copy while the pointer holds `--send`; once `--send` is gone a
|
||||
same-copy `--no-send` refuses `already-active`. Later activates, `pm-on` and
|
||||
rollbacks carry the pointer, so send stays off. No flag puts `--send` back.
|
||||
`--plan` prints one `send:` line: `dropped`, `off` with or without the
|
||||
`--no-send` note, or `carried`.
|
||||
|
||||
#### A target past c216f47db: the PM after the harness kind
|
||||
|
||||
From c216f47db on, the bus launches the PM only with a `harness` kind in the
|
||||
@@ -594,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]
|
||||
@@ -671,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)
|
||||
@@ -697,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
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Brief: activate --no-send drops the Console's --send
|
||||
|
||||
Sage, 2026-10-20, for Mos's ruling mos-0972 (Q175 interim, Vikunja #412). Owner Sage, reviewer Dewey, gate owner
|
||||
Sage. Base: refactor 9ae8c73ac (row 163 landed). Line numbers are at that commit.
|
||||
Sage. Base: refactor 9ae8c73ac (row 163 landed). Line numbers are at that commit. The candidate corrects them;
|
||||
the first draft took them before row 163.
|
||||
|
||||
This row builds the tool only. The live restart without `--send` is a separate step afterwards, a same-copy
|
||||
activate under Q173 with a backup first and a STATE report after. No test here touches `~/.mosaic-dev`, the
|
||||
@@ -14,11 +15,11 @@ Mos ruled that the Console runs without `--send` until Q175 phase 2, because wit
|
||||
process can forge `X-authentik-username` on 127.0.0.1:7332 and send as Jason (design section 8). On the pinned
|
||||
stack, the Console flags live in the pointer (`~/.mosaic-dev/state/stack-active.json`):
|
||||
|
||||
- `console_for_target` (scripts/stack-release.sh:658-687) carries the pointer's flags unchanged. Only
|
||||
- `console_for_target` (scripts/stack-release.sh:664-693) carries the pointer's flags unchanged. Only
|
||||
`--tester-port` and the row 187 proof pair can change.
|
||||
- `check_target` (:247-249) lets an activate name the pointer's copy only for a proof-pair change.
|
||||
- `adopt` refuses once a pointer exists (:822).
|
||||
- A hand `webui-service.sh install` without `--send` makes `flags_drift` (:640-651) refuse
|
||||
- `check_target` (:249-256) lets an activate name the pointer's copy only for a proof-pair change.
|
||||
- `adopt` refuses once a pointer exists (:828).
|
||||
- A hand `webui-service.sh install` without `--send` makes `flags_drift` (:646-657) refuse
|
||||
`unit-flags-drift` on every later activate, pm-on and rollback.
|
||||
|
||||
So the tool can't drop `--send` today. This row adds that one path.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Brief: a credential store per agent, own git identity, pushes to a remote
|
||||
|
||||
Sage, 2026-10-20, for mos-0974 (MVP T1 item 6) and the MVP plan
|
||||
(`jason-master-restart/projects/mosaic-mvp/PLAN.md` item 6, GAP-MAP G11 and G13; TARGET.md: "credentials store
|
||||
available for all agents; secrets protected with RLS equivalent" and "Agent uses own credentials for git and
|
||||
other actions"). Gitea #1697. Owner Megavolt (Opus 5.5, a new builder seat), reviewer Duckworth, gate owner
|
||||
Sage. Base: refactor at the brief commit. Design first. The first deliverable is
|
||||
`agents/megavolt/work/queue-ROW/DESIGN.md`.
|
||||
|
||||
This row draws a security boundary. Sage reviews the design's authority model, and Mos rules on it before any
|
||||
code is written.
|
||||
|
||||
## What exists today (GAP-MAP, measured at 9ae8c73ac)
|
||||
|
||||
- G11. Credential references exist per instance for Gitea and Vikunja only
|
||||
(`packages/business/src/credentials.mjs`, `packages/bus/src/credentials.mjs`, `vocabulary.mjs:103`). One
|
||||
Claude env file is shared by every seat.
|
||||
- G13. Agents use their own Vikunja token (`packages/tasks/src/adapter.mjs`). Git goes through the host
|
||||
socket (`packages/cli/src/git-socket.mjs`). Gitea tokens are loaded but never used.
|
||||
- The development repository's helper (`scripts/git-credential-mosaic.mjs`, wired in `.git/config` for
|
||||
git.mosaicstack.dev) pushes as one identity. Until this row lands, seats push only their own `<seat>/*`
|
||||
branches through it (mos-0981).
|
||||
|
||||
## What the design must settle
|
||||
|
||||
1. The store. Where each agent's credentials live, per instance, under the row 190 config canon. File modes,
|
||||
ownership and the read path. Which process may read which entry, and how the bus enforces that (the "RLS
|
||||
equivalent"). A process asking for another agent's entry is refused and logged.
|
||||
2. Kinds. Gitea (git over HTTPS, and the API), Vikunja, and the model provider. Say which kinds 10-27 needs
|
||||
and which wait. The model provider entry must not copy jwoltje's `claude-session.env`. If it needs a new
|
||||
provider account or subscription, flag it for Jason and don't pick one.
|
||||
3. Git identity and push. Each agent commits with its own name and email and pushes with its own token to a
|
||||
remote it is granted. Which refs an agent may push (its own branches, never the default branch or another
|
||||
agent's), and where that's enforced: the Gitea side (branch protection, token scopes) and our side. How
|
||||
this fits the host git socket, or replaces it.
|
||||
4. Lifecycle. Issue, rotate (`rotateBy`, `expires`), revoke and delete, and what row 194 (agent lifecycle) calls
|
||||
when an agent is hired or fired. A fired agent's credentials stop working at once.
|
||||
5. Who does what. Creating a Gitea user or token, or any account outside the fleet, is Jason's (Q173
|
||||
exclusions). The design lists each such step as a command or UI action for Jason. Agents never mint
|
||||
accounts.
|
||||
6. Migration. Today's seats (jarvis helper, seat tokens) keep working until each moves. The mos-0981 push
|
||||
rule ends only when Mos says so.
|
||||
7. What this row closes in G11 and G13, and what stays open.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No credential value is read, printed, logged or committed. Tests use fake tokens in scratch roots and a
|
||||
fake Gitea.
|
||||
- No change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, slot files, `~/.mosaic` or the live
|
||||
`.git/config` of the canonical checkout.
|
||||
- No request to git.mosaicstack.dev, tasks.woltje.com or any provider from a test.
|
||||
|
||||
## Gate
|
||||
|
||||
Duckworth approves DESIGN.md, Mos rules on the authority model, then Duckworth approves the manifest. The bus,
|
||||
business, cli and tasks suites and every `scripts/test-*.sh` are green on Sage's gate, and the design's
|
||||
mutants are killed.
|
||||
@@ -0,0 +1,120 @@
|
||||
# Federation build rows R1, R2 and R5 (#414)
|
||||
|
||||
Sage, 2026-10-20, for Mos's orders mos-1023 item 1, mos-1028 item 2 and mos-1034 item 1. The design is
|
||||
Gizmoduck's `agents/gizmoduck/work/federation/DESIGN.md` at a1daa3c07 on `gizmoduck/federation-design` (r4.9,
|
||||
sha256 a31cbebd070f9e3a5ca4ccb158b1747c6f5a83c3469855705b0026a581fb5c6a). Duckworth approved r4.5
|
||||
(duckworth-0012), r4.6, r4.7 (duckworth-0020) and r4.8 (duckworth-0021, 8a22d0897 on `duckworth/reviews`),
|
||||
and acked r4.9 (duckworth-0024). r4.7 applies Jason's Q181 hosts, r4.8 adds N22's R8 checks and r4.9 names who
|
||||
runs them. None of them changes R1, R2 or R5. Section 7's table
|
||||
sets each row's scope and test gate. Vikunja P19 #414. Seats push only their own `<seat>/*` branches
|
||||
(mos-0981).
|
||||
|
||||
The other R rows are filed when their parents have candidates: R3 after R1 and row 196, R4 after R3, R5 and
|
||||
row 193's viewer level (193a, mos-1019), R6 after #412 phase 1, R1, R2 and R3, R7 after R1-R6 and row 190.
|
||||
R8 is a live window, not a code row. Its brief is the last section here.
|
||||
|
||||
## Brief: FED R1, the access.json loader and validator
|
||||
|
||||
Owner Gizmoduck, reviewer Duckworth, gate owner Sage. Base: refactor at this brief's commit. No parent.
|
||||
|
||||
`packages/business/src/access.mjs`: the access.json loader and validator from DESIGN.md section 3.5, and the
|
||||
pure `covers`. Providers, peers and businesses come in as arguments. One read plus the file checks. The
|
||||
exported interface is what R5 and row 193a build against, so the manifest names it, and any change to it in
|
||||
review goes to Sage before it lands. Section 7 names `covers` but not the loader and validator exports or
|
||||
the context shape. Morgana's 193a (508ee4d61 on `morgana/row193a`) assumes `checkAccess(doc, {providers,
|
||||
peers, businesses})`, with `businesses` mapping an id to `{human, instances}`, throwing a `BusinessError` that
|
||||
names the key path. Use those names, or tell Morgana and Quackerjack yours before the manifest goes to
|
||||
Duckworth. Row 193a later widens the level check to `viewer` on R1's candidate (mos-1019).
|
||||
|
||||
Tests, in `packages/business/tests/`: every rule in 3.5 refuses with the key path named. That covers a
|
||||
symlink, a non-regular file, another owner, group or other write, expiry, `*`, a grant disjoint from the given
|
||||
subjects, `human` and `tester` ids, any level other than `collaborator`, and a project entry that isn't a
|
||||
UUID. For `covers`: `*` and listed ids match, while an unlisted project, instance or business doesn't, an
|
||||
expired grant doesn't, and a level filter rejects another level. One mutant per refusal, each killed.
|
||||
|
||||
Boundaries: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or any live access.json. Tests
|
||||
use scratch roots. Single test files run freely, and suites need Sage's slot.
|
||||
|
||||
Gate: Duckworth approves the manifest. The business suite and every `scripts/test-*.sh` are green on Sage's
|
||||
gate, and the mutants are killed.
|
||||
|
||||
## Brief: FED R5, mosaic access list, grant and revoke
|
||||
|
||||
Owner Quackerjack, reviewer Duckworth, gate owner Sage. Chained on R1 (Q177). The build starts now against
|
||||
R1's exported interface as the R1 manifest names it, and R5 lands after R1 and rebases onto R1's landing.
|
||||
|
||||
`mosaic access list`, `grant` and `revoke`: human proof, atomic write, revision bump, a receipt line. The help
|
||||
text states the `instances` limit and the provider-removal order (DESIGN.md section 7, row R5). R4 later makes
|
||||
these verbs call `access.reload`, so R5 leaves no other reload path.
|
||||
|
||||
Context (sage-1419). `ssoSubjects` is row 192's `console.sso.providers[].subjects` flattened to `{provider,
|
||||
sub}` pairs; the design's "`console.sso.subjects`" in 3.5 is shorthand for it. `providers` is the list of
|
||||
`providers[].id`, read through `loadSystem` and row 192's `checkConsole`. `peers` is `[]` until R2 lands, so a grant
|
||||
naming a peer home refuses. R5 rebases onto row 192's landing as well as R1's.
|
||||
|
||||
Tests, in `packages/cli/tests/`: tester and agent callers are refused, a concurrent write refuses on a
|
||||
revision conflict, the receipt line is appended, and a bad grant is refused before anything is written. One
|
||||
mutant per refusal, each killed.
|
||||
|
||||
Boundaries: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or any live access.json. The
|
||||
human proof in tests uses the scratch stand-in pattern (a scratch bus verifies its own human-cli path).
|
||||
Single test files run freely, and suites need Sage's slot.
|
||||
|
||||
Gate: Duckworth approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate,
|
||||
and the mutants are killed.
|
||||
|
||||
## Brief: FED R2, the federation key in the system config
|
||||
|
||||
Owner Quackerjack, reviewer Webby, gate owner Sage. Chained on row 192 (#412 phase 1). The build starts when
|
||||
row 192 has a candidate, on that candidate.
|
||||
|
||||
A `federation` key in `scripts/mosaic-config.mjs` with the shape in DESIGN.md section 3. A config with no
|
||||
`federation` key still loads as it does today. This changes the config schema, so the manifest lists every
|
||||
reader of the key.
|
||||
|
||||
Tests, in `scripts/test-config.sh`: a valid key loads, an unknown sub-key and a bad origin refuse, and a
|
||||
config without the key loads. One mutant per refusal, each killed.
|
||||
|
||||
Boundaries: `~/.config/mosaic-dev/config.json` is user-authored and is not touched. Tests use scratch config
|
||||
dirs only.
|
||||
|
||||
Gate: Webby approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate, and
|
||||
the mutants are killed.
|
||||
|
||||
## Brief: FED R8, the hub window on 10.1.1.54
|
||||
|
||||
A live window, not a code row (DESIGN.md section 7, row R8). The brief follows r4.11, b93f098f0 on
|
||||
`gizmoduck/federation-design` (sha256 1bcee8df0a3a8628a6646535b756c936ca6d9e7bcfa7e1dd2bf1d3f01715c8e6),
|
||||
which Duckworth acked with r4.10 (duckworth-0034). Mos closed design rounds there (mos-1072). Run by Jason and
|
||||
Mos. Nothing here authorizes a step on any host; each step waits for the window's go.
|
||||
|
||||
What changed since r4.9 (DECISIONS Q182, Q183, mos-1055, mos-1063). R8 is a recorded acceptance. It starts
|
||||
with Jason's acceptance of the factory paths to .54 on record, quoted in section 8. The privilege and host
|
||||
checks still run, and each result goes into the R8 record with the name of whoever ran it. They report and
|
||||
don't refuse. The four functional checks named at the end of section 7's R8 row still stop R8 when they fail
|
||||
(mos-1066 item 1).
|
||||
|
||||
Order (mos-1042 item 2). R8 runs after the Case 2 install on .54 (#416, due by 2026-10-27, Jason's latest-by
|
||||
date in Q176, mos-1069), after Bean and Chet are live in Case 2, and after both Hermes gateways on 10.1.1.54
|
||||
have stopped. Case 2 go-live doesn't wait on R8. The risk, stated for 10-27: federation between the two
|
||||
instances depends on Melanie's switch-over date.
|
||||
|
||||
Entry checks, all before anything starts:
|
||||
- R7, #412 phase 2 and Jason's #419 answer, as section 7 lists.
|
||||
- The four hub user checks on 10.1.1.54 (N22, mos-1035 item 1): `sudo -l -U mosaic` grants nothing, `id
|
||||
mosaic` shows only its own group, `~mosaic/.ssh/authorized_keys` is absent or empty, and no file under
|
||||
`/etc/sudoers.d` names `mosaic`. An agent or Jason runs them as localadmin (mos-1055), and the output goes
|
||||
into the R8 record with who ran them.
|
||||
- The factory privilege checks, the key fingerprint comparison and the section 8 privilege path table, each
|
||||
as section 7's R8 row states them.
|
||||
|
||||
Build notes:
|
||||
- N1 (Duckworth, duckworth-0034, mos-1072). A check that fails on a path Jason didn't name in Q182 or Q183 is
|
||||
marked "not named in Q182 or Q183" in the record, not filed under the acceptance. Examples: a factory mount
|
||||
of hub data, another agent on .54, a provider admin token. Mos takes each one to Jason.
|
||||
- N2 (Duckworth, doc-only). The Q183 and Q4 quotes in section 8 are excerpts. When the R8 record quotes them,
|
||||
it marks each omission with "…" and checks the full text at DECISIONS.md:1535-1541 in jason-master-restart
|
||||
(read only).
|
||||
|
||||
Done when every entry check has its output recorded, Jason signs in to the hub and sends one message, Melanie
|
||||
revokes, and his next request is refused.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Brief: a pre-push hook that refuses force-pushes, and a busy check that fails on BUSY (#426)
|
||||
|
||||
Sage, 2026-10-20, for Mos's order mos-1078 (Vikunja P19 #426) and its go mos-1083 item 1. Owner Rocko,
|
||||
reviewer Dewey, gate owner Sage. Base: refactor at this brief's commit. Starts after row 191.
|
||||
|
||||
## Why
|
||||
|
||||
Morgana force-pushed a seat branch on 2026-10-20 (morgana-0016). Mos took it to Jason. Force-push is a
|
||||
Q173 exclusion, and canon says never force-push, but nothing mechanical stops one. Mos: "Done means a
|
||||
force-push from a seat worktree is refused, with a test that shows it."
|
||||
|
||||
Earlier the same day, a busy check chained as `| tail -1 &&` passed while the slot was BUSY. The script it
|
||||
called prints BUSY or CLEAR, but it exits 0 either way.
|
||||
|
||||
The guard has two layers:
|
||||
- (a) Gitea protection with force-push off on seat branch patterns and on refactor, next and main. ops-01
|
||||
sets it up, through Mos (mos-1082). That part isn't this row.
|
||||
- (b) The client hook below. It can be bypassed with `--no-verify`, so (a) is the real guard and (b) is the
|
||||
early refusal.
|
||||
|
||||
## What ships
|
||||
|
||||
1. `scripts/git-hooks/pre-push` (new). For each ref line git passes on stdin:
|
||||
- It refuses when the remote sha isn't all zeros and isn't an ancestor of the local sha (`git
|
||||
merge-base --is-ancestor`). The refusal names the ref and both shas, and the hook exits 1.
|
||||
- A new branch passes. A delete (local sha all zeros) is refused.
|
||||
- Any failure of the check refuses too, for example an unknown remote object. The hook fetches nothing.
|
||||
If the remote tip isn't present locally, it refuses and says to fetch first.
|
||||
2. Install, following `scripts/queue-commit.sh --install-hook`. Either add a `--install-push-hook` there or
|
||||
ship a small `scripts/install-git-hooks.sh`, whichever fits the existing code better. Say which in BUILD.md.
|
||||
- The install copies the hook into the common `.git/hooks`, which every seat worktree shares.
|
||||
- It refuses when `core.hooksPath` is set (the husky trap, 2026-10-09). It refuses when an installed
|
||||
pre-push differs from the repository copy, unless `--force-install` is given.
|
||||
- It never edits the installed copy in place.
|
||||
3. `scripts/slot-busy.sh` (new), the shared busy check.
|
||||
- It prints one BUSY line per matching process, or CLEAR.
|
||||
- It exits 0 on CLEAR, 1 on BUSY and 2 on its own error.
|
||||
- It matches `node --test`, `gate.sh`, `mutant`, `scripts/test-*.sh` and `docker compose` command lines.
|
||||
It skips its own process and shell snapshots.
|
||||
- It reads `/proc/<pid>/cmdline` and the cwd link only, never file contents under another seat's
|
||||
directory.
|
||||
- Usage in `docs/TOOLS.md`: `bash scripts/slot-busy.sh > LOG && gate`, with `set -o pipefail` in any
|
||||
script that pipes it. Never `| tail -1 &&`.
|
||||
|
||||
## Tests
|
||||
|
||||
Add them to `scripts/test-land-work.sh` or a new `scripts/test-git-guard.sh` (say which). Run against a scratch
|
||||
bare remote and a scratch clone with a worktree, never against origin:
|
||||
- A rewritten branch pushed with `--force` from a worktree is refused, and the remote ref is unchanged.
|
||||
- A `+refspec` push is refused.
|
||||
- A fast-forward push passes.
|
||||
- A new branch passes.
|
||||
- A delete is refused.
|
||||
- A remote tip that's missing locally is refused.
|
||||
- The install refuses with `core.hooksPath` set, and refuses a differing installed hook.
|
||||
- `slot-busy.sh` exits 1 with a fake `node --test` process running (a sleeping stand-in with that argv),
|
||||
and 0 without one.
|
||||
- `slot-busy.sh | tail -1 && false` doesn't apply here. The test runs `bash scripts/slot-busy.sh >/dev/null
|
||||
&& echo ran`, and asserts it doesn't print `ran` while BUSY.
|
||||
|
||||
Mutants, each killed:
|
||||
- drop the ancestor check
|
||||
- treat an unknown remote object as passing
|
||||
- let a delete pass
|
||||
- install over a set `core.hooksPath`
|
||||
- `slot-busy.sh` exits 0 on BUSY
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Install the hook in the canonical checkout only after landing, as its own step with Sage. Tests use
|
||||
scratch repositories.
|
||||
- No push to origin from a test. No change to Gitea config. That's ops-01's.
|
||||
- Don't touch `~/.mosaic`, `~/.mosaic-dev` or `~/.config/mosaic-dev`.
|
||||
|
||||
## Gate
|
||||
|
||||
Dewey approves the manifest. The new or changed `scripts/test-*.sh` file and every other `scripts/test-*.sh`
|
||||
are green on Sage's gate, and the mutants are killed. Done when the hook is installed in the canonical
|
||||
checkout and a scripted force-push from a seat worktree to a scratch remote is refused there. ops-01's
|
||||
server proof (a refused force-push, plus a fast-forward push and a new branch that go through) is recorded
|
||||
in Vikunja #426 by Mos.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Brief: agent host access with sudo through the credential store (#424)
|
||||
|
||||
Sage, 2026-10-20, for Jason's rulings Q183 and Q184 relayed by Mos (mos-1056 item 3, mos-1067). Gitea #1707, Vikunja P19
|
||||
#424, due 2026-10-27 (Q184: "Q5: by 10.27"). Owner Gizmoduck (design and build), reviewer Duckworth, gate owner
|
||||
Sage. Chained on row 199's candidate (Q177): row 199 design r6 (4d3007ec9 on `megavolt/row199`, approved in
|
||||
duckworth-0027) is not reopened. This row lands after 199 and rebases onto its landing.
|
||||
|
||||
## What Jason asked for
|
||||
|
||||
Q183, through Mos: "I envision a credential store within Mosaic Stack that works as some sort of variable for
|
||||
the agent to call. The system will enforce access to the credentials to keep the agents sandboxed with proper
|
||||
security... If we can utilize the credential store in Mosaic Stack and allow agents to perform their jobs with
|
||||
NOPASSWD sudo on various machines, I am a happy man." Q183 also says not every agent needs that access.
|
||||
|
||||
Mos's requirements (mos-1067 item 1):
|
||||
- An agent asks for a host credential by name.
|
||||
- The stack checks a grant for that agent and that host, and does the ssh and sudo for it.
|
||||
- The agent never sees the key or any secret value.
|
||||
- Only agents with a grant can do it.
|
||||
|
||||
The PRD text is REQ-CRED-3, drafted by Honker for PRD 1.7 (sage-1422). If it misses 1.7 it goes into 1.8, and
|
||||
this row lands only after the version that holds it is approved.
|
||||
|
||||
## What row 199 gives and what it doesn't (megavolt-0019)
|
||||
|
||||
It builds on 199 as 199 stands: the store layout under `<configDir>/secrets/agents/<business>/<agent>/`, the
|
||||
lifecycle verbs and their `changes.jsonl` receipts, `useFor(session, service, fn)` with the D5 allowlist test,
|
||||
`assertClean` on every result, and the grant shape of `publish` (a list on the role entry, checked by the
|
||||
broker, plus a hard-wait action that isn't routed).
|
||||
|
||||
This row's own changes:
|
||||
- A host kind in the closed kind set (vocabulary.mjs `SERVICES`, credentials.mjs, the broker's service check,
|
||||
the CLI date flag, the remove regex), with a naming rule for one credential per host or per agent.
|
||||
- A per-kind value check. An OpenSSH private key fails today's `TOKEN` pattern.
|
||||
- Handing the key to ssh without the agent seeing it: an ssh-agent on a private socket, or a 0600 temp file
|
||||
removed on every path, the way publish's one-shot helper works.
|
||||
- Pinned host keys per host, and no trust on first use.
|
||||
- The command boundary: what an agent may run under sudo, and how output returns without echoing the secret.
|
||||
This is the main design work.
|
||||
- The executor: a ticket and a host op running in the bus child, the same split row 199 uses for publish
|
||||
(Megavolt's deviation in 199's BUILD.md).
|
||||
|
||||
## Steps
|
||||
|
||||
1. DESIGN.md first, in `agents/gizmoduck/work/queue-ROW/`. It answers each item above, names the grant shape
|
||||
(`roles.<agent>.hosts` and a `host.sudo` action, or a reasoned alternative), says who grants (Jason, through
|
||||
a reviewed role change, unless REQ-CRED-3 says otherwise), and lists tests and mutants. Duckworth approves it
|
||||
before code. Sage reads it too, because it gives agents sudo on hosts.
|
||||
2. Build on 199's candidate. Single test files run freely. Suites and mutant batches need Sage's slot.
|
||||
3. The live part is not this row. Putting a key in the store and granting a host is a separate window with
|
||||
Jason or Mos, after landing.
|
||||
|
||||
## Hosts
|
||||
|
||||
The first targets are `localadmin` with NOPASSWD sudo on 10.1.1.53 and 10.1.1.54 (#423 step 1, applied by
|
||||
ops-01 at 20:05:47Z), and the servers agents already reach as localadmin (Q172). ops-01 owns the host side.
|
||||
Tests use scratch sshd fixtures on 127.0.0.1 or fakes, never a real host.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, slot files, the live units or `~/.mosaic`. No key is
|
||||
read, copied or printed. No ssh to any real host from a test or a seat.
|
||||
- No widening of row 199's kinds or regex inside row 199. Both rows touch them, so this row rebases after 199
|
||||
lands.
|
||||
|
||||
## Gate
|
||||
|
||||
Duckworth approves DESIGN.md, then the manifest. The bus, business and cli suites and every
|
||||
`scripts/test-*.sh` are green on Sage's gate, and the mutants are killed (one per refusal, plus one that leaks
|
||||
the key into the agent's output and one that skips the host key pin).
|
||||
@@ -0,0 +1,66 @@
|
||||
# Brief: the Case 1 install on 10.1.1.53 (row 195)
|
||||
|
||||
Sage, 2026-10-20, third version, for Darkwing's rewrite request and sage-1439. Gitea #1693.
|
||||
|
||||
Why it changed:
|
||||
- The first two versions put Melanie's hub on its own Linux user, then on its own host, Jason's choice under
|
||||
#419.
|
||||
- Q181 settled the hosts: Case 1 (Jason's software factory) runs on 10.1.1.53 and Case 2 (Melanie's hub) on
|
||||
10.1.1.54.
|
||||
- Q182 and Q183 made the hub's privilege checks a recorded acceptance. They report and don't refuse
|
||||
(federation R8, mos-1055, mos-1063, mos-1066).
|
||||
- The Case 2 install on .54 is Gosalyn's C2-13 plus its window (#416, due by 2026-10-27). It is not this row.
|
||||
|
||||
This row now installs Case 1 on .53. On the 10-27 MVP path (mos-0974, T1 item 1).
|
||||
|
||||
Owner Darkwing (Opus 5.5), reviewer Duckworth, gate owner Sage. Chained on row 190's candidate (the config
|
||||
canon) and on C2-13's candidate (the install script), Q177. It reuses C2-13's `case2-install.sh`,
|
||||
`case2-precheck.sh` and `case2-unit.sh` by input, not by copy. A gap in them goes to Gosalyn as a C2-13
|
||||
change, or into this row's manifest as a named, reviewed change to those files. Design first:
|
||||
`agents/darkwing/work/queue-195/DESIGN.md`.
|
||||
|
||||
## Goal
|
||||
|
||||
Jason's software factory runs on 10.1.1.53 as `mosaic` under `/srv/mosaic`, in the same unit form as Case 2
|
||||
(design 9, Q181). It uses row 190's config canon and the model account the factory uses today (Q185,
|
||||
mos-1081 item 1). No new provider account.
|
||||
|
||||
## Provision
|
||||
|
||||
- ops-01 runs the root steps over ssh as `localadmin`: `useradd mosaic`, `/srv/mosaic` and the unit through
|
||||
`case2-unit.sh` from a root-owned checkout. #423 step 1 (localadmin with NOPASSWD sudo on .53 and .54) was
|
||||
applied at 20:05:47Z (mos-1066, mos-1077), and nothing else in #423 gates this row.
|
||||
- #423 step 4 (jarvis sudo) waits until Case 1 is live on .53.
|
||||
- The host list comes from Q181. Agents may run read-only checks on .53 as `localadmin`.
|
||||
|
||||
## The design must state
|
||||
|
||||
1. The install as input values to C2-13's scripts: user, home, business file, roles, the human id, the
|
||||
token slot path. Which commands run as `mosaic` through `sudo -u mosaic -H`, and which as root through
|
||||
ops-01.
|
||||
2. Where Case 1 differs from Case 2: the factory roles in place of the library business, no library sync,
|
||||
and any precheck item that doesn't apply, each with a reason.
|
||||
3. Model access through the existing factory account (Q185). Placing the credential on .53 is a step under
|
||||
the Q173 secrets rule, done in the window. It is never copied by the script, and its value is never
|
||||
printed.
|
||||
4. Re-pin, rollback and backup on .53, and who runs them.
|
||||
5. The acceptance checks, as code Duckworth can read. Each check names a path and gives it one of three
|
||||
marks: closed, accepted under Q182 or Q183, or "not named in Q182 or Q183". An accepted path doesn't
|
||||
fail the row. An unnamed one is reported to Mos, who takes it to Jason (the R8 N1 rule). The functional
|
||||
checks fail the row when they fail: the unit runs as `mosaic`, the Console answers on its port on the
|
||||
internal network only, and one tester message gets a reply.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Infra and k3s changes, exposure outside the internal network, and spend stay Jason's (Q173 exclusions).
|
||||
- No change to dragon-lin's `~/.mosaic-dev`, `~/.config/mosaic-dev`, its live units or `~/.mosaic`.
|
||||
- No credential value is printed, logged or committed.
|
||||
- Tests use scratch roots and stand-ins. Nothing in a test reaches .53.
|
||||
- The run on .53 is a window with Jason's or Mos's go, a backup first where state exists, and a STATE report
|
||||
after.
|
||||
|
||||
## Gate
|
||||
|
||||
Duckworth approves DESIGN.md, then the manifest. Any repository change is green on Sage's gate (cli suite and
|
||||
every `scripts/test-*.sh`), with the design's mutants killed. The row is done when the window's acceptance
|
||||
run passes on .53, Duckworth reads its output, and Mos confirms.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Brief: the human boundary across uids and humans (P19 333)
|
||||
|
||||
Sage, 2026-10-20, for Mos's order mos-0989 (P19 333, MOSAICSTA-173, is now on the 10-27 path: federation R3 and
|
||||
row 193 need it). Gitea #1694. Owner Gander (Codex gpt-6.1-sol, a new builder seat under mos-0977 item 5),
|
||||
reviewer Duckworth, gate owner Sage. Latest-by: DESIGN.md approved by 2026-10-22, landed by 2026-10-24, and in
|
||||
any case before federation R3 lands. R3 and row 193 may chain on this row's candidate (Q177). Base: refactor at
|
||||
the brief commit. Design first. The first deliverable is `agents/gander/work/queue-ROW/DESIGN.md`.
|
||||
|
||||
## The problem, as filed and as it now matters
|
||||
|
||||
P19 333 as filed (row 87, mos-0657): the Console listens on a loopback TCP port, and any host process that
|
||||
reaches it, under any uid, acts as Jason. Jason accepted that as a documented limit for a one-user host
|
||||
(mos-0787, row 161). Two things change that:
|
||||
|
||||
- Row 190b (#1693, mos-0986) puts Melanie's hub under its own Linux user. Factory processes then sit on another
|
||||
uid, on the same host, next to the hub's ports and socket. The other-uid reach is now the Case 2 threat.
|
||||
- Federation r2 (gizmoduck-0004, 60b9b112a) binds a bus user only from a user-cli whose proof passes the human
|
||||
walk, whose direct parent is the bus's own Console, and which holds a live grant. That rule rests on the
|
||||
path-bound human proof (#225): today a same-uid process that runs the tree's `human-cli.mjs` acts as the
|
||||
human, without touching the Console. The Q175 design (section 8) names that limit and leaves it here.
|
||||
|
||||
## What already settles part of it
|
||||
|
||||
- Q175 phase 2 (#412) settles the Console-ingress part on the main port. Under `--sso`, 7332 answers any
|
||||
non-public Host with 403 except `GET /healthz`, and every other request needs a verified OIDC session. A
|
||||
process on another uid that reaches the port gets only `/healthz`. Phase 1 (row 192) builds it; it is live
|
||||
only after the phase 2 window.
|
||||
- Row 189 (`--no-send`) is interim only. Until phase 2 the Console takes no human writes, so the reach is
|
||||
read-only. It settles nothing.
|
||||
- Row 190b separates files and units by uid. Whether the hub's `broker.sock` refuses the factory uid depends on
|
||||
its directory and socket modes, which this row checks and tests rather than assumes.
|
||||
|
||||
## What the design must settle
|
||||
|
||||
1. Inventory every listener and entry point per instance: the Console port, the tester listener (7333 today),
|
||||
`broker.sock`, the human CLI wrapper, the Discord connector and anything row 190 adds. For each: who can
|
||||
reach it today from the same uid and from another uid, with and without `--sso`.
|
||||
2. The tester listener. It serves reads and the row 175 tester writes to anything that reaches it, on any uid.
|
||||
Close the other-uid reach (for example a unix socket with 0600 modes, or a peer-uid check on loopback), or
|
||||
bring Mos a concrete reason it must stay open. bus.tester stays Jason's switch.
|
||||
3. The bus human boundary. How the bus tells the human's own verified call from a same-uid process that runs
|
||||
`human-cli.mjs`. State what federation R3's direct-parent rule needs from the bus (peer credentials on
|
||||
`broker.sock`, the parent check, how it can't be forged by a child of an agent), and what stays possible for
|
||||
a same-uid process after this row. Say it plainly where a residual limit remains.
|
||||
4. Fit with row 193 (Morgana, human users and roles; Mos's B1 rule: a principal comes only from something the
|
||||
bus verifies) and with federation R3. One mechanism, not three.
|
||||
5. Migration: the live single-user Console and bus keep working until each instance re-pins.
|
||||
6. Which parts of P19 333 this row closes, which parts Q175 phase 2 closes, and what P19 333 says when it is
|
||||
closed.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Who can reach the Console from outside stays Jason's. This row changes what a host process can do once it
|
||||
reaches a local port or socket.
|
||||
- No test touches `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units or a slot file. No test creates a real
|
||||
second uid; use stand-ins as rows 190 and 190b do, and name any part only a Q173 window can prove.
|
||||
- No request to the live 127.0.0.1:7332 or 7333, mosaic.woltje.com or auth.diversecanvas.com.
|
||||
|
||||
## Gate
|
||||
|
||||
Duckworth approves DESIGN.md, then the manifest. The bus, webui and cli suites and every `scripts/test-*.sh`
|
||||
are green on Sage's gate, and the design's mutants are killed.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Row 193 build rows: 193a, 193b1, 193b2, 193c and 193d (#1691)
|
||||
|
||||
Sage, 2026-10-20, for Mos's go mos-1034 item 4. Row 193's design is Morgana's
|
||||
`agents/morgana/work/queue-193/DESIGN.md` at e2d161e2e on `morgana/row193` (r5, sha256
|
||||
dd7fcb419ed8510e9d8d74b569183386d0942af8b68eecc0bbb5e182e677b523), approved by Duckworth (duckworth-0018).
|
||||
Section 8 lists the tests and mutants, and section 9 the split. The brief behind them is
|
||||
`docs/plans/2026-10-20_human-users.md`. Vikunja P19 #414/#415, Gitea #1691.
|
||||
|
||||
Owner Morgana for every row, reviewer Duckworth, gate owner Sage. Each row chains on its parent's candidate
|
||||
(Q177) and rebases onto the parent's landing. Seats push only their own `<seat>/*` branches (mos-0981).
|
||||
Single test files run freely, and suites and mutant batches need Sage's slot.
|
||||
|
||||
Boundaries for every row: no change to `~/.mosaic-dev`, `~/.config/mosaic-dev`, the live units, slot files, any
|
||||
live access.json or `~/.mosaic`. No request to the live Console, mosaic.woltje.com or auth.diversecanvas.com.
|
||||
No user thread goes live before 193b1's message filter has landed (mos-1021 B2, FED R8).
|
||||
|
||||
The owner-visible records are fixed by Mos (mos-1023 item 2) and named in one user sentence, which FED R6
|
||||
reuses word for word: "The business owner sees your thread's agent's decisions, inbox entries, claims, tasks
|
||||
and project files, but not your messages."
|
||||
|
||||
## Brief: 193a, the access level check widened to viewer
|
||||
|
||||
Chained on FED R1. Started early on `morgana/row193a` (508ee4d61) against R1's interface as the FED brief
|
||||
names it, with Sage's go (sage-1373). R1's level check accepts `viewer` and still refuses any other new
|
||||
level. Tests in `packages/business/tests/` (section 8, Business), with the four mutants in Morgana's
|
||||
BUILD.md. FED R4 chains on this row.
|
||||
|
||||
Duckworth's N1 on R1 (duckworth-0033, sage-1417): with viewer added, `covers` called with no level would let a
|
||||
viewer grant cover a collaborator ask. This row makes `level` required in `covers`, with a test and a mutant that
|
||||
makes it optional again. R3 and R4 call `covers` with a level.
|
||||
|
||||
Gate: Duckworth approves the manifest. The business suite and every `scripts/test-*.sh` are green on Sage's
|
||||
gate, and the mutants are killed.
|
||||
|
||||
## Brief: 193b1, viewer verbs, board refusals and the message filter
|
||||
|
||||
Chained on FED R3, FED R4 and row 196's verifier. The bus gets the viewer verb list, per-grant level
|
||||
checks, board reads refused to users, and the section 5.7 `trail` and `replies` filter with `#readable`
|
||||
owner-only. No schema change. The governance pin test names each item in the user sentence above. Bus tests
|
||||
and mutants 1, 3, 6, 10, 11 and 12 from section 8.
|
||||
|
||||
Gate: Duckworth approves the manifest. The bus suite and every `scripts/test-*.sh` are green on Sage's gate,
|
||||
and the mutants are killed.
|
||||
|
||||
## Brief: 193b2, thread shares (schema 3j)
|
||||
|
||||
Chained on 193b1. `thread_shares`, the share verbs, the share rules of `#readable` and `#threadRead`, and the
|
||||
section 5.5 share filters. Schema 3i to 3j. The table is append-only, and row 181's restore guard and
|
||||
`COUNTED` include it. Bus tests for shares and the migration, and mutants 2, 4, 7, 8 and 9 from section 8.
|
||||
Going live needs a `--migrate` window with Jason. Sage takes it to Mos when this row is near.
|
||||
|
||||
Gate: Duckworth approves the manifest. The bus suite, the cli suite and every `scripts/test-*.sh` are green on
|
||||
Sage's gate, and the mutants are killed.
|
||||
|
||||
## Brief: 193c, mosaic access user verbs and --level viewer
|
||||
|
||||
Chained on FED R5. `mosaic access user add/remove` and `grant --level viewer`, only where R5 lacks them. They
|
||||
refuse tester and agent callers, write a receipt line and call `access.reload` (section 8, CLI). If R5
|
||||
already has all of them, this row records that and closes with no code.
|
||||
|
||||
Gate: Duckworth approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate,
|
||||
and the mutants are killed.
|
||||
|
||||
## Brief: 193d, the Console's shared list, share controls and viewer read-only
|
||||
|
||||
Chained on FED R6, row 192 and 193b2 (for share controls). A viewer has no composer or share control, and
|
||||
with each UI check removed the bus still refuses (section 8, Webui).
|
||||
|
||||
Gate: Duckworth approves the manifest. The webui suite and every `scripts/test-*.sh` are green on Sage's
|
||||
gate, and the mutants are killed.
|
||||
@@ -12,8 +12,10 @@ before any code, as Q175 did. Then the build follows the design, with tests and
|
||||
|
||||
Two or more instances run side by side on dragon-lin. Each one has its own config file, data root (store,
|
||||
broker.sock, host.lock, state, runs, sessions, workspaces), businesses, bus unit, Console unit and port,
|
||||
tester port, Discord unit, Claude session env file, stack-active pointer and activation log. Nothing one
|
||||
instance does can read or change another's state. The current instance keeps working unchanged: its
|
||||
tester port, Discord unit, Claude session env file, stack-active pointer and activation log. No instance's
|
||||
commands, units or tools address another instance's state by default or by mistake. On one uid this is
|
||||
addressing only, not a security boundary: a same-uid process can still read another instance's files.
|
||||
Isolation against that is row 190b (#1693, mos-0986), one Linux user per instance. The current instance keeps working unchanged: its
|
||||
config path, data root, unit names and ports stay as they are, and no re-pin is needed for it.
|
||||
|
||||
## Known single-instance points (GAP-MAP.md, measured at 9ae8c73ac)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Brief: retire the control board writer on 7331
|
||||
|
||||
Sage, 2026-10-20, for Mos's ruling mos-1008 item 2. Gitea #1696. MVP 10-27 work. Owner Bushroot (Haiku 5.5, a
|
||||
low-level model under Q177), reviewer Webby, gate owner Sage. Base: refactor at the brief commit.
|
||||
|
||||
## Why
|
||||
|
||||
`packages/control-board/src/serve.mjs` is a loopback HTTP service on 7331 with no authentication. It reads
|
||||
transcripts and writes through `/api/reply`. Any host process that reaches the port can do both. That fails
|
||||
Q175 ("zero trust with verification"). Gander found it in the row 196 inventory (gander-0004). Nothing listens
|
||||
on 7331 now (`ss -ltnp`, 2026-10-20T19:05:09Z), so retiring it costs the least.
|
||||
|
||||
## Steps, in order
|
||||
|
||||
1. Inventory first. List every reference to the control board and to 7331: scripts, units, `scripts/mosaic`
|
||||
and other CLI dispatch, docs (AGENTS.md, READMEs, TOOLS.md), package.json scripts, tests, and the Console
|
||||
(`packages/webui`). For each, say whether it starts the writer, reads its code, or only mentions it.
|
||||
Commit the list as `agents/bushroot/work/queue-ROW/INVENTORY.md` and send it to Sage and Webby.
|
||||
2. Stop if something depends on it. If a reference shows a live dependency (the Console or another package
|
||||
imports code that only the control board has, or a unit or script still starts it), stop. Sage takes it to
|
||||
Mos before anything is removed.
|
||||
3. Otherwise retire the writer. Either remove the `serve` entry point, or make it refuse to start with a
|
||||
message that points to the Console. Keep any read-only code another package imports, and say which.
|
||||
Update the docs that tell people to start it. The AGENTS.md text about the control-board reply convention
|
||||
is a separate matter. Change it only if it tells someone to start 7331.
|
||||
|
||||
## Mos's call on the Console dependency (mos-1019)
|
||||
|
||||
The inventory (004f133bd) found that the Console proxies `/api/board`, `/api/seen`, `/api/reply` and the
|
||||
conversation routes to `DEFAULT_BOARD` on 7331 (`packages/webui/src/serve.mjs:12`), so the board view returns
|
||||
502 today. Mos took Sage's recommendation: retire the 7331 listener and its README start commands, and have
|
||||
the Console call the board's read code in-process, behind the Console's own auth (Q175 SSO on 7332).
|
||||
`/api/reply` stays only under `--send`. The DESIGN.md for that change comes before code, and Webby approves
|
||||
it.
|
||||
|
||||
## Acceptance
|
||||
|
||||
Nothing in the stack can start a writer on 7331. A test proves it: the entry point refuses (or is gone), no
|
||||
script or unit starts it, and a grep check finds no starter. Each change gets a mutant that brings the writer
|
||||
back and the test kills it. Mos added three items (mos-1019):
|
||||
|
||||
- The board view answers from the Console with nothing listening on 7331.
|
||||
- Every board route sits behind the Console's auth.
|
||||
- A test proves `/api/reply` refuses without `--send`.
|
||||
|
||||
Test fixtures that bind port 0 may stay if they only test the code in-process.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No change to the live units, `~/.mosaic-dev`, `~/.config/mosaic-dev`, slot files or `~/.mosaic`.
|
||||
- No request to any live port.
|
||||
- Single test files run freely. Suites need Sage's slot.
|
||||
|
||||
## Gate
|
||||
|
||||
Webby approves INVENTORY.md, then DESIGN.md, then the manifest. The control-board and webui package suites, the cli suite and
|
||||
every `scripts/test-*.sh` are green on Sage's gate, and the mutants are killed.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Brief: a second gate lane, two full gates at once on dragon-lin
|
||||
|
||||
Sage, 2026-10-20, for Mos's order mos-0977 item 6 ("put the design for a second gate lane on the list now")
|
||||
and the MVP push (mos-0974). Gitea #1695. Owner Herb (Codex gpt-6.1-sol, a new builder seat), reviewer
|
||||
Webby, gate owner Sage. Base: refactor at the brief commit. Design first. The first deliverable is
|
||||
`agents/herb/work/queue-ROW/DESIGN.md`.
|
||||
|
||||
## Why
|
||||
|
||||
Every full run shares one slot today. A full gate (cli suite plus every `scripts/test-*.sh`) takes about 13
|
||||
minutes or more, reviewers' runs and mutants take the same slot, and with eight or more seats building for
|
||||
10-27 the slot is the bottleneck. The slot exists because concurrent runs broke each other. The busy check is
|
||||
`agents/sage/work/gate-lane/busy-now.sh` (a copy of Sage's), and the gate is
|
||||
`agents/sage/work/gate-lane/gate-template.sh` (a copy of Sage's template). The host has 16 cores and 62 GB.
|
||||
|
||||
## What the design must settle
|
||||
|
||||
1. Every shared resource two concurrent runs can collide on, found from the code and tests, not guessed:
|
||||
Docker compose project names and networks (`scripts/test-task.sh` and others), fixed TCP ports in tests,
|
||||
unix socket paths and their 107-byte limit under TMPDIR, the sandbox's private `/tmp`, shared caches,
|
||||
scratch and worktree paths, the queue lock, and any test that reads host-wide state (process lists,
|
||||
`pgrep`, `/proc`). For each: how a lane gets its own copy, or why it is safe to share.
|
||||
2. Timing. Which tests have time limits that a doubled CPU load could break, measured, and what changes (a
|
||||
bound, or a lane rule that keeps those suites apart).
|
||||
3. The lane mechanism: lane ids, a lock per lane that a run takes and releases even on a crash, the busy
|
||||
check that reports per lane, and how a seat asks for a lane. Sage still grants it.
|
||||
4. The gate template takes a lane id and isolates everything from point 1 by it.
|
||||
5. Proof: two full gates on different candidates at once, three times, with zero cross-failures, plus the
|
||||
wall time of each against a single-lane run. Any flake found is named with its cause.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No change to the live units, `~/.mosaic-dev`, `~/.config/mosaic-dev` or slot files. The proof runs use
|
||||
scratch roots only, and it needs Sage's grant of the whole slot while it runs.
|
||||
- Test code changes that only isolate a resource are in scope. A change to what a test checks is not, and
|
||||
goes to its owner.
|
||||
- Docker networks and containers a run makes are removed by the run (trap), and nothing else is touched.
|
||||
|
||||
## Gate
|
||||
|
||||
Webby approves DESIGN.md, then the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's
|
||||
gate, both singly and in the two-lane proof.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Brief: stack-release.sh activates a Console with --sso (Q175 phase 2 prerequisite)
|
||||
|
||||
Sage, 2026-10-20, for Launchpad's finding F3 on row 192 (launchpad-0001) and Q175 (#412). Gitea #1698. Owner
|
||||
Launchpad, reviewer Webby, gate owner Sage. Chained on row 192's candidate (Q177): it lands after row 192 and
|
||||
before the phase 2 window. Base: row 192's candidate.
|
||||
|
||||
## Why
|
||||
|
||||
The phase 2 window re-pins the Console to the phase 1 commit with `--sso --send --tester-port 7333`
|
||||
(`agents/sage/work/q175-sso/DESIGN.md`, section 5). `scripts/stack-release.sh` doesn't know `--sso` today.
|
||||
Its `proof_checks` (`:751`) requires `--public-host`, `--auth-header` and `--allow-user` whenever a proof pair is
|
||||
carried, and the pointer's Console flags still hold the forward-auth pair (`--auth-header X-authentik-username
|
||||
--allow-user jason.woltje`) that `--sso` refuses to start with. So the window can't run through the script, and
|
||||
a hand-edited pointer is not an option.
|
||||
|
||||
## What the row does
|
||||
|
||||
1. `activate ID BUSINESS --sso` writes Console flags with `--sso` and without `--auth-header`, `--allow-user`
|
||||
and any proof pair, and `--plan` names each flag it drops and carries, in the style of row 189's `send:`
|
||||
line. The design names the full flag set the phase 2 Console starts with and checks it against row 192's
|
||||
refusals.
|
||||
2. Before anything stops, the script refuses a target copy whose Console doesn't support `--sso`, and refuses
|
||||
when the config's `console` key or its slot file fails the checks row 192's `--sso` start runs. It reads
|
||||
neither value and only uses the same stat and shape checks.
|
||||
3. Rollback from an `--sso` pointer to a pre-phase-1 copy follows the design's phase 2 rollback order (remove
|
||||
the `console` key first). The script refuses a rollback that would start a Console on a config it can't
|
||||
read, and says which step comes first.
|
||||
4. `docs/TOOLS.md` gets the flag.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- No change to the live pointer, units, `~/.mosaic-dev` or `~/.config/mosaic-dev`. Tests use scratch roots
|
||||
and seeded pointers, as rows 187 and 189 do.
|
||||
- No request to the live Console, mosaic.woltje.com or auth.diversecanvas.com.
|
||||
|
||||
## Gate
|
||||
|
||||
Webby approves the manifest. The cli suite and every `scripts/test-*.sh` are green on Sage's gate, and the
|
||||
mutants are killed (one per refusal, and one that keeps `--auth-header` in the written flags).
|
||||
+23
-3
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
|
||||
|
||||
<!-- mosaic-queue:begin -->
|
||||
|
||||
Generated from `docs/plans/queue.json` revision 2247 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
Generated from `docs/plans/queue.json` revision 2361 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
|
||||
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -225,9 +225,29 @@ Generated from `docs/plans/queue.json` revision 2247 by `queue render`. Do not e
|
||||
| 186 | stack-release --plan: the backup line says what the run will do (P19 404, row 185 N1/N2) | darkwing; reviewers filbert | #1684 | done | — | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the three brief mutants killed (sage) | `docs/plans/2026-10-20_plan-backup-line.md` § Brief: the activate plan's backup line says what the run will do | Landed 269134d8d (author Darkwing) on 3c5da3a91. Patch-id c97b9f6e25cd matches the r1 candidate (manifest 449e9121, Filbert approve 29419). Sage gate on b367f5730+patch 09:00:20Z-09:08:59Z: node 2681/0, scripts 422/0, queue 156, network removed; 3c5da3a91 adds only a SESSIONS line. Not live: the stack runs copy 490e2771d3d0. Follow-ups on P19 404: Filbert M6 gap, store-unclean reading. |
|
||||
| 187 | stack-release activate carries the row 178 proof pair (mos-0928 Part B A0) | darkwing; reviewers filbert | #1685 | done | 186 done | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-proof-pair.md` § Brief: activate carries the row 178 proof pair | Landed 9a71e4e80 (author Darkwing) on d17328a55; patch-id 6b57c47bc661 matches candidate 52a5ecc1d143 and Filbert's r1 reading. Sage gate passed 10:21:55Z: cli 446/446 and every package and scripts/test-*.sh green. Filbert approve 29424, rev 2204; follow-ups N1-N4 filed on P19. |
|
||||
| 188 | stack-release tests: a carried proof pair and a proof file inside the repository (P19 409, 410; gates Part B) | darkwing; reviewers filbert | #1686 | done | 187 settled | Filbert approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_proof-test-gaps.md` § Brief: tests for the two proof-file paths row 187 left open | Landed 8fc0ee216 (author Darkwing) on 23a609c5c; patch-id c9a56b167c07 matches candidate 86aa549bad75 and Filbert's r1 reading; stack-release.sh blob 2586a2a37cd2 unchanged. Sage gate passed 10:58Z: cli 453/453 and every package and scripts/test-*.sh green. Filbert approve 29433, rev 2212; N1 (REPO logical path) to P19 after Part B (mos-0959). |
|
||||
| 189 | stack-release activate --no-send drops the Console's --send and keeps the pointer in step (Q175 interim, mos-0972, P19 413) | sage; reviewers dewey | #1687 | in-review, round 1, request posted | 163 done | Dewey approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-no-send.md` § Brief: activate --no-send drops the Console's --send | — |
|
||||
| 190 | several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414) | darkwing; reviewers filbert | #1688 | in-progress | — | Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed (sage) | `docs/plans/2026-10-20_multi-instance.md` § Brief: several Mosaic Stack instances on one host | — |
|
||||
| 189 | stack-release activate --no-send drops the Console's --send and keeps the pointer in step (Q175 interim, mos-0972, P19 413) | sage; reviewers dewey | #1687 | done | 163 done | Dewey approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the brief mutants killed (sage) | `docs/plans/2026-10-20_activate-no-send.md` § Brief: activate --no-send drops the Console's --send | Landed ace27dc5e on refactor (cherry-pick of candidate d6116771f onto a4a42dbac). Gate GATE_PASSED on 0c177e9ef (base 0217b71c8), 2026-10-20T19:09:37Z-19:20:36Z, 25 suites rc=0. patch-id ce89378718f6 for both 0c177e9ef and ace27dc5e; range-diff differs only in the message. Landed manifest hashes to 67af69bd (Dewey approve 29485). Base drift 0217b71c8..a4a42dbac: 7 doc/README files, none on manifest paths, no test reads them. Brief re-pinned to blob 29a9e6d2d. |
|
||||
| 190 | several Mosaic Stack instances on one host: per-instance config, data root, units and ports (MVP T1.1, mos-0974, Vikunja #414) | darkwing; reviewers filbert | #1688 | in-review, round 1, request posted | — | Filbert approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the design mutants killed (sage) | `docs/plans/2026-10-20_multi-instance.md` § Brief: several Mosaic Stack instances on one host | — |
|
||||
| 191 | one kill switch for the bus, every run, Discord and new launches, from the CLI and the Console (MVP T1.4, mos-0974, Vikunja #414) | rocko; reviewers dewey | #1689 | in-progress | — | Dewey approves DESIGN.md then the manifest; cli, bus, discord and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_kill-switch.md` § Brief: one kill switch for the bus, every run, Discord and new launches | — |
|
||||
| 192 | Q175 phase 1: the Console verifies SSO itself (--sso), OIDC RP with oauth4webapi 3.8.8, no live change (MVP T1.2, #412) | launchpad; reviewers webby | #1690 | in-review, round 2, request posted | — | Webby approves the manifest; webui and cli suites, scripts/test-config.sh and every scripts/test-*.sh green on Sage's gate; design section 9 mutants killed (sage) | `docs/plans/2026-10-20_sso-phase1.md` § Brief: Q175 phase 1, the Console verifies SSO itself (--sso) | — |
|
||||
| 193 | many humans per business, human roles, private and shared threads, one access.json store (MVP T1.3, Vikunja #414/#415) | morgana; reviewers duckworth | #1691 | in-progress | — | Duckworth approves DESIGN.md then the manifest; bus, business, cli and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users.md` § Brief: many humans per business, human roles, private and shared threads | — |
|
||||
| 194 | agent lifecycle from the Console and the CLI (create, launch, terminate, regenerate, delete) with a per-agent hire/fire veto (MVP T1.5, Vikunja #414/#415) | honker; reviewers webby | #1692 | in-progress | — | Webby approves DESIGN.md then the manifest; bus, cli, business and webui suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-lifecycle.md` § Brief: agent lifecycle from the Console and the CLI, with a per-agent hire/fire veto | — |
|
||||
| 195 | row 195: the Case 1 install on 10.1.1.53 as mosaic under /srv/mosaic, reusing C2-13's scripts by input under row 190's canon (Q181, Q182/Q183, Q185; ops-01 root steps under #423 step 1) | darkwing; reviewers duckworth | #1693 | briefed | 190 done | Duckworth approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_hub-own-uid.md` § Brief: the Case 1 install on 10.1.1.53 (row 195) | sage-1439: rewritten to Case 1 on .53 (f24f2cd40). Chains on C2-13's candidate too once C2-13 is queued. Darkwing starts DESIGN.md after row 190's mutants. |
|
||||
| 196 | the human boundary across uids and humans: Console, tester listener, broker.sock and the path-bound human proof (P19 333, before federation R3, mos-0989) | gander; reviewers duckworth | #1694 | in-progress | — | Duckworth approves DESIGN.md then the manifest; bus, webui and cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-boundary.md` § Brief: the human boundary across uids and humans (P19 333) | — |
|
||||
| 197 | a second gate lane: two full gates at once on dragon-lin, design first (mos-0977 item 6) | herb; reviewers webby | #1695 | in-progress | — | Webby approves DESIGN.md then the manifest; cli suite and every scripts/test-*.sh green on Sage's gate, singly and in the two-lane proof (sage) | `docs/plans/2026-10-20_second-gate-lane.md` § Brief: a second gate lane, two full gates at once on dragon-lin | Order with row 198 (Sage, mos-1040 N1): 197 lands first. After B1 the shared tests are packages/webui/tests/serve.test.mjs:100 and preview.test.mjs:208. Row 198 rebases onto 197's landing before its manifest review. |
|
||||
| 198 | retire the control board writer on 7331 (unauthenticated loopback writer, Q175; mos-1008) | bushroot; reviewers webby | #1696 | in-progress | 192 done, 197 done | Webby approves INVENTORY.md then the manifest; control-board, webui and cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_retire-control-board.md` § Brief: retire the control board writer on 7331 | Order with row 197 (Sage, mos-1040 N1): 197 lands first. Row 198 rebases onto 197's landing before its manifest review; shared tests are packages/webui/tests/serve.test.mjs:100 and preview.test.mjs:208. |
|
||||
| 199 | MVP T1 item 6: a credential store per agent, own git identity, pushes to a remote (G11, G13; mos-0974) | megavolt; reviewers duckworth | #1697 | in-progress | — | Duckworth approves DESIGN.md; Mos rules on the authority model; Duckworth approves the manifest; bus, business, cli and tasks suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: a credential store per agent, own git identity, pushes to a remote | — |
|
||||
| 200 | stack-release.sh activate --sso for the Q175 phase 2 window (row 192 F3; chained on 192) | launchpad; reviewers webby | #1698 | in-review, round 2, request posted | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_stack-release-sso.md` § Brief: stack-release.sh activates a Console with --sso (Q175 phase 2 prerequisite) | — |
|
||||
| 201 | FED R1: access.json loader and validator, and covers (#414) | gizmoduck; reviewers duckworth | #1699 | in-review, round 1, request posted | — | Duckworth approves the manifest; business suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R1, the access.json loader and validator | — |
|
||||
| 202 | FED R5: mosaic access list, grant and revoke (#414) | quackerjack; reviewers duckworth | #1700 | in-review, round 1, request posted | — | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R5, mosaic access list, grant and revoke | Chained on row 201's candidate (Q177), not its done: claimable now, lands after 201 and rebases onto its landing (Sage) |
|
||||
| 203 | FED R2: the federation key in the system config (#414) | quackerjack; reviewers webby | #1701 | briefed | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R2, the federation key in the system config | Chained on row 192's candidate (Q177): starts on 192's candidate, lands after 192 (Sage) |
|
||||
| 204 | 193a: access level check widened to viewer (#1691) | morgana; reviewers duckworth | #1702 | in-review, round 1, request posted | — | Duckworth approves the manifest; business suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193a, the access level check widened to viewer | Chained on row 201's candidate (Q177), not its done: claimable now, lands after 201 and rebases onto its landing (Sage) |
|
||||
| 205 | 193b1: viewer verbs, board refusals and the message filter (#1691; also after FED R3 and R4 when filed) | morgana; reviewers duckworth | #1703 | queued | 196 done | Duckworth approves the manifest; bus suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1, viewer verbs, board refusals and the message filter | — |
|
||||
| 206 | 193b2: thread shares, schema 3j (#1691; live needs a --migrate window with Jason) | morgana; reviewers duckworth | #1704 | queued | 205 done | Duckworth approves the manifest; bus suite, cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b2, thread shares (schema 3j) | — |
|
||||
| 207 | 193c: mosaic access user verbs and --level viewer (#1691) | morgana; reviewers duckworth | #1705 | queued | 202 done | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193c, mosaic access user verbs and --level viewer | — |
|
||||
| 208 | 193d: Console shared list, share controls and viewer read-only (#1691; also after FED R6 when filed) | morgana; reviewers duckworth | #1706 | queued | 192 done, 206 done | Duckworth approves the manifest; webui suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193d, the Console's shared list, share controls and viewer read-only | — |
|
||||
| 209 | FED R8: the hub window on 10.1.1.54, a recorded acceptance (#414, r4.11 b93f098f0; live window run by Jason and Mos, after the Case 2 install #416, Bean and Chet live and both Hermes gateways stopped) | sage; reviewers duckworth | — | queued | — | Every entry check's output is in the R8 record with who ran it; Jason signs in to the hub and sends one message, Melanie revokes, his next request is refused; Duckworth checks the record (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R8, the hub window on 10.1.1.54 | Stays queued until its entry gates near: Case 2 install on .54 (#416, by 2026-10-27), Bean and Chet live, both Hermes gateways on .54 stopped, R7, #412 phase 2, #419 answer. Filed per mos-1073 after duckworth-0034 (Sage) |
|
||||
| 210 | Agent host access with sudo through the credential store: grant per agent and host, the stack does ssh and sudo, the agent never sees the key (#1707, Vikunja #424, Q183/Q184, due 10-27; chained on row 199's candidate) | gizmoduck; reviewers duckworth | #1707 | in-progress | — | Duckworth approves DESIGN.md, then the manifest; bus, business, cli suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_host-credentials.md` § Brief: agent host access with sudo through the credential store (#424) | Design runs before 199 lands (Q177 chain on 199's candidate). after cleared so Gizmoduck can claim; lands only after 199 and rebases onto it. |
|
||||
| 211 | Force-push guard: scripts/git-hooks/pre-push refuses non-fast-forward and delete pushes from every worktree, and scripts/slot-busy.sh exits 1 on BUSY (#1709, Vikunja #426, mos-1078/1083) | rocko; reviewers dewey | #1709 | briefed | — | Dewey approves the manifest; the new or changed scripts/test-*.sh and every other scripts/test-*.sh green on Sage's gate; the mutants killed; hook installed in the canonical checkout with a scripted refused force-push (sage) | `docs/plans/2026-10-20_force-push-guard.md` § Brief: a pre-push hook that refuses force-pushes, and a busy check that fails on BUSY (#426) | — |
|
||||
|
||||
<!-- mosaic-queue:end -->
|
||||
|
||||
|
||||
+4263
-14
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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});
|
||||
@@ -381,7 +419,7 @@ async function seed(e,{copy=B,pm=false,store='3g',pointer:how='adopt'}={}){
|
||||
const rolledBack=(code,to=B)=>`stack-release: ${code}; rolled back to ${to}, which passed the gate\n`;
|
||||
const planBackup=(e)=>`backup: the run backs up the stopped store to ${e.data}/backups/stack/<activation>/ before any migration`;
|
||||
const planNoBackup='backup: none; the run rolls back before the backup';
|
||||
const planTail=(e,to,pm=false)=>[`bus: scripts/bus-service.sh install --copy ${to} --copies ${COPIES}${pm?' --pm':''} --claude ${e.claude}`,'proof: off',`Console: scripts/webui-service.sh install --copy ${to} --copies ${COPIES} --port ${e.port} --business acme`,`CLI: scripts/stack-copy.sh wrapper ${to} --copies ${COPIES} --bin ${e.bin}`];
|
||||
const planTail=(e,to,pm=false)=>[`bus: scripts/bus-service.sh install --copy ${to} --copies ${COPIES}${pm?' --pm':''} --claude ${e.claude}`,'proof: off','send: off',`Console: scripts/webui-service.sh install --copy ${to} --copies ${COPIES} --port ${e.port} --business acme`,`CLI: scripts/stack-copy.sh wrapper ${to} --copies ${COPIES} --bin ${e.bin}`];
|
||||
|
||||
// --- The T table ---
|
||||
|
||||
@@ -1030,17 +1068,17 @@ test('T36 --plan shows the pending state and lists the row 169 refusals it can s
|
||||
return r.out.split('\n');
|
||||
};
|
||||
let out=await quiet(0,'activate',C1,'acme','--pm-after-harness','--plan');
|
||||
assert.deepEqual(out.slice(-6),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,C1),'']);
|
||||
assert.deepEqual(out.slice(-7),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,C1),'']);
|
||||
biz(e,HARNESS);
|
||||
out=await quiet(3,'activate',A,'acme','--pm-after-harness','--plan');
|
||||
assert.deepEqual(out.slice(-7),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,A),`refuses unknown-kind-for-target: the business file names plug-in kind(s) harness, which ${A} does not know`,'']);
|
||||
assert.deepEqual(out.slice(-8),['pm: the bus starts without --pm and the PM stays pending; next: Jason\'s plugins set of harness, then pm-on',...planTail(e,A),`refuses unknown-kind-for-target: the business file names plug-in kind(s) harness, which ${A} does not know`,'']);
|
||||
out=await quiet(3,'activate',C1,'acme','--pm-after-harness','--plan');
|
||||
assert.equal(out.at(-2),'refuses pm-after-harness-set: the business file already sets harness ["claude-code"]; use --pm');
|
||||
biz(e);assert.equal((await run(e,'activate',C1,'acme','--pm-after-harness')).code,0);
|
||||
out=await quiet(3,'activate',C,'acme','--plan');
|
||||
assert.deepEqual(out.slice(-7),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,C,true),`refuses pm-pending: ${PENDING_MSG}`,'']);
|
||||
assert.deepEqual(out.slice(-8),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,C,true),`refuses pm-pending: ${PENDING_MSG}`,'']);
|
||||
out=await quiet(0,'rollback','acme','--plan');
|
||||
assert.deepEqual(out.slice(-6),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,B,true),'']);
|
||||
assert.deepEqual(out.slice(-7),['pm: the pointer is pending (activate --pm-after-harness); the bus runs without its PM',...planTail(e,B,true),'']);
|
||||
});
|
||||
|
||||
// Row 176 test 11: supported and older Console copies deliberately differ from the checkout.
|
||||
@@ -1706,6 +1744,113 @@ test('T54 row 188: a carried pair gets the proof file checks, and a proof file i
|
||||
});
|
||||
});
|
||||
|
||||
// Row 189 (#1687, mos-0972/0973): activate --no-send drops the pointer's --send; nothing turns it back on.
|
||||
const sendLines=(out)=>out.filter((l)=>l.startsWith('send:'));
|
||||
const noSend=(c)=>c.filter((x)=>x!=='--send');
|
||||
const LIVE_NOTE=`note: ${B} is the pointer's copy; the run re-pins it and only the Console flags change`;
|
||||
test('T55 row 189: activate --no-send drops --send, and rollback and pm-on carry it off',async(t)=>{
|
||||
await t.test('test 1: a live-copy --no-send drops --send from the pointer and the unit, keeps the rest in order, logs the step and backs up the stopped store',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']),want=noSend(base);
|
||||
assert.deepEqual(want.slice(-2),['--tester-port','7333']);
|
||||
const sum=createHash('sha256').update(readFileSync(e.store)).digest('hex'),n=calls(e).length;
|
||||
let r=await prun(e,'activate',B,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,want);
|
||||
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));assert.doesNotMatch(conExec(e),/ --send( |$)/);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
assert.match(stepText(e),/Z drop --send; --no-send was given\n/);
|
||||
const act=pointer(e).activation,l=logLines(e).at(-1);
|
||||
assert.deepEqual([l.op,l.from,l.to,l.result,l.backup],['activate',B,B,'ok',act]);assert.equal(backupSum(e,act),sum);
|
||||
assert.deepEqual(startsStops(e,n),[`stop ${CON}`,`stop ${BUS}`,`start ${BUS}`,`start ${CON}`]);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('test 2: a plain live-copy activate still refuses already-active, and so does --no-send once --send is gone',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e,undefined,['--send']);
|
||||
await refused(e,['activate',B,'acme'],'already-active',`${B} is the pointer's copy`);
|
||||
await refused(e,['activate',B,'acme','--tester-port','7333'],'already-active',`${B} is the pointer's copy`);
|
||||
await refused(e,['activate',B,'acme','--no-proof'],'already-active',`${B} is the pointer's copy`);
|
||||
const r=await prun(e,'activate',B,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
await refused(e,['activate',B,'acme','--no-send'],'already-active',`${B} is the pointer's copy`);
|
||||
const g=await mk(t);await seedPreview(g);
|
||||
await refused(g,['activate',B,'acme','--no-send'],'already-active',`${B} is the pointer's copy`);
|
||||
for(const x of [e,g])await noLeak(x);
|
||||
});
|
||||
await t.test('test 3: --plan prints one send: line in each case, with the live-copy note on a same-copy run',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send']);
|
||||
let out=await planOnly(e,0,'activate',B,'acme','--no-send');
|
||||
assert.deepEqual(sendLines(out),['send: dropped; --no-send was given']);
|
||||
assert.deepEqual(out.filter((l)=>l.startsWith('note: ')),[LIVE_NOTE]);
|
||||
assert.equal(out[out.indexOf('send: dropped; --no-send was given')+1],`Console: scripts/webui-service.sh install --copy ${B} --copies ${COPIES} ${noSend(base).join(' ')}`);
|
||||
out=await planOnly(e,0,'activate',C1,'acme');
|
||||
assert.deepEqual(sendLines(out),['send: carried']);assert.deepEqual(out.filter((l)=>l.startsWith('note: ')),[]);
|
||||
assert.ok(out.find((l)=>l.startsWith('Console: ')).endsWith(' --send'));
|
||||
assert.deepEqual(sendLines(await planOnly(e,0,'activate',C1,'acme','--no-send')),['send: dropped; --no-send was given']);
|
||||
let r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,noSend(base));assert.match(stepText(e),/Z drop --send; --no-send was given\n/);
|
||||
assert.deepEqual(sendLines(await planOnly(e,0,'activate',B,'acme','--no-send')),['send: off; --no-send was given and the pointer holds no --send']);
|
||||
assert.deepEqual(sendLines(await planOnly(e,0,'activate',B,'acme')),['send: off']);
|
||||
assert.deepEqual(sendLines(await planOnly(e,0,'rollback','acme')),['send: off']);
|
||||
r=await prun(e,'activate',B,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.match(stepText(e),/Z --no-send was given and the pointer holds no --send, so there is nothing to drop\n/);
|
||||
assert.doesNotMatch(stepText(e),/drop --send/);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('test 4: after --no-send, a rollback to the earlier copy leaves --send out of the pointer and the unit',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send']);
|
||||
let r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal(pointer(e).copy,B);assert.deepEqual(pointer(e).console,noSend(base));assert.doesNotMatch(conExec(e),/ --send( |$)/);
|
||||
assert.doesNotMatch(stepText(e),/--send/);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('test 4: pm-on after --no-send keeps --send off',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,{pm:true},['--send']);
|
||||
let r=await prun(e,'activate',C1,'acme','--pm-after-harness','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
biz(e,HARNESS);
|
||||
r=await prun(e,'pm-on','acme');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.equal(pointer(e).pm,true);assert.deepEqual(pointer(e).console,noSend(base));assert.doesNotMatch(conExec(e),/ --send( |$)/);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('test 5: --no-send on rollback, pm-on, status, adopt or rehearse is a usage error',async(t)=>{
|
||||
const e=await mk(t);await seedPreview(e,undefined,['--send']);
|
||||
const usageText=(await prun(e,'--help')).err,n=calls(e).length,logs=logLines(e).length,before=fp(e);
|
||||
for(const args of [['rollback','acme','--no-send'],['rollback','acme','--plan','--no-send'],['pm-on','acme','--no-send'],['status','acme','--no-send'],['adopt','acme','--no-send'],
|
||||
['rehearse','acme','--backup','/tmp/x','--port','7400','--no-send']]){
|
||||
const r=await prun(e,...args);assert.equal(r.code,4,args.join(' '));assert.equal(r.out,'');assert.equal(r.err,usageText);
|
||||
}
|
||||
assert.match(usageText,/\| --no-proof\] \[--no-send\]/);
|
||||
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
|
||||
await noLeak(e);
|
||||
});
|
||||
await t.test('test 6: with both a pair and --send in the pointer, --no-send and --no-proof each drop what they name and keep the rest',async(t)=>{
|
||||
const e=await mk(t),base=await seedPreview(e,undefined,['--send']),pre=noSend(base),f=await proofFile(e),g=await proofFile(e,'proof2.189',0o400);
|
||||
let r=await prun(e,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,[...pre,...pair(f),'--send']);
|
||||
let out=await planOnly(e,0,'activate',C1,'acme','--no-proof');
|
||||
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: dropped; --no-proof drops header x-console-proof','send: carried']);
|
||||
out=await planOnly(e,0,'activate',C1,'acme','--no-send');
|
||||
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: carried, header x-console-proof','send: dropped; --no-send was given']);
|
||||
out=await planOnly(e,0,'activate',B,'acme','--no-send','--no-proof');
|
||||
assert.deepEqual([...proofLines(out),...sendLines(out)],['proof: dropped; --no-proof drops header x-console-proof','send: dropped; --no-send was given']);
|
||||
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,[...pre,...pair(f)]);
|
||||
r=await prun(e,'activate',C1,'acme','--no-send',...pair(g,'X-Proof-Two'));assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(e).console,[...pre,...pair(g,'X-Proof-Two')]);
|
||||
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
const h=await mk(t),hbase=await seedPreview(h,undefined,['--send']),hpre=noSend(hbase),k=await proofFile(h);
|
||||
r=await prun(h,'activate',B,'acme',...pair(k));assert.equal(r.code,0,r.out+r.err);
|
||||
r=await prun(h,'activate',C1,'acme','--no-proof');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(h).console,[...hpre,'--send']);
|
||||
r=await prun(h,'activate',C1,'acme','--no-send','--no-proof');assert.equal(r.code,0,r.out+r.err);
|
||||
assert.deepEqual(pointer(h).console,hpre);assert.doesNotMatch(conExec(h),/--proof-| --send( |$)/);
|
||||
const steps=stepText(h);
|
||||
assert.match(steps,/Z --no-proof was given and the pointer holds no proof pair, so there is nothing to drop\n/);assert.match(steps,/Z drop --send; --no-send was given\n/);
|
||||
r=await prun(h,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
|
||||
for(const x of [e,h])await noLeak(x);
|
||||
});
|
||||
});
|
||||
|
||||
// --- rehearse (D2, row 163; the brief's "Stack rehearsal on a scratch root") ---
|
||||
// Copies of their own in RCOPIES: the real scripts/mosaic, config loader, business loader, roles and
|
||||
// mosaic-config.mjs, with stand-ins for the bus host (cli.mjs), the Console, the Store and
|
||||
@@ -2084,3 +2229,154 @@ 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);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -32,16 +49,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) {
|
||||
@@ -159,10 +272,11 @@ function validate(document, file) {
|
||||
};
|
||||
}
|
||||
|
||||
function load(file) {
|
||||
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 {
|
||||
@@ -177,67 +291,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);
|
||||
}
|
||||
}
|
||||
|
||||
function bootstrapDefault(file) {
|
||||
if (fs.existsSync(file)) {
|
||||
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")) {
|
||||
load(file);
|
||||
return;
|
||||
}
|
||||
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.
|
||||
function bootstrapNamed(file, instance) {
|
||||
if (fs.existsSync(file)) {
|
||||
const resolved = 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 = load(file, instance);
|
||||
checkClaim(resolved.dataRoot, instance);
|
||||
return;
|
||||
}
|
||||
const resolved = 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.
|
||||
function selected(instance) {
|
||||
const file = configPath(instance);
|
||||
let dataRoot = "";
|
||||
if (isNamed(instance)) {
|
||||
dataRoot = load(file, instance).dataRoot;
|
||||
checkClaim(dataRoot, instance);
|
||||
} else {
|
||||
let resolved = null;
|
||||
try {
|
||||
resolved = readRaw(file) === null ? null : 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");
|
||||
}
|
||||
|
||||
function loadSelected(instance) {
|
||||
const file = configPath(instance);
|
||||
const resolved = 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)) {
|
||||
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)) bootstrapNamed(file, instance);
|
||||
else 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") {
|
||||
load(file);
|
||||
process.exit(0);
|
||||
}
|
||||
fail(1, `unable to create configuration: ${error.message}`);
|
||||
} finally {
|
||||
if (fd !== undefined) fs.closeSync(fd);
|
||||
|
||||
case "validate": {
|
||||
const resolved = loadSelected(instance);
|
||||
process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
load(file);
|
||||
process.stderr.write(`mosaic-config: created default configuration: ${file}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
case "validate": {
|
||||
const resolved = load(file);
|
||||
process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
|
||||
process.exit(0);
|
||||
}
|
||||
case "env": {
|
||||
const resolved = 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 = 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(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
@@ -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
|
||||
|
||||
+105
-37
@@ -3,7 +3,7 @@
|
||||
# Moves the bus, the Console and the human CLI from the pointer's copy to another copy together,
|
||||
# behind a health gate, and puts the old stack back inside the same run when the gate fails.
|
||||
# Console tester listener flags follow target support (row 176, #1674); so does the row 178 proof
|
||||
# pair, which activate sets or drops (row 187, #1685).
|
||||
# pair, which activate sets or drops (row 187, #1685). activate --no-send drops --send (row 189, #1687).
|
||||
# Design and tests: agents/darkwing/work/queue-162/BUILD.md.
|
||||
#
|
||||
# Who runs it. Decision 96 (Mos, mos-0716) lets this script stop and start the live units under
|
||||
@@ -24,24 +24,26 @@ 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 R_BACKUP="" R_PORT=""
|
||||
PROOF_HEADER="" PROOF_FILE="" NO_PROOF=0 NO_SEND=0 R_BACKUP="" R_PORT=""
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'USAGE'
|
||||
usage: stack-release.sh adopt BUSINESS
|
||||
stack-release.sh activate ID BUSINESS [--pm | --pm-after-harness] [--migrate] [--tester-port N]
|
||||
[--proof-header NAME --proof-file PATH | --no-proof] [--timeout S] [--plan]
|
||||
[--proof-header NAME --proof-file PATH | --no-proof] [--no-send] [--timeout S] [--plan]
|
||||
stack-release.sh pm-on BUSINESS [--timeout S]
|
||||
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() {
|
||||
@@ -85,6 +90,7 @@ parse_opts() {
|
||||
--proof-header) [ "$OP" = activate ] && [ $# -ge 2 ] && [ -n "$2" ] || usage; PROOF_HEADER="$2"; shift 2 ;;
|
||||
--proof-file) [ "$OP" = activate ] && [ $# -ge 2 ] && [ -n "$2" ] || usage; PROOF_FILE="$2"; shift 2 ;;
|
||||
--no-proof) [ "$OP" = activate ] || usage; NO_PROOF=1; shift ;;
|
||||
--no-send) [ "$OP" = activate ] || usage; NO_SEND=1; shift ;;
|
||||
--migrate) [ "$OP" = activate ] || usage; MIGRATE=1; shift ;;
|
||||
--restore) [ "$OP" = rollback ] && [ $# -ge 2 ] && [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || usage; RESTORE="$2"; shift 2 ;;
|
||||
--backup) [ "$OP" = rehearse ] && [ $# -ge 2 ] && { [[ "$2" =~ ^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{12}(-[1-9])?$ ]] || [[ "$2" =~ ^/[A-Za-z0-9/._+-]*$ ]]; } || usage; R_BACKUP="$2"; shift 2 ;;
|
||||
@@ -244,14 +250,16 @@ target_schema() {
|
||||
in_chain() { local v; for v in "${T_CHAIN[@]}"; do [ "$v" != "$1" ] || return 0; done; return 1; }
|
||||
|
||||
# The item 3 checks on the target copy, in order: copy, schema, already-active. An activate that
|
||||
# sets a proof pair, or drops the pointer's pair with --no-proof, may name the pointer's copy: it
|
||||
# re-pins the live copy with new Console flags (row 187, Part B's B3 and B-R1).
|
||||
# sets a proof pair, drops the pointer's pair with --no-proof, or drops the pointer's --send with
|
||||
# --no-send, may name the pointer's copy: it re-pins the live copy with new Console flags (row 187,
|
||||
# Part B's B3 and B-R1; row 189, mos-0972).
|
||||
check_target() {
|
||||
copy_exists "$TO" 2>/dev/null || refuse copy-missing "no copy $TO in $COPIES"
|
||||
copy_ok "$TO" 2>/dev/null || refuse copy-unreachable "$TO is not reachable from refactor and origin/refactor"
|
||||
target_schema "$TO" || refuse copy-schema-unknown "cannot read SCHEMA_VERSION and its chain from $COPIES/$TO/packages/bus/src/store.mjs"
|
||||
[ "$TO" != "$P_COPY" ] || [ -n "$PROOF_HEADER" ] ||
|
||||
{ [ "$NO_PROOF" = 1 ] && [[ " ${P_CONSOLE[*]} " == *" --proof-header "* ]]; } ||
|
||||
{ [ "$NO_SEND" = 1 ] && [[ " ${P_CONSOLE[*]} " == *" --send "* ]]; } ||
|
||||
refuse already-active "$TO is the pointer's copy"
|
||||
}
|
||||
|
||||
@@ -413,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"
|
||||
@@ -455,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"
|
||||
}
|
||||
|
||||
@@ -483,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; }
|
||||
@@ -530,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/"}"
|
||||
@@ -542,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"
|
||||
}
|
||||
@@ -604,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
|
||||
@@ -618,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
|
||||
@@ -681,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"
|
||||
@@ -690,6 +703,33 @@ console_for_target() {
|
||||
DROP_TESTER="--tester-port $inherited"
|
||||
fi
|
||||
proof_for_target
|
||||
send_for_target
|
||||
}
|
||||
|
||||
# Row 189 (mos-0972): --no-send drops --send from RUN_CONSOLE, so the unit and the pointer both lose
|
||||
# it; without the flag the pointer's --send goes on unchanged. Rollback and pm-on never take it, so
|
||||
# they carry the pointer as it is. Sets SEND_PLAN (the plan's send: line) and SEND_STEP.
|
||||
send_for_target() {
|
||||
local had=0 i
|
||||
local -a without_send=()
|
||||
for ((i = 0; i < ${#RUN_CONSOLE[@]}; i++)); do
|
||||
if [ "${RUN_CONSOLE[i]}" = --send ]; then had=1; else without_send+=("${RUN_CONSOLE[i]}"); fi
|
||||
done
|
||||
SEND_STEP=""
|
||||
if [ "$NO_SEND" = 1 ]; then
|
||||
RUN_CONSOLE=("${without_send[@]}")
|
||||
if [ "$had" = 1 ]; then
|
||||
SEND_PLAN="send: dropped; --no-send was given"
|
||||
SEND_STEP="drop --send; --no-send was given"
|
||||
else
|
||||
SEND_PLAN="send: off; --no-send was given and the pointer holds no --send"
|
||||
SEND_STEP="--no-send was given and the pointer holds no --send, so there is nothing to drop"
|
||||
fi
|
||||
elif [ "$had" = 1 ]; then
|
||||
SEND_PLAN="send: carried"
|
||||
else
|
||||
SEND_PLAN="send: off"
|
||||
fi
|
||||
}
|
||||
|
||||
# Row 187: the row 178 proof pair in RUN_CONSOLE. A new pair replaces the pointer's and goes last;
|
||||
@@ -783,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"
|
||||
@@ -827,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"
|
||||
@@ -940,11 +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 "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 "$SEND_PLAN"
|
||||
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
|
||||
@@ -956,6 +1018,7 @@ run_steps() {
|
||||
capture || refuse capture-failed "could not write the rollback set $RS; nothing was stopped"
|
||||
[ -z "$DROP_TESTER" ] || say "drop $DROP_TESTER; the target Console does not support it"
|
||||
[ -z "$PROOF_STEP" ] || say "$PROOF_STEP"
|
||||
[ -z "$SEND_STEP" ] || say "$SEND_STEP"
|
||||
trap 'after_fail signal' INT TERM HUP
|
||||
stop_both || rollback_in_run stop-timeout
|
||||
if [ -e "$STORE" ]; then
|
||||
@@ -1015,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
|
||||
@@ -1027,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"
|
||||
@@ -1121,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"
|
||||
@@ -1141,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
|
||||
@@ -1405,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
|
||||
@@ -1457,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=""
|
||||
@@ -1469,16 +1536,17 @@ 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="" PLAN_REFUSE=()
|
||||
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=()
|
||||
R="" R_RC=0 R_EARLY=() R_BK="" R_HOST_PID="" R_CONSOLE_PID="" R_STARTED="" LIVE_CFG="" LIVE_BIZ=""
|
||||
case "$OP" in
|
||||
adopt) do_adopt ;;
|
||||
|
||||
@@ -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
@@ -62,12 +62,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] [--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."
|
||||
@@ -83,6 +104,7 @@ ALLOW_WORKTREE=0
|
||||
RELOAD=1
|
||||
DIR_FLAGS=0
|
||||
PORT=7332
|
||||
PORT_SET=0
|
||||
TESTER_PORT=""
|
||||
TESTER_SET=0
|
||||
BUSINESS=""
|
||||
@@ -104,7 +126,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 ;;
|
||||
@@ -122,6 +144,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
|
||||
@@ -249,7 +296,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
|
||||
@@ -269,16 +316,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"
|
||||
@@ -298,13 +346,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")"
|
||||
@@ -326,11 +376,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
|
||||
}
|
||||
@@ -342,7 +392,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"
|
||||
@@ -426,6 +476,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 "$@" ;;
|
||||
|
||||
Reference in New Issue
Block a user