Compare commits
5 Commits
b55deb4cc3
...
chore/ci-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02cf88d0dd | ||
| 94e5cd7a81 | |||
| 4e84f8e850 | |||
| cf8ceb3095 | |||
| bf2a6745c8 |
4
.npmrc
4
.npmrc
@@ -1 +1,5 @@
|
|||||||
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
||||||
|
# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci),
|
||||||
|
# so the pipeline `pnpm install --prefer-offline` consumes the baked store
|
||||||
|
# instead of repopulating a fresh one.
|
||||||
|
store-dir=/root/.local/share/pnpm/store
|
||||||
|
|||||||
40
.woodpecker/ci-image.yml
Normal file
40
.woodpecker/ci-image.yml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Build & push the pre-baked CI base image (Dockerfile.ci) to the Gitea
|
||||||
|
# registry CI already publishes to. Reuses the exact kaniko + auth pattern
|
||||||
|
# from publish.yml (REGISTRY_USER/REGISTRY_PASS from_secret, /kaniko/.docker
|
||||||
|
# config.json). Other pipelines (ci.yml, publish.yml) pull `ci-base:latest`
|
||||||
|
# for their install step.
|
||||||
|
#
|
||||||
|
# Rebuild ONLY when the dependency set or the image recipe changes — a normal
|
||||||
|
# code push must not trigger a 25-min image build. `path` applies to push/PR
|
||||||
|
# events; `event: tag` (releases) rebuilds unconditionally so a tagged release
|
||||||
|
# always ships a fresh base.
|
||||||
|
when:
|
||||||
|
- event: tag
|
||||||
|
- event: [push, manual]
|
||||||
|
branch: main
|
||||||
|
path:
|
||||||
|
include:
|
||||||
|
- 'pnpm-lock.yaml'
|
||||||
|
- 'Dockerfile.ci'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
build-ci-base:
|
||||||
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
|
environment:
|
||||||
|
REGISTRY_USER:
|
||||||
|
from_secret: gitea_username
|
||||||
|
REGISTRY_PASS:
|
||||||
|
from_secret: gitea_password
|
||||||
|
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||||
|
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||||
|
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||||
|
commands:
|
||||||
|
- mkdir -p /kaniko/.docker
|
||||||
|
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||||
|
- |
|
||||||
|
# Lockfile-hash tag: an immutable identity for the exact dep set baked
|
||||||
|
# into this image. `:latest` is the mutable pointer pipelines consume.
|
||||||
|
LOCK_HASH=$(sha256sum pnpm-lock.yaml | cut -c1-12)
|
||||||
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/ci-base:latest"
|
||||||
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/ci-base:lock-$LOCK_HASH"
|
||||||
|
/kaniko/executor --context . --dockerfile Dockerfile.ci $DESTINATIONS
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
|
# &node_image is the pre-baked CI base built by .woodpecker/ci-image.yml:
|
||||||
|
# node:24-alpine + python3/make/g++/postgresql-client + pnpm + a warm pnpm
|
||||||
|
# store. The install step resolves from the baked store (--prefer-offline)
|
||||||
|
# instead of paying a ~731s cold fetch + native compile every run.
|
||||||
variables:
|
variables:
|
||||||
- &node_image 'node:22-alpine'
|
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
|
||||||
- &enable_pnpm 'corepack enable'
|
- &enable_pnpm 'corepack enable'
|
||||||
|
|
||||||
when:
|
when:
|
||||||
@@ -15,8 +19,9 @@ steps:
|
|||||||
image: *node_image
|
image: *node_image
|
||||||
commands:
|
commands:
|
||||||
- corepack enable
|
- corepack enable
|
||||||
- apk add --no-cache python3 make g++
|
# python3/make/g++ are baked into ci-base; --prefer-offline resolves from
|
||||||
- pnpm install --frozen-lockfile
|
# the baked pnpm store.
|
||||||
|
- pnpm install --frozen-lockfile --prefer-offline
|
||||||
|
|
||||||
# Blocking gate: public framework package must contain no operator-specific
|
# Blocking gate: public framework package must contain no operator-specific
|
||||||
# personal data or private $HOME defaults. Runs early (no node_modules needed).
|
# personal data or private $HOME defaults. Runs early (no node_modules needed).
|
||||||
@@ -64,8 +69,7 @@ steps:
|
|||||||
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
|
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *enable_pnpm
|
||||||
# Install postgresql-client for pg_isready
|
# postgresql-client (pg_isready) is baked into ci-base.
|
||||||
- apk add --no-cache postgresql-client
|
|
||||||
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
|
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
|
||||||
- |
|
- |
|
||||||
ready=0
|
ready=0
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
# Runs only on main branch push/tag
|
# Runs only on main branch push/tag
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
- &node_image 'node:22-alpine'
|
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
||||||
|
# toolchain + warm pnpm store. Kills the second cold install publish pays.
|
||||||
|
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
|
||||||
- &enable_pnpm 'corepack enable'
|
- &enable_pnpm 'corepack enable'
|
||||||
# Heavy kaniko image builds (~25 min) — gate them so a merge that only touches
|
# Heavy kaniko image builds (~25 min) — gate them so a merge that only touches
|
||||||
# the npm-only CLI (@mosaicstack/mosaic) or docs does NOT rebuild the platform
|
# the npm-only CLI (@mosaicstack/mosaic) or docs does NOT rebuild the platform
|
||||||
@@ -31,7 +33,8 @@ steps:
|
|||||||
image: *node_image
|
image: *node_image
|
||||||
commands:
|
commands:
|
||||||
- corepack enable
|
- corepack enable
|
||||||
- pnpm install --frozen-lockfile
|
# Resolve from the baked pnpm store instead of a cold network fetch.
|
||||||
|
- pnpm install --frozen-lockfile --prefer-offline
|
||||||
|
|
||||||
build:
|
build:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
|
|||||||
45
Dockerfile.ci
Normal file
45
Dockerfile.ci
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Pre-baked CI base image for Woodpecker pipelines.
|
||||||
|
#
|
||||||
|
# Purpose: eliminate the cold `pnpm install` that dominates every pipeline
|
||||||
|
# (~731s median). This image ships the native toolchain (no per-run `apk add`)
|
||||||
|
# AND a warm, content-addressable pnpm store with the dependency-tree tarballs
|
||||||
|
# already fetched at build time. `pnpm fetch` only populates the store from the
|
||||||
|
# lockfile — it does NOT run the native node-gyp builds (better-sqlite3,
|
||||||
|
# node-pty, sqlite3, canvas, sharp); those still compile at `pnpm install`,
|
||||||
|
# which is exactly why the musl toolchain stays baked into this image. A
|
||||||
|
# pipeline `pnpm install --frozen-lockfile --prefer-offline` then resolves
|
||||||
|
# tarballs from local hard-links (no network) and compiles natives against the
|
||||||
|
# already-present toolchain, in tens of seconds instead of ~731s.
|
||||||
|
#
|
||||||
|
# Rebuilt only when `pnpm-lock.yaml` or this Dockerfile change
|
||||||
|
# (see .woodpecker/ci-image.yml).
|
||||||
|
#
|
||||||
|
# Node version is pinned to 24 (Active LTS). This is the follow-up bump from
|
||||||
|
# node:22 — sequenced AFTER the CI cache work landed so the runtime change
|
||||||
|
# carries zero cache variables. node:26 stays held until it reaches LTS
|
||||||
|
# (Oct 2026); the Current line risks native-module (node-gyp) breakage on a
|
||||||
|
# runner that compiles better-sqlite3 / canvas / sharp / node-pty from source.
|
||||||
|
FROM node:24-alpine
|
||||||
|
|
||||||
|
# Native toolchain required to compile node-gyp deps on musl, plus the
|
||||||
|
# postgresql-client used by the test step's pg_isready readiness probe. `bash`
|
||||||
|
# is baked here too — the sanitization step in ci.yml otherwise does a per-run
|
||||||
|
# `apk add bash`.
|
||||||
|
RUN apk add --no-cache python3 make g++ postgresql-client bash
|
||||||
|
|
||||||
|
# Pin pnpm to the repo's packageManager version via corepack.
|
||||||
|
RUN corepack enable && corepack prepare pnpm@10.6.2 --activate
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Pin the store location so the pipeline can point `store-dir` at the same path.
|
||||||
|
ENV PNPM_HOME=/root/.local/share/pnpm
|
||||||
|
RUN pnpm config set store-dir /root/.local/share/pnpm/store
|
||||||
|
|
||||||
|
# Warm the store. `pnpm fetch` populates the content-addressable store with the
|
||||||
|
# dependency tarballs directly from the lockfile (no package.json / workspace
|
||||||
|
# needed), so a baked store stays valid until the lockfile changes. Note:
|
||||||
|
# `fetch` does NOT compile native modules — that happens later at `pnpm install`
|
||||||
|
# in the pipeline, against the toolchain baked above.
|
||||||
|
COPY pnpm-lock.yaml ./
|
||||||
|
RUN pnpm fetch --frozen-lockfile
|
||||||
@@ -82,3 +82,11 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
|||||||
## north-star doctrine consolidation — doc PR — feat/north-star-doctrine
|
## north-star doctrine consolidation — doc PR — feat/north-star-doctrine
|
||||||
|
|
||||||
- Status: applied Mos's consolidated merge-map to docs/fleet/north-star.md (budget governance + control plane/central register + 200k cap + delegation + unified-identity Fleet + role-based naming + tmux security + drift re-captures). Doctrine only; #622/#623/#625/#628 out-of-scope. Conflict checklist green. Detail: scratchpads/north-star-doctrine.md.
|
- Status: applied Mos's consolidated merge-map to docs/fleet/north-star.md (budget governance + control plane/central register + 200k cap + delegation + unified-identity Fleet + role-based naming + tmux security + drift re-captures). Doctrine only; #622/#623/#625/#628 out-of-scope. Conflict checklist green. Detail: scratchpads/north-star-doctrine.md.
|
||||||
|
|
||||||
|
## #631 — re-seed preserves user fleet data (CRITICAL) — fix/631-reseed-preserves-fleet-data
|
||||||
|
|
||||||
|
- Status: implemented + tested. PRIMARY: install.sh PRESERVE_PATHS += fleet/\*.yaml + fleet/agents + fleet/run (glob-aware cp-fallback); TS parity. SECONDARY: refreshActiveFleetUnits propagates unit fixes to ~/.config/systemd/user on mosaic update. bash F6 + TS + unit tests green. Detail: scratchpads/631-reseed-preserves-fleet.md.
|
||||||
|
|
||||||
|
## #633 — comms-block emitter + FLEET-LAUNCH runbook — feat/633-comms-block-runbook
|
||||||
|
|
||||||
|
- Status: implemented + tested (TDD). `mosaic fleet comms-block <role> [--host]` wraps resolveCommsBlock → readFleetCommsBlock; fails loud (stderr + exit 1) on unknown role / missing roster instead of silent empty. docs/fleet/FLEET-LAUNCH.md runbook: worker path + orchestrator .env fold (MOSAIC_AGENT_COMMAND; line-41 [-z] short-circuits line-44 yolo hardcode) + 3 launch gotchas + #632 preserve note + North-Star 4-field arc (harness ✅/model ✅ roster-native today; yolo + command/channels = PATH B #636). 177 fleet+comms tests green (6 new resolveCommsBlock cases). PATH A of the A→B→webUI arc. Detail: scratchpads/633-comms-block-runbook.md.
|
||||||
|
|||||||
114
docs/fleet/FLEET-LAUNCH.md
Normal file
114
docs/fleet/FLEET-LAUNCH.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# Fleet Launch Runbook
|
||||||
|
|
||||||
|
How every Mosaic fleet agent — workers **and** the orchestrator — is launched, and how to
|
||||||
|
configure each one. The guiding principle: **one roster-driven launcher**. There is no bespoke
|
||||||
|
per-agent launch script; the roster plus per-agent `.env` files are the single source of launch
|
||||||
|
config.
|
||||||
|
|
||||||
|
## The launch chain
|
||||||
|
|
||||||
|
| Layer | File | Responsibility |
|
||||||
|
| ---------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| systemd unit | `mosaic-agent@<role>.service` | One templated unit per role; `ExecStart` runs the session launcher with the instance name `%i`. Defaults `MOSAIC_AGENT_RUNTIME=pi`, `MOSAIC_AGENT_NAME=%i`. |
|
||||||
|
| session launcher | `tools/fleet/start-agent-session.sh <role>` | Builds the launch command, opens the tmux pane, wires the heartbeat. |
|
||||||
|
| launch command | `mosaic yolo <runtime>` (or a per-agent override) | Replaces the pane's foreground process with the runtime, fully seeded. |
|
||||||
|
| seeding | `mosaic`'s `composeContract()` | Injects the Constitution/USER/TOOLS/runtime contract, `*.local` overlays, **and** the Fleet-Comms cheat-sheet — all via `--append-system-prompt`. |
|
||||||
|
|
||||||
|
Per-agent overrides live in `fleet/agents/<role>.env`, generated from `roster.yaml` by
|
||||||
|
`generateAgentEnv` (`packages/mosaic/src/commands/fleet.ts`) and consumed by the launcher.
|
||||||
|
|
||||||
|
## Worker launch path (default)
|
||||||
|
|
||||||
|
1. `roster.yaml` carries each agent's `runtime` and optional `model_hint`.
|
||||||
|
2. `generateAgentEnv` emits `fleet/agents/<role>.env` with `MOSAIC_AGENT_NAME`,
|
||||||
|
`MOSAIC_AGENT_RUNTIME`, and `MOSAIC_AGENT_MODEL`.
|
||||||
|
3. `start-agent-session.sh` has no `MOSAIC_AGENT_COMMAND` set, so it falls through to the default
|
||||||
|
(line ~44):
|
||||||
|
```sh
|
||||||
|
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}"
|
||||||
|
```
|
||||||
|
4. The launcher bakes `MOSAIC_AGENT_NAME` into the pane command (line ~118), so `composeContract`
|
||||||
|
can inject the Fleet-Comms cheat-sheet for that role.
|
||||||
|
|
||||||
|
That is the whole worker path: roster → `.env` → `mosaic yolo <runtime>` → seeded pane.
|
||||||
|
|
||||||
|
## Orchestrator fold (PATH A — ships today)
|
||||||
|
|
||||||
|
The orchestrator is **just another roster agent** launched through the canonical path — not a
|
||||||
|
snowflake script.
|
||||||
|
|
||||||
|
| Piece | Value |
|
||||||
|
| ------------------ | ----------------------------------- |
|
||||||
|
| host-side launcher | `orchestrator-launch.sh` |
|
||||||
|
| systemd unit | `mosaic-fleet-orchestrator.service` |
|
||||||
|
| tmux session | `orchestrator` (role-named) |
|
||||||
|
|
||||||
|
Set its launch command via `fleet/agents/orchestrator.env`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
MOSAIC_AGENT_COMMAND='mosaic yolo claude --channels plugin:discord@<channel>'
|
||||||
|
```
|
||||||
|
|
||||||
|
When `MOSAIC_AGENT_COMMAND` is set, `start-agent-session.sh`'s `if [ -z "$MOSAIC_AGENT_COMMAND" ]`
|
||||||
|
guard (line ~41) is false, so the line-44 default — **including its hardcoded `yolo`** — is skipped
|
||||||
|
entirely. The override fully controls the runtime and flags. Routing through `mosaic yolo claude`
|
||||||
|
(rather than a raw `claude` invocation) is what gives the orchestrator the same full
|
||||||
|
`composeContract` seeding + Fleet-Comms cheat-sheet as every worker, with `--channels` and any
|
||||||
|
other flags passed straight through to the `claude` binary.
|
||||||
|
|
||||||
|
## Launch gotchas
|
||||||
|
|
||||||
|
1. **Flag conflict.** `mosaic yolo claude` already injects `--dangerously-skip-permissions`. Do
|
||||||
|
**not** also pass `--permission-mode bypassPermissions` — the `claude` binary would receive both.
|
||||||
|
Use `mosaic yolo claude …` alone (yolo covers the unattended posture), **or** non-yolo
|
||||||
|
`mosaic claude --permission-mode bypassPermissions …`. Never mix the two.
|
||||||
|
2. **`MOSAIC_AGENT_NAME` must reach the pane.** The launcher bakes it from the instance name, and
|
||||||
|
`composeContract` gates the Fleet-Comms block on it (`launch.ts`, in `composeContract`) — **and**
|
||||||
|
the role must be a member of `roster.yaml`, or the block resolves empty.
|
||||||
|
3. **`launchRuntime` guards.** `mosaic yolo claude` runs `checkSoul` / `checkRuntime` /
|
||||||
|
`checkSequentialThinking`. The host needs `SOUL.md` and the sequential-thinking MCP, or the
|
||||||
|
launch aborts (a raw `claude` invocation skipped these checks). Dry-run the composed command in a
|
||||||
|
throwaway tmux session before swapping a live launcher.
|
||||||
|
|
||||||
|
## Why per-agent `.env` survives upgrades (#632)
|
||||||
|
|
||||||
|
`install.sh` `PRESERVE_PATHS` includes `fleet/*.yaml`, `fleet/agents`, and `fleet/run`, so
|
||||||
|
`mosaic update`'s framework re-seed **preserves** your roster and per-agent `.env` overrides
|
||||||
|
(glob-aware `cp` fallback; matching TS parity in `file-adapter.ts`). Before #632, an auto re-seed
|
||||||
|
could wipe them — which is exactly why PATH A's `.env` override is safe to rely on now.
|
||||||
|
|
||||||
|
## Inspecting the comms wiring
|
||||||
|
|
||||||
|
- `mosaic fleet comms-block <role>` prints the Fleet-Comms cheat-sheet a given role receives at
|
||||||
|
launch — its `[host:session]` identity, the exact `agent-send.sh` command for each peer, and the
|
||||||
|
FLIP / `--verify` conventions. `--host <h>` previews a cross-host view. An unknown role or missing
|
||||||
|
roster **fails loud** (stderr + non-zero exit), so a typo is never a silent no-op.
|
||||||
|
- Versus `mosaic compose-contract <runtime>`: that emits the **whole** system prompt and reads the
|
||||||
|
role from `MOSAIC_AGENT_NAME` (a full-prompt smoke test). `comms-block` is the targeted,
|
||||||
|
explicit-arg, comms-only view — e.g. `mosaic fleet comms-block coder0-0` to preview a peer.
|
||||||
|
|
||||||
|
## North Star / future direction
|
||||||
|
|
||||||
|
**Vision:** a webUI lets the user edit each agent's launch config — switch **harness**
|
||||||
|
(claude / pi / codex / opencode), toggle **yolo**, pick a **model**, set a **command/channels**
|
||||||
|
override — with no terminal.
|
||||||
|
|
||||||
|
**Continuity — this is not a new launch path.** It is a data-model + UI-binding layer over the
|
||||||
|
existing roster-driven launcher. Field-by-field status today:
|
||||||
|
|
||||||
|
| Launch-config field | Roster-native today? | Mechanism / gap |
|
||||||
|
| ------------------------ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **harness** (`runtime`) | ✅ end-to-end | `roster.runtime` → `generateAgentEnv` emits `MOSAIC_AGENT_RUNTIME` → launcher line 44. UI just writes the field. |
|
||||||
|
| **model** (`model_hint`) | ✅ end-to-end | `roster.model_hint` → `MOSAIC_AGENT_MODEL` → launcher line 44 `--model`. UI just writes the field. |
|
||||||
|
| **yolo** | ❌ new | Launcher line 44 **hardcodes** `mosaic yolo`. A non-yolo toggle needs a roster `yolo` field → emit `MOSAIC_AGENT_YOLO` → make line 44 conditional. |
|
||||||
|
| **command / channels** | ❌ new | `MOSAIC_AGENT_COMMAND` is **consumed** (launcher line ~12) but `generateAgentEnv` does not emit it. Needs a roster `command`/`channels` field → emitted. |
|
||||||
|
|
||||||
|
**The arc:**
|
||||||
|
|
||||||
|
- **A** — `.env` `MOSAIC_AGENT_COMMAND` hatch: manual, ships now, kept safe across upgrades by #632.
|
||||||
|
- **B** — roster-native launch-config: harness + model are already there; add the **yolo** toggle
|
||||||
|
(line-44 conditional) and **command/channels** emission to complete the data model.
|
||||||
|
- **webUI** — binds dropdowns/toggles directly to those four roster fields.
|
||||||
|
|
||||||
|
PATH A's `.env` override is the **manual form** of exactly what PATH B makes roster-native and the
|
||||||
|
webUI edits — one continuous arc, not three separate features. PATH B is tracked as #636.
|
||||||
32
docs/scratchpads/631-reseed-preserves-fleet.md
Normal file
32
docs/scratchpads/631-reseed-preserves-fleet.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# #631 — re-seed must preserve user fleet data (CRITICAL data-loss)
|
||||||
|
|
||||||
|
- **Issue:** #631 · **Branch:** `fix/631-reseed-preserves-fleet-data`
|
||||||
|
|
||||||
|
## Root cause
|
||||||
|
|
||||||
|
`mosaic update` auto-runs `install.sh` keep-mode sync (#610). install.sh's rsync `--delete` (keep mode)
|
||||||
|
honored PRESERVE_PATHS, but `fleet/` wasn't listed → the sync WIPED `~/.config/mosaic/fleet/roster.yaml`
|
||||||
|
(+ run/, agents/). Any user running `mosaic update` lost their roster. (overwrite mode wipes by design;
|
||||||
|
the live loss was keep mode.)
|
||||||
|
|
||||||
|
## Fix (PRIMARY)
|
||||||
|
|
||||||
|
- install.sh PRESERVE_PATHS += `fleet/*.yaml`, `fleet/agents`, `fleet/run` — the framework still SEEDS
|
||||||
|
fleet/examples + fleet/roles + fleet/roster.schema.json (synced), but user files survive.
|
||||||
|
- Made the cp-fallback (no-rsync) GLOB-AWARE so `fleet/*.yaml` preserves every user roster there too;
|
||||||
|
fixed the restore to re-glob per-pattern (so only the user file is restored, not the whole fleet/ dir).
|
||||||
|
- file-adapter.ts (TS installer): mirrored the preserve list for parity. (TS syncDirectory is copy-only,
|
||||||
|
never --delete, so it never had the bug — belt-and-suspenders + parity.)
|
||||||
|
|
||||||
|
## Fix (SECONDARY)
|
||||||
|
|
||||||
|
- `refreshActiveFleetUnits()` (update-checker.ts): the re-seed updates ~/.config/mosaic/systemd/user but
|
||||||
|
systemd runs ~/.config/systemd/user, so unit fixes (#627) didn't take effect. After the re-seed,
|
||||||
|
`mosaic update` now copies the fresh mosaic-\*.service → the active dir + daemon-reload (best-effort,
|
||||||
|
only when a fleet is already installed). Wired into the cli.ts update flow.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- bash F6 fixture (6 checks: roster/custom-yaml/agents/run survive + examples refreshed + schema seeded);
|
||||||
|
20/20 migration matrix green. TS file-adapter test (roster/run/agents survive keep sync). 2 unit tests
|
||||||
|
for refreshActiveFleetUnits. tsc/eslint/prettier/sanitize clean.
|
||||||
54
docs/scratchpads/633-comms-block-runbook.md
Normal file
54
docs/scratchpads/633-comms-block-runbook.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# #633 — comms-block emitter + FLEET-LAUNCH runbook
|
||||||
|
|
||||||
|
Branch: `feat/633-comms-block-runbook` (off `bf2a6745`, post-#632 merge)
|
||||||
|
Issue: #633 · Follow-up filed: #636 (PATH B)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
PATH A of the orchestrator-launch fix: give every launch path the Fleet-Comms onboarding, and
|
||||||
|
document the canonical roster-driven launcher so the orchestrator stops being a bespoke snowflake.
|
||||||
|
|
||||||
|
## Deliverables
|
||||||
|
|
||||||
|
1. **`mosaic fleet comms-block <role> [--host <h>]`** — explicit-arg, comms-block-only emitter.
|
||||||
|
- Backed by new `resolveCommsBlock(mosaicHome, role, fleetHost?)` in `fleet/comms-onboarding.ts`
|
||||||
|
returning `{ ok, output, error }`.
|
||||||
|
- Unlike `readFleetCommsBlock` (returns `''` on any miss so `composeContract` can no-op silently
|
||||||
|
during launch), the emitter **fails loud**: unknown role / missing roster → `ok:false` → CLI
|
||||||
|
prints to stderr + sets `process.exitCode = 1`. A typo is never a silent no-op.
|
||||||
|
- Distinct from `mosaic compose-contract <runtime>` (whole prompt, env-coupled via
|
||||||
|
`MOSAIC_AGENT_NAME`); comms-block is the targeted, explicit-arg, comms-only view.
|
||||||
|
2. **`docs/fleet/FLEET-LAUNCH.md`** — worker path + orchestrator `.env` fold + 3 launch gotchas +
|
||||||
|
#632 preserve note + North-Star 4-field arc.
|
||||||
|
|
||||||
|
## Key findings (drove the design)
|
||||||
|
|
||||||
|
- `mosaic yolo claude` **already** forwards `--channels`/`--permission-mode` to the binary
|
||||||
|
(`launch.ts` claude case `cliArgs.push(...args)`) AND injects the comms block via
|
||||||
|
`composeContract` → `readFleetCommsBlock(home, env.MOSAIC_AGENT_NAME)`. So no `launch.ts` change
|
||||||
|
was needed — PATH A is `.env` + doc only.
|
||||||
|
- `start-agent-session.sh` line ~41 `[ -z "$MOSAIC_AGENT_COMMAND" ]` short-circuits the line-44
|
||||||
|
default, so an `.env` `MOSAIC_AGENT_COMMAND` override bypasses the hardcoded `yolo` entirely — the
|
||||||
|
yolo-conditional is therefore a PATH B (default-path) concern, not PATH A.
|
||||||
|
- `generateAgentEnv` (`fleet.ts` ~202-207) emits NAME/RUNTIME/MODEL but **not** `MOSAIC_AGENT_COMMAND`
|
||||||
|
— the seam PATH B (#636) closes.
|
||||||
|
|
||||||
|
## A → B → webUI arc (North Star)
|
||||||
|
|
||||||
|
- A = `.env` `MOSAIC_AGENT_COMMAND` hatch (manual, ships now, #632-safe).
|
||||||
|
- B (#636) = roster-native launch-config: harness ✅ + model ✅ already there; add **yolo** (line-44
|
||||||
|
conditional `MOSAIC_AGENT_YOLO`) + **command/channels** (`generateAgentEnv` emission).
|
||||||
|
- webUI binds dropdowns/toggles to those four roster fields. One launcher, no new launch path.
|
||||||
|
|
||||||
|
## Results
|
||||||
|
|
||||||
|
- TDD: spec first (`comms-onboarding.spec.ts`, 6 new `resolveCommsBlock` cases) → red → implement → green.
|
||||||
|
- `fleet.spec.ts` subcommand-list assertion extended with `comms-block`.
|
||||||
|
- 177 fleet+comms tests green; typecheck clean; eslint clean; prettier clean.
|
||||||
|
|
||||||
|
## Risks / notes
|
||||||
|
|
||||||
|
- Pre-existing local-only failure `uninstall.spec.ts > removeFramework > handles missing mosaicHome
|
||||||
|
gracefully` (EACCES on `/nonexistent` as non-root) — unrelated to #633, passes in CI as root.
|
||||||
|
- Did NOT run `mosaic update` / anything auto-reseed: installed CLI still 0.0.40 (roster-wipe live
|
||||||
|
until mos-claude-0 ships 0.0.41). All work is in-repo + vitest, never touches the live mosaic home.
|
||||||
@@ -23,7 +23,15 @@ INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
|||||||
# entries (CONSTITUTION/AGENTS/STANDARDS) ARE re-applied afterward by
|
# entries (CONSTITUTION/AGENTS/STANDARDS) ARE re-applied afterward by
|
||||||
# reconcile_framework_files (overwrite + backup-once); the rest stay user-owned.
|
# reconcile_framework_files (overwrite + backup-once); the rest stay user-owned.
|
||||||
# User-created content in these paths survives rsync --delete.
|
# User-created content in these paths survives rsync --delete.
|
||||||
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials")
|
#
|
||||||
|
# fleet/* — the framework SEEDS only fleet/examples, fleet/roles, and
|
||||||
|
# fleet/roster.schema.json (synced normally). The user's own fleet files MUST
|
||||||
|
# survive `mosaic update` (which runs this sync automatically): the active
|
||||||
|
# roster (`fleet/roster.yaml` + any other `fleet/*.yaml`), per-agent env
|
||||||
|
# (`fleet/agents/`), and heartbeat run dir (`fleet/run/`). Without these, an
|
||||||
|
# update wipes the operator's fleet. Glob entries are honored by both the rsync
|
||||||
|
# path (`--exclude`) and the glob-aware cp fallback below.
|
||||||
|
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials" "fleet/*.yaml" "fleet/agents" "fleet/run")
|
||||||
|
|
||||||
# Framework-owned contract files: re-copied from defaults/ on every upgrade (the
|
# Framework-owned contract files: re-copied from defaults/ on every upgrade (the
|
||||||
# user must not edit them; a divergent copy is backed up once before overwrite).
|
# user must not edit them; a divergent copy is backed up once before overwrite).
|
||||||
@@ -179,15 +187,23 @@ sync_framework() {
|
|||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Fallback: cp-based sync
|
# Fallback: cp-based sync. Glob-aware so entries like "fleet/*.yaml" preserve
|
||||||
|
# every matching user file (parity with the rsync --exclude path above).
|
||||||
local preserve_tmp=""
|
local preserve_tmp=""
|
||||||
if [[ "$INSTALL_MODE" == "keep" ]]; then
|
if [[ "$INSTALL_MODE" == "keep" ]]; then
|
||||||
preserve_tmp="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-preserve-XXXXXX")"
|
preserve_tmp="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-preserve-XXXXXX")"
|
||||||
|
local match rel
|
||||||
for path in "${PRESERVE_PATHS[@]}"; do
|
for path in "${PRESERVE_PATHS[@]}"; do
|
||||||
if [[ -e "$TARGET_DIR/$path" ]]; then
|
# Unquoted $path lets the glob expand against TARGET_DIR; nullglob makes a
|
||||||
mkdir -p "$preserve_tmp/$(dirname "$path")"
|
# non-matching pattern vanish instead of staying literal.
|
||||||
cp -R "$TARGET_DIR/$path" "$preserve_tmp/$path"
|
shopt -s nullglob
|
||||||
fi
|
for match in "$TARGET_DIR/"$path; do
|
||||||
|
[[ -e "$match" ]] || continue
|
||||||
|
rel="${match#"$TARGET_DIR/"}"
|
||||||
|
mkdir -p "$preserve_tmp/$(dirname "$rel")"
|
||||||
|
cp -R "$match" "$preserve_tmp/$rel"
|
||||||
|
done
|
||||||
|
shopt -u nullglob
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -196,12 +212,19 @@ sync_framework() {
|
|||||||
rm -rf "$TARGET_DIR/.git"
|
rm -rf "$TARGET_DIR/.git"
|
||||||
|
|
||||||
if [[ -n "$preserve_tmp" ]]; then
|
if [[ -n "$preserve_tmp" ]]; then
|
||||||
|
# Restore by re-globbing the SAME patterns against preserve_tmp, so each
|
||||||
|
# preserved item is restored at its own relative path (e.g. only
|
||||||
|
# fleet/roster.yaml is replaced — the freshly-synced fleet/examples stays).
|
||||||
for path in "${PRESERVE_PATHS[@]}"; do
|
for path in "${PRESERVE_PATHS[@]}"; do
|
||||||
if [[ -e "$preserve_tmp/$path" ]]; then
|
shopt -s nullglob
|
||||||
rm -rf "$TARGET_DIR/$path"
|
for match in "$preserve_tmp/"$path; do
|
||||||
mkdir -p "$TARGET_DIR/$(dirname "$path")"
|
[[ -e "$match" ]] || continue
|
||||||
cp -R "$preserve_tmp/$path" "$TARGET_DIR/$path"
|
rel="${match#"$preserve_tmp/"}"
|
||||||
fi
|
rm -rf "$TARGET_DIR/$rel"
|
||||||
|
mkdir -p "$TARGET_DIR/$(dirname "$rel")"
|
||||||
|
cp -R "$match" "$TARGET_DIR/$rel"
|
||||||
|
done
|
||||||
|
shopt -u nullglob
|
||||||
done
|
done
|
||||||
rm -rf "$preserve_tmp"
|
rm -rf "$preserve_tmp"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -61,7 +61,25 @@ MOSAIC_HOME="$T5" MOSAIC_INSTALL_MODE=bogus MOSAIC_SYNC_ONLY=1 bash "$INSTALL" >
|
|||||||
chk "F5 failure: invalid mode rejected (nonzero exit)" "[ $rc -ne 0 ]"
|
chk "F5 failure: invalid mode rejected (nonzero exit)" "[ $rc -ne 0 ]"
|
||||||
chk "F5 failure: SOUL + credentials intact" "grep -q orig '$T5/SOUL.md' && grep -q keepme '$T5/credentials/c.json'"
|
chk "F5 failure: SOUL + credentials intact" "grep -q orig '$T5/SOUL.md' && grep -q keepme '$T5/credentials/c.json'"
|
||||||
|
|
||||||
rm -rf "$T1" "$T2" "$T3" "$T4" "$T5"
|
# F6 — keep-mode re-seed (the `mosaic update` path) MUST NOT wipe user fleet data.
|
||||||
|
# Regression for the roster-loss bug: fleet/ was not in PRESERVE_PATHS.
|
||||||
|
T6=$(mktemp -d); mkdir -p "$T6/fleet/examples" "$T6/fleet/run" "$T6/fleet/agents"
|
||||||
|
printf '# persona\n' > "$T6/SOUL.md" # makes it a recognized existing install (→ keep mode)
|
||||||
|
printf 'version: 1\nagents:\n - name: coder0\n' > "$T6/fleet/roster.yaml"
|
||||||
|
printf 'version: 1\nagents:\n - name: custom\n' > "$T6/fleet/my-fleet.yaml"
|
||||||
|
printf 'ts=x\n' > "$T6/fleet/run/coder0.hb"
|
||||||
|
printf 'MOSAIC_AGENT_NAME=coder0\n' > "$T6/fleet/agents/coder0.env"
|
||||||
|
printf '# stale preset\n' > "$T6/fleet/examples/general.yaml"
|
||||||
|
echo 3 > "$T6/.framework-version"
|
||||||
|
run "$T6" keep
|
||||||
|
chk "F6 reseed: user roster.yaml SURVIVES keep-mode sync" "grep -q coder0 '$T6/fleet/roster.yaml'"
|
||||||
|
chk "F6 reseed: other user fleet/*.yaml survives (glob)" "[ -f '$T6/fleet/my-fleet.yaml' ]"
|
||||||
|
chk "F6 reseed: per-agent env (fleet/agents) survives" "[ -f '$T6/fleet/agents/coder0.env' ]"
|
||||||
|
chk "F6 reseed: heartbeat run dir (fleet/run) survives" "[ -f '$T6/fleet/run/coder0.hb' ]"
|
||||||
|
chk "F6 reseed: framework examples ARE refreshed (not preserved stale)" "grep -q orchestrator '$T6/fleet/examples/general.yaml'"
|
||||||
|
chk "F6 reseed: framework roster.schema.json seeded" "[ -f '$T6/fleet/roster.schema.json' ]"
|
||||||
|
|
||||||
|
rm -rf "$T1" "$T2" "$T3" "$T4" "$T5" "$T6"
|
||||||
echo
|
echo
|
||||||
echo "RESULT: $pass passed, $fail failed"
|
echo "RESULT: $pass passed, $fail failed"
|
||||||
[ "$fail" -eq 0 ]
|
[ "$fail" -eq 0 ]
|
||||||
|
|||||||
@@ -2,12 +2,20 @@
|
|||||||
when:
|
when:
|
||||||
- event: [push, pull_request, manual]
|
- event: [push, pull_request, manual]
|
||||||
|
|
||||||
|
# Dependencies are installed ONCE in the `install` step and every downstream
|
||||||
|
# step depends on it, reusing the populated node_modules from the shared
|
||||||
|
# workspace volume. Do NOT re-run `npm ci` per step — that pays the full cold
|
||||||
|
# install (network fetch + native rebuilds) N times and is the dominant cost
|
||||||
|
# in a pipeline.
|
||||||
|
#
|
||||||
|
# For best results, replace `&node_image` with a pre-baked CI base image that
|
||||||
|
# ships your toolchain (python3/make/g++ for native modules) and a warm npm
|
||||||
|
# cache, then keep `--prefer-offline` so installs resolve from the cache. See
|
||||||
|
# the Mosaic Stack repo's Dockerfile.ci + .woodpecker/ci-image.yml for the
|
||||||
|
# baked-image pattern.
|
||||||
variables:
|
variables:
|
||||||
- &node_image 'node:20-alpine'
|
- &node_image 'node:20-alpine'
|
||||||
- &gitleaks_image 'ghcr.io/gitleaks/gitleaks:v8.24.0'
|
- &gitleaks_image 'ghcr.io/gitleaks/gitleaks:v8.24.0'
|
||||||
- &install_deps |
|
|
||||||
corepack enable
|
|
||||||
npm ci --ignore-scripts
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# Secret scanning (runs in parallel with install, no deps)
|
# Secret scanning (runs in parallel with install, no deps)
|
||||||
@@ -17,15 +25,18 @@ steps:
|
|||||||
- gitleaks git --redact --verbose --log-opts="HEAD~1..HEAD"
|
- gitleaks git --redact --verbose --log-opts="HEAD~1..HEAD"
|
||||||
depends_on: []
|
depends_on: []
|
||||||
|
|
||||||
|
# Single cached install. Every other step depends on this and reuses the
|
||||||
|
# node_modules it produces in the shared workspace.
|
||||||
install:
|
install:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
commands:
|
commands:
|
||||||
- *install_deps
|
- corepack enable
|
||||||
|
- npm ci --ignore-scripts --prefer-offline
|
||||||
|
depends_on: []
|
||||||
|
|
||||||
security-audit:
|
security-audit:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
commands:
|
commands:
|
||||||
- *install_deps
|
|
||||||
- npm audit --audit-level=high
|
- npm audit --audit-level=high
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
@@ -35,7 +46,6 @@ steps:
|
|||||||
environment:
|
environment:
|
||||||
SKIP_ENV_VALIDATION: 'true'
|
SKIP_ENV_VALIDATION: 'true'
|
||||||
commands:
|
commands:
|
||||||
- *install_deps
|
|
||||||
- npm run lint
|
- npm run lint
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
@@ -45,7 +55,6 @@ steps:
|
|||||||
environment:
|
environment:
|
||||||
SKIP_ENV_VALIDATION: 'true'
|
SKIP_ENV_VALIDATION: 'true'
|
||||||
commands:
|
commands:
|
||||||
- *install_deps
|
|
||||||
- npm run type-check
|
- npm run type-check
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
@@ -55,7 +64,6 @@ steps:
|
|||||||
environment:
|
environment:
|
||||||
SKIP_ENV_VALIDATION: 'true'
|
SKIP_ENV_VALIDATION: 'true'
|
||||||
commands:
|
commands:
|
||||||
- *install_deps
|
|
||||||
- npm run test -- --coverage --coverageThreshold='{"global":{"branches":80,"functions":80,"lines":80,"statements":80}}'
|
- npm run test -- --coverage --coverageThreshold='{"global":{"branches":80,"functions":80,"lines":80,"statements":80}}'
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
@@ -66,7 +74,6 @@ steps:
|
|||||||
SKIP_ENV_VALIDATION: 'true'
|
SKIP_ENV_VALIDATION: 'true'
|
||||||
NODE_ENV: 'production'
|
NODE_ENV: 'production'
|
||||||
commands:
|
commands:
|
||||||
- *install_deps
|
|
||||||
- npm run build
|
- npm run build
|
||||||
depends_on:
|
depends_on:
|
||||||
- lint
|
- lint
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
formatAllPackagesTable,
|
formatAllPackagesTable,
|
||||||
getInstallAllCommand,
|
getInstallAllCommand,
|
||||||
runFrameworkReseed,
|
runFrameworkReseed,
|
||||||
|
refreshActiveFleetUnits,
|
||||||
readRosterAgentNames,
|
readRosterAgentNames,
|
||||||
buildRelaunchCommands,
|
buildRelaunchCommands,
|
||||||
FRAMEWORK_RESEED_PACKAGE,
|
FRAMEWORK_RESEED_PACKAGE,
|
||||||
@@ -466,6 +467,12 @@ program
|
|||||||
const reseed = runFrameworkReseed();
|
const reseed = runFrameworkReseed();
|
||||||
if (reseed.ok) {
|
if (reseed.ok) {
|
||||||
console.log('✔ Framework re-seeded.');
|
console.log('✔ Framework re-seeded.');
|
||||||
|
// Propagate shipped systemd unit fixes to the ACTIVE units (re-seed only
|
||||||
|
// touches ~/.config/mosaic/systemd/user; systemd runs ~/.config/systemd/user).
|
||||||
|
const units = refreshActiveFleetUnits();
|
||||||
|
if (units.refreshed.length > 0) {
|
||||||
|
console.log(`✔ Refreshed ${units.refreshed.length} active systemd unit(s).`);
|
||||||
|
}
|
||||||
const agents = readRosterAgentNames();
|
const agents = readRosterAgentNames();
|
||||||
if (agents.length > 0) {
|
if (agents.length > 0) {
|
||||||
if (opts.relaunch) {
|
if (opts.relaunch) {
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ describe('registerFleetCommand', () => {
|
|||||||
expect(agent).toBeDefined();
|
expect(agent).toBeDefined();
|
||||||
expect(agent!.options.map((option) => option.long)).toContain('--list');
|
expect(agent!.options.map((option) => option.long)).toContain('--list');
|
||||||
expect(agent!.commands.map((command) => command.name()).sort()).toEqual([
|
expect(agent!.commands.map((command) => command.name()).sort()).toEqual([
|
||||||
|
'comms-block',
|
||||||
'reset',
|
'reset',
|
||||||
'roster',
|
'roster',
|
||||||
'send',
|
'send',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { spawn } from 'node:child_process';
|
|||||||
import * as readline from 'node:readline';
|
import * as readline from 'node:readline';
|
||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
import YAML from 'yaml';
|
import YAML from 'yaml';
|
||||||
|
import { resolveCommsBlock } from '../fleet/comms-onboarding.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A function that spawns a command with inherited stdio (TTY passthrough).
|
* A function that spawns a command with inherited stdio (TTY passthrough).
|
||||||
@@ -1359,6 +1360,23 @@ export function registerFleetAgentCommands(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
agentCommand
|
||||||
|
.command('comms-block <role>')
|
||||||
|
.description(
|
||||||
|
"Print the Fleet Comms cheat-sheet for a roster role (preview a peer's peer-reach view)",
|
||||||
|
)
|
||||||
|
.option('--host <host>', 'Override the fleet host (preview a cross-host peer view)')
|
||||||
|
.action((role: string, opts: { host?: string }) => {
|
||||||
|
const mosaicHome = resolveMosaicHomeFromCommand(agentCommand, deps.mosaicHome);
|
||||||
|
const res = resolveCommsBlock(mosaicHome, role, opts.host);
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(`[mosaic] comms-block: ${res.error}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(res.output);
|
||||||
|
});
|
||||||
|
|
||||||
agentCommand
|
agentCommand
|
||||||
.command('status [agent]')
|
.command('status [agent]')
|
||||||
.description('Show tmux status for the local fleet or one agent')
|
.description('Show tmux status for the local fleet or one agent')
|
||||||
|
|||||||
@@ -153,6 +153,30 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
|
|||||||
expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md'), 'utf-8')).toBe('# AGENTS default\n');
|
expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md'), 'utf-8')).toBe('# AGENTS default\n');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves user fleet data (roster.yaml, agents/, run/) through a keep-mode sync', async () => {
|
||||||
|
// Regression for the roster-loss bug (#631): user-authored fleet files must
|
||||||
|
// survive the framework re-seed that `mosaic update` runs.
|
||||||
|
mkdirSync(join(fixture.mosaicHome, 'fleet', 'run'), { recursive: true });
|
||||||
|
mkdirSync(join(fixture.mosaicHome, 'fleet', 'agents'), { recursive: true });
|
||||||
|
writeFileSync(join(fixture.mosaicHome, 'fleet', 'roster.yaml'), 'version: 1\nMINE\n');
|
||||||
|
writeFileSync(join(fixture.mosaicHome, 'fleet', 'run', 'a.hb'), 'ts=x\n');
|
||||||
|
writeFileSync(join(fixture.mosaicHome, 'fleet', 'agents', 'a.env'), 'X=1\n');
|
||||||
|
// The framework ships fleet/examples — it should still seed/refresh.
|
||||||
|
mkdirSync(join(fixture.sourceDir, 'fleet', 'examples'), { recursive: true });
|
||||||
|
writeFileSync(join(fixture.sourceDir, 'fleet', 'examples', 'general.yaml'), '# preset\n');
|
||||||
|
|
||||||
|
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
||||||
|
await adapter.syncFramework('keep');
|
||||||
|
|
||||||
|
expect(readFileSync(join(fixture.mosaicHome, 'fleet', 'roster.yaml'), 'utf-8')).toBe(
|
||||||
|
'version: 1\nMINE\n',
|
||||||
|
);
|
||||||
|
expect(existsSync(join(fixture.mosaicHome, 'fleet', 'run', 'a.hb'))).toBe(true);
|
||||||
|
expect(existsSync(join(fixture.mosaicHome, 'fleet', 'agents', 'a.env'))).toBe(true);
|
||||||
|
// framework-owned fleet/examples is seeded
|
||||||
|
expect(existsSync(join(fixture.mosaicHome, 'fleet', 'examples', 'general.yaml'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('is a no-op for seeding when defaults/ dir does not exist', async () => {
|
it('is a no-op for seeding when defaults/ dir does not exist', async () => {
|
||||||
rmSync(fixture.defaultsDir, { recursive: true });
|
rmSync(fixture.defaultsDir, { recursive: true });
|
||||||
|
|
||||||
|
|||||||
@@ -173,6 +173,13 @@ export class FileConfigAdapter implements ConfigService {
|
|||||||
'memory',
|
'memory',
|
||||||
'sources',
|
'sources',
|
||||||
'credentials',
|
'credentials',
|
||||||
|
// User-authored fleet data MUST survive `mosaic update`'s re-seed.
|
||||||
|
// The framework seeds only fleet/examples + fleet/roles +
|
||||||
|
// fleet/roster.schema.json; the operator's roster, per-agent env, and
|
||||||
|
// heartbeat run dir stay user-owned. (Mirror of install.sh PRESERVE_PATHS.)
|
||||||
|
'fleet/*.yaml',
|
||||||
|
'fleet/agents',
|
||||||
|
'fleet/run',
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
buildFleetCommsBlock,
|
buildFleetCommsBlock,
|
||||||
renderPeerReach,
|
renderPeerReach,
|
||||||
readFleetCommsBlock,
|
readFleetCommsBlock,
|
||||||
|
resolveCommsBlock,
|
||||||
type CommsPeer,
|
type CommsPeer,
|
||||||
} from './comms-onboarding.js';
|
} from './comms-onboarding.js';
|
||||||
|
|
||||||
@@ -185,3 +186,53 @@ describe('readFleetCommsBlock — situational (the context a spawned agent gets)
|
|||||||
expect(readFleetCommsBlock(mkdtempSync(join(tmpdir(), 'noroster-')), 'orchestrator')).toBe('');
|
expect(readFleetCommsBlock(mkdtempSync(join(tmpdir(), 'noroster-')), 'orchestrator')).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('resolveCommsBlock — `mosaic fleet comms-block <role>` emitter semantics', () => {
|
||||||
|
// The emitter wraps readFleetCommsBlock but must NEVER print an empty string silently:
|
||||||
|
// an unknown role / missing roster has to fail loud (caller maps !ok → stderr + exit 1)
|
||||||
|
// so `mosaic fleet comms-block bogus` is a visible error, not a confusing no-op. The
|
||||||
|
// success path returns the block verbatim for `mosaic fleet comms-block <peer>` previews.
|
||||||
|
let home: string;
|
||||||
|
beforeEach(() => {
|
||||||
|
home = mkdtempSync(join(tmpdir(), 'mosaic-commsblk-'));
|
||||||
|
mkdirSync(join(home, 'fleet'), { recursive: true });
|
||||||
|
writeFileSync(join(home, 'fleet', 'roster.yaml'), ROSTER);
|
||||||
|
});
|
||||||
|
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
it('returns ok + the cheat-sheet for a roster member', () => {
|
||||||
|
const res = resolveCommsBlock(home, 'orchestrator', 'w-jarvis');
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(res.output).toContain('# Fleet Comms');
|
||||||
|
expect(res.output).toContain('| enhancer |');
|
||||||
|
expect(res.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails loud (not ok + error naming the role) for a non-member — never silently empty', () => {
|
||||||
|
const res = resolveCommsBlock(home, 'stranger', 'w-jarvis');
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(res.output).toBe('');
|
||||||
|
expect(res.error).toContain('stranger');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails loud when no roster exists at the mosaic home', () => {
|
||||||
|
const noRoster = mkdtempSync(join(tmpdir(), 'mosaic-noroster-'));
|
||||||
|
const res = resolveCommsBlock(noRoster, 'orchestrator', 'w-jarvis');
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(res.error).toBeTruthy();
|
||||||
|
rmSync(noRoster, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails loud for a missing role argument', () => {
|
||||||
|
const res = resolveCommsBlock(home, undefined, 'w-jarvis');
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(res.error).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors a host override so a peer can preview its own cross-host view', () => {
|
||||||
|
// coder0-0 viewing with its own host → its self-identity line uses that host.
|
||||||
|
const res = resolveCommsBlock(home, 'coder0-0', '10.1.10.37');
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(res.output).toContain('`[10.1.10.37:coder0-0]`');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -179,5 +179,48 @@ export function readFleetCommsBlock(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Result of resolving a comms-block emit request — see `mosaic fleet comms-block`. */
|
||||||
|
export interface CommsBlockResult {
|
||||||
|
/** True when a cheat-sheet was produced; false maps to stderr + non-zero exit. */
|
||||||
|
ok: boolean;
|
||||||
|
/** The Fleet-Comms cheat-sheet (empty unless ok). */
|
||||||
|
output: string;
|
||||||
|
/** Operator-facing reason when !ok. */
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the Fleet-Comms cheat-sheet for an explicit <role>, backing the
|
||||||
|
* `mosaic fleet comms-block <role>` command. Unlike readFleetCommsBlock — which
|
||||||
|
* returns '' on any miss so composeContract can no-op silently during a launch —
|
||||||
|
* this NEVER silently emits empty: an unknown role or missing roster yields
|
||||||
|
* ok:false + an operator-facing reason, so the CLI surfaces it (stderr + exit 1)
|
||||||
|
* rather than printing nothing. That makes it safe to preview any peer's view,
|
||||||
|
* e.g. `mosaic fleet comms-block coder0-0`.
|
||||||
|
*/
|
||||||
|
export function resolveCommsBlock(
|
||||||
|
mosaicHome: string,
|
||||||
|
role: string | undefined,
|
||||||
|
fleetHost?: string,
|
||||||
|
): CommsBlockResult {
|
||||||
|
if (!role) {
|
||||||
|
return { ok: false, output: '', error: 'comms-block requires a <role> argument' };
|
||||||
|
}
|
||||||
|
const block = fleetHost
|
||||||
|
? readFleetCommsBlock(mosaicHome, role, fleetHost)
|
||||||
|
: readFleetCommsBlock(mosaicHome, role);
|
||||||
|
if (!block) {
|
||||||
|
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
output: '',
|
||||||
|
error: existsSync(rosterPath)
|
||||||
|
? `role "${role}" is not a member of the fleet roster at ${rosterPath}`
|
||||||
|
: `no fleet roster at ${rosterPath}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, output: block };
|
||||||
|
}
|
||||||
|
|
||||||
/** Default mosaic home (mirrors launch.ts), for callers that don't pass one. */
|
/** Default mosaic home (mirrors launch.ts), for callers that don't pass one. */
|
||||||
export const DEFAULT_MOSAIC_HOME_FOR_COMMS = join(homedir(), '.config', 'mosaic');
|
export const DEFAULT_MOSAIC_HOME_FOR_COMMS = join(homedir(), '.config', 'mosaic');
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import {
|
|||||||
buildRelaunchCommands,
|
buildRelaunchCommands,
|
||||||
readRosterAgentNames,
|
readRosterAgentNames,
|
||||||
runFrameworkReseed,
|
runFrameworkReseed,
|
||||||
|
refreshActiveFleetUnits,
|
||||||
} from './update-checker.js';
|
} from './update-checker.js';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* F3-m3 / R13: `mosaic update` re-seeds the framework + (opt-in) relaunches
|
* F3-m3 / R13: `mosaic update` re-seeds the framework + (opt-in) relaunches
|
||||||
@@ -83,3 +85,41 @@ describe('runFrameworkReseed', () => {
|
|||||||
rmSync(missing, { recursive: true, force: true });
|
rmSync(missing, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('refreshActiveFleetUnits', () => {
|
||||||
|
let root: string;
|
||||||
|
let mosaicHome: string;
|
||||||
|
let configHome: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
root = mkdtempSync(join(tmpdir(), 'mosaic-units-'));
|
||||||
|
mosaicHome = join(root, 'mosaic');
|
||||||
|
configHome = join(root, 'config');
|
||||||
|
mkdirSync(join(mosaicHome, 'systemd', 'user'), { recursive: true });
|
||||||
|
mkdirSync(join(configHome, 'systemd', 'user'), { recursive: true });
|
||||||
|
// Freshly re-seeded units (new content).
|
||||||
|
writeFileSync(join(mosaicHome, 'systemd', 'user', 'mosaic-agent@.service'), 'NEW\n');
|
||||||
|
writeFileSync(join(mosaicHome, 'systemd', 'user', 'mosaic-tmux-holder.service'), 'NEW\n');
|
||||||
|
});
|
||||||
|
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
it('refreshes active units when a fleet is already installed', () => {
|
||||||
|
// Active dir already carries mosaic units (stale) → fleet is installed.
|
||||||
|
writeFileSync(join(configHome, 'systemd', 'user', 'mosaic-agent@.service'), 'OLD\n');
|
||||||
|
const res = refreshActiveFleetUnits(mosaicHome, {
|
||||||
|
XDG_CONFIG_HOME: configHome,
|
||||||
|
} as NodeJS.ProcessEnv);
|
||||||
|
expect(res.refreshed).toContain('mosaic-agent@.service');
|
||||||
|
expect(
|
||||||
|
readFileSync(join(configHome, 'systemd', 'user', 'mosaic-agent@.service'), 'utf-8'),
|
||||||
|
).toBe('NEW\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when no fleet is installed (active dir has no mosaic units)', () => {
|
||||||
|
const res = refreshActiveFleetUnits(mosaicHome, {
|
||||||
|
XDG_CONFIG_HOME: configHome,
|
||||||
|
} as NodeJS.ProcessEnv);
|
||||||
|
expect(res.refreshed).toEqual([]);
|
||||||
|
expect(existsSync(join(configHome, 'systemd', 'user', 'mosaic-agent@.service'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -14,7 +14,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
readFileSync,
|
||||||
|
writeFileSync,
|
||||||
|
readdirSync,
|
||||||
|
copyFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { dirname, join, resolve } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -536,6 +543,47 @@ export function readRosterAgentNames(mosaicHome = join(homedir(), '.config', 'mo
|
|||||||
return names;
|
return names;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the ACTIVE systemd user units from the freshly re-seeded copies.
|
||||||
|
*
|
||||||
|
* The re-seed updates `~/.config/mosaic/systemd/user/*.service`, but the units
|
||||||
|
* systemd actually runs live at `~/.config/systemd/user/`. Without this copy,
|
||||||
|
* shipped unit fixes (e.g. the socket-env change) never take effect after
|
||||||
|
* `mosaic update` until `mosaic fleet install` is re-run. Best-effort + scoped:
|
||||||
|
* only refreshes when a fleet is already installed (the active dir already
|
||||||
|
* carries `mosaic-*` units), so non-fleet hosts are untouched.
|
||||||
|
*/
|
||||||
|
export function refreshActiveFleetUnits(
|
||||||
|
mosaicHome = join(homedir(), '.config', 'mosaic'),
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): { refreshed: string[]; ok: boolean; reason?: string } {
|
||||||
|
const src = join(mosaicHome, 'systemd', 'user');
|
||||||
|
const configHome = env['XDG_CONFIG_HOME'] ?? join(homedir(), '.config');
|
||||||
|
const dest = join(configHome, 'systemd', 'user');
|
||||||
|
if (!existsSync(src)) return { refreshed: [], ok: true };
|
||||||
|
// Only refresh when a fleet is already installed (active dir has mosaic units).
|
||||||
|
const fleetInstalled =
|
||||||
|
existsSync(dest) &&
|
||||||
|
readdirSync(dest).some((f) => f.startsWith('mosaic-') && f.endsWith('.service'));
|
||||||
|
if (!fleetInstalled) return { refreshed: [], ok: true };
|
||||||
|
const units = readdirSync(src).filter((f) => f.startsWith('mosaic-') && f.endsWith('.service'));
|
||||||
|
const refreshed: string[] = [];
|
||||||
|
for (const unit of units) {
|
||||||
|
try {
|
||||||
|
copyFileSync(join(src, unit), join(dest, unit));
|
||||||
|
refreshed.push(unit);
|
||||||
|
} catch {
|
||||||
|
// best-effort per unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
execSync('systemctl --user daemon-reload', { stdio: 'ignore', timeout: 15_000 });
|
||||||
|
} catch {
|
||||||
|
// non-systemd host or no session bus — non-fatal
|
||||||
|
}
|
||||||
|
return { refreshed, ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
/** Build the per-agent systemd relaunch commands (drain+relaunch via restart). */
|
/** Build the per-agent systemd relaunch commands (drain+relaunch via restart). */
|
||||||
export function buildRelaunchCommands(agentNames: string[]): string[][] {
|
export function buildRelaunchCommands(agentNames: string[]): string[][] {
|
||||||
return agentNames.map((name) => [
|
return agentNames.map((name) => [
|
||||||
|
|||||||
Reference in New Issue
Block a user