Compare commits
2 Commits
feat/fleet
...
b464c39fc7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b464c39fc7 | ||
|
|
250d3da12d |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -12,6 +12,3 @@ docs/reports/
|
||||
|
||||
# Step-CA dev password — real file is gitignored; commit only the .example
|
||||
infra/step-ca/dev-password
|
||||
|
||||
# Scratch dirs created by the framework git-wrapper shell test harnesses
|
||||
.mosaic-test-work/
|
||||
|
||||
@@ -64,7 +64,6 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and
|
||||
21. `@mosaicstack/cli` — unified `mosaic` CLI
|
||||
22. Docker Compose deployment + bare-metal capability
|
||||
23. Agent log service — ingest, parse, tier, summarize agent interaction logs
|
||||
24. Local durable agent fleet canary — `mosaic fleet` / `mosaic agent` CLI for an isolated tmux-backed canary fleet using a named socket, with roster-driven local customization and rollback-safe verification
|
||||
|
||||
### Out of Scope (v0.1.0)
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
3. [Provider Configuration](#provider-configuration)
|
||||
4. [MCP Server Configuration](#mcp-server-configuration)
|
||||
5. [Environment Variables Reference](#environment-variables-reference)
|
||||
6. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
5. [Adding New MCP Tools](#adding-new-mcp-tools)
|
||||
6. [Database Schema and Migrations](#database-schema-and-migrations)
|
||||
7. [API Endpoint Reference](#api-endpoint-reference)
|
||||
8. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
# Local Fleet Canary
|
||||
|
||||
The local fleet canary runs a small tmux-backed Mosaic agent fleet on an
|
||||
isolated tmux socket. The default socket is `mosaic-factory`; the commands do
|
||||
not use or stop the default tmux server.
|
||||
|
||||
## Files
|
||||
|
||||
Product-owned defaults:
|
||||
|
||||
- `packages/mosaic/framework/fleet/roster.schema.json`
|
||||
- `packages/mosaic/framework/fleet/examples/minimal.yaml`
|
||||
- `packages/mosaic/framework/fleet/examples/local-canary.yaml`
|
||||
- `packages/mosaic/framework/systemd/user/mosaic-tmux-holder.service`
|
||||
- `packages/mosaic/framework/systemd/user/mosaic-agent@.service`
|
||||
- `packages/mosaic/framework/tools/fleet/start-agent-session.sh`
|
||||
- `packages/mosaic/framework/tools/tmux/agent-send.sh`
|
||||
- `packages/mosaic/framework/tools/tmux/send-message.sh`
|
||||
|
||||
Site-owned local roster:
|
||||
|
||||
```text
|
||||
~/.config/mosaic/fleet/roster.yaml
|
||||
```
|
||||
|
||||
Do not put a host-specific full roster into product defaults. Start from an
|
||||
example and edit the local roster after `mosaic fleet init --write`.
|
||||
|
||||
## Install
|
||||
|
||||
Minimal canary:
|
||||
|
||||
```bash
|
||||
mosaic fleet init --profile minimal --write
|
||||
# If a site-owned roster already exists, inspect it first; overwrite only explicitly:
|
||||
# mosaic fleet init --profile minimal --write --force
|
||||
mosaic fleet install-systemd
|
||||
systemctl --user daemon-reload
|
||||
mosaic fleet start
|
||||
mosaic fleet verify
|
||||
```
|
||||
|
||||
Small dogfood roster:
|
||||
|
||||
```bash
|
||||
mosaic fleet init --profile local-canary --write
|
||||
# Use --force only after preserving any site-owned roster changes.
|
||||
mosaic fleet install-systemd
|
||||
systemctl --user daemon-reload
|
||||
mosaic fleet start
|
||||
mosaic fleet status
|
||||
```
|
||||
|
||||
## Agent Operations
|
||||
|
||||
```bash
|
||||
mosaic agent roster
|
||||
mosaic agent status
|
||||
mosaic agent status canary-pi
|
||||
mosaic agent send canary-pi --message "status check"
|
||||
mosaic agent reset canary-pi --new
|
||||
mosaic agent tail canary-pi -n 80
|
||||
```
|
||||
|
||||
These commands read the roster and target the configured tmux socket. The
|
||||
generated systemd agent services use `start-agent-session.sh`; message delivery
|
||||
uses the tmux send tools with `-L mosaic-factory`.
|
||||
|
||||
## Verification
|
||||
|
||||
Use these checks before expanding the roster:
|
||||
|
||||
```bash
|
||||
tmux -L mosaic-factory ls
|
||||
tmux ls
|
||||
mosaic fleet verify
|
||||
systemctl --user status mosaic-tmux-holder.service
|
||||
```
|
||||
|
||||
Expected results:
|
||||
|
||||
- `tmux -L mosaic-factory ls` shows `_holder` and roster agent sessions.
|
||||
- `tmux ls` shows only the default tmux server sessions and is not changed by
|
||||
fleet start/stop operations.
|
||||
- `mosaic fleet verify` checks exact session targets on the isolated socket.
|
||||
|
||||
## Rollback
|
||||
|
||||
Stop the local canary:
|
||||
|
||||
```bash
|
||||
mosaic fleet stop
|
||||
systemctl --user disable mosaic-agent@canary-pi.service
|
||||
systemctl --user disable mosaic-tmux-holder.service
|
||||
systemctl --user daemon-reload
|
||||
```
|
||||
|
||||
For a full local cleanup of generated canary files:
|
||||
|
||||
```bash
|
||||
rm -f ~/.config/systemd/user/mosaic-agent@.service
|
||||
rm -f ~/.config/systemd/user/mosaic-tmux-holder.service
|
||||
rm -rf ~/.config/mosaic/fleet
|
||||
rm -rf ~/.config/mosaic/tools/fleet
|
||||
```
|
||||
|
||||
This rollback leaves the default tmux server untouched. If a canary session is
|
||||
still present after service stop, remove only the isolated socket server:
|
||||
|
||||
```bash
|
||||
tmux -L mosaic-factory kill-server
|
||||
```
|
||||
@@ -10,7 +10,6 @@
|
||||
6. [CLI Usage](#cli-usage)
|
||||
7. [Sub-package Commands](#sub-package-commands)
|
||||
8. [Telemetry](#telemetry)
|
||||
9. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# Fleet CLI Local Canary Dogfood — 2026-06-20
|
||||
|
||||
## Objective
|
||||
|
||||
Move the durable tmux fleet PoC into a functional local canary on this server. This is **not** production deployment. It is a canary/dogfood path for a small local agent fleet using an isolated tmux socket.
|
||||
|
||||
## Issue
|
||||
|
||||
- Gitea issue: #562 — `feat(fleet): local CLI canary dogfood`
|
||||
|
||||
## Scope
|
||||
|
||||
Implement enough product surface to use the fleet locally:
|
||||
|
||||
- `mosaic fleet init/install/start/stop/restart/status/verify`
|
||||
- `mosaic agent roster/status/send/reset/tail`
|
||||
- roster schema and examples
|
||||
- local canary docs and rollback instructions
|
||||
- tests for CLI behavior where practical
|
||||
- canary verification on named tmux socket `mosaic-factory`
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No production rollout.
|
||||
- No migration of existing default tmux sessions.
|
||||
- No image build/deploy work.
|
||||
- No hardcoded USC/local roster as product default.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- CLI can initialize a minimal roster outside product defaults.
|
||||
- CLI can install user systemd units and fleet helper scripts to a configurable Mosaic home.
|
||||
- CLI can start/stop/status/verify a canary fleet using `mosaic-factory`.
|
||||
- `mosaic agent send` uses existing named-socket/exact-target tmux tooling.
|
||||
- `mosaic agent reset` targets only the named agent session on the named socket.
|
||||
- Verification proves default tmux sessions remain untouched.
|
||||
- Baseline repo gates pass.
|
||||
- PR CI is green before merge.
|
||||
- Local canary evidence is captured after merge/install.
|
||||
|
||||
## Budget / Routing
|
||||
|
||||
- Agent: codex preferred.
|
||||
- Estimate: 25K-40K tokens.
|
||||
- Worker owns implementation/tests/docs in branch `feat/fleet-cli-local-canary`.
|
||||
- Orchestrator owns `docs/TASKS.md`, issue/PR/merge, and local canary install verification.
|
||||
|
||||
## Progress
|
||||
|
||||
- 2026-06-20: #557 PoC primitives merged to `main` as `45e2c2a`.
|
||||
- 2026-06-20: issue #562 created for local CLI canary dogfood.
|
||||
- 2026-06-20: worktree created at `/home/jarvis/src/mosaicstack-stack-worktrees/fleet-cli-local-canary`.
|
||||
@@ -1,87 +0,0 @@
|
||||
# Wrapper hardening fold-in: #559 (eval removal) + #560 (host-derived login)
|
||||
|
||||
**Branch:** `fix/wrapper-hardening-tls-credpath-cicwait` (PR #551)
|
||||
**Worker:** coderlite0 (Sonnet lane) · coordinated by mos-claude
|
||||
**Date:** 2026-06-20
|
||||
**Scope:** `packages/mosaic/framework/tools/git/*.sh` only
|
||||
|
||||
## What the issues asked for vs. what was already landed
|
||||
|
||||
Both issues were largely satisfied by prior merged work; this fold-in closes the
|
||||
remaining gaps (regression tests + a loud diagnostic + one residual word-split site)
|
||||
rather than re-implementing finished functionality.
|
||||
|
||||
### #559 — remove `eval` from issue-create.sh (and siblings)
|
||||
|
||||
- `eval`-based command construction was already removed across the wrapper surface
|
||||
(landed in #549). A full scan of `tools/git/*.sh` finds **zero** `eval` usages.
|
||||
- `issue-create.sh`, `pr-create.sh`, `issue-edit.sh`, `issue-assign.sh` already build
|
||||
their `tea`/`gh` invocations as argv arrays (`CMD=(...)`, `"${CMD[@]}"`), so Markdown
|
||||
bodies pass through verbatim.
|
||||
- **Residual found & fixed:** `issue-comment.sh` still used unquoted
|
||||
`$(get_gitea_repo_args)` word-splitting (the comment body itself was already safely
|
||||
quoted, so no injection bug — but it was the inconsistent, fragile pattern #559 targets,
|
||||
and it failed silently when no login resolved). Converted to an argv array with an
|
||||
explicit, loud login-resolution error.
|
||||
- **Added regression test:** `test-issue-create-body-safety.sh` — feeds a hostile
|
||||
Markdown body (`$(touch SENTINEL)`, backticks, single/double quotes, `$HOME`/`${PATH}`,
|
||||
pipes/`&&`/`;`) through `issue-create.sh` and asserts (1) no command substitution
|
||||
executes (sentinel file never created) and (2) the `--description` `tea` receives is
|
||||
byte-for-byte the original body.
|
||||
|
||||
### #560 — auto-detect Gitea `--login` from repo origin host
|
||||
|
||||
- Centralized host→login resolution already exists in `detect-platform.sh`
|
||||
(`get_gitea_login_for_host` → `find_tea_login_for_host`, matching `urlparse(url).hostname`).
|
||||
Every wrapper routes through it (or `get_gitea_login` / `get_gitea_login_for_repo_override`);
|
||||
**no wrapper hardcodes `${GITEA_LOGIN:-mosaicstack}`**. Explicit `GITEA_LOGIN` wins only
|
||||
when it matches the host (`tea_login_matches_host`), so stale overrides are rejected.
|
||||
- **Gap fixed — silent failure → loud diagnostic:** the failure path of
|
||||
`get_gitea_login_for_host` returned non-zero with no message. Added
|
||||
`print_gitea_login_diagnostic`, emitted to **stderr** on resolution failure: names the
|
||||
unresolved host, lists available tea logins (name + host), and gives the `GITEA_LOGIN`
|
||||
override + `tea login add` fix. Stderr-only, so it never contaminates stdout (the
|
||||
resolved login name) or the log-grep assertions in the existing harnesses. Callers with
|
||||
an API fallback (pr-merge, issue-close, pr-create, issue-create) still follow with their
|
||||
own "using API fallback" line, giving a clear "no login → fallback" trail.
|
||||
- **Extended test:** `test-gitea-login-resolution.sh` now also asserts (a) the loud
|
||||
diagnostic fires and lists available logins for an unresolved host, (b) login is derived
|
||||
from origin host for **both** instances (mosaicstack + usc) via a scoped second `tea`
|
||||
mock, and (c) a valid `GITEA_LOGIN` override is honored. The scoped mock keeps the
|
||||
existing API-fallback assertions (which require mosaicstack to have _no_ tea login) valid.
|
||||
|
||||
## Files changed (wrapper surface only)
|
||||
|
||||
- `detect-platform.sh` — add `print_gitea_login_diagnostic`; call it on the
|
||||
`get_gitea_login_for_host` failure path.
|
||||
- `issue-comment.sh` — argv array + loud login-resolution error (was unquoted
|
||||
`$(get_gitea_repo_args)`).
|
||||
- `test-issue-create-body-safety.sh` — **new** (#559 regression).
|
||||
- `test-gitea-login-resolution.sh` — extended (#560 diagnostic + both-host + override).
|
||||
|
||||
## Verification
|
||||
|
||||
All wrapper harnesses pass locally:
|
||||
|
||||
- `test-issue-create-body-safety.sh` — PASS
|
||||
- `test-gitea-login-resolution.sh` — PASS
|
||||
- `test-pr-merge-gitea-empty-uid.sh` — PASS
|
||||
- `test-pr-metadata-gitea.sh` — PASS
|
||||
- `test-lane-brief-pr-linkage.sh` — PASS
|
||||
|
||||
## Open items flagged to mos-claude (orchestrator decisions)
|
||||
|
||||
1. **CHANGELOG absent.** The task said "update CHANGELOG (append-only), keep the existing
|
||||
#550/#551 entry." No CHANGELOG file exists anywhere in the repo, and #550/#551 are not
|
||||
recorded in one. **ASSUMPTION:** documenting #559/#560 in this scratchpad + the PR
|
||||
description (`Closes #559 Closes #560`) follows the repo's actual convention
|
||||
(`docs/scratchpads/`). Did not invent a new CHANGELOG structure.
|
||||
2. **`docs/TASKS.md` is orchestrator single-writer.** It carries a "Workers read but never
|
||||
modify" banner. As a worker I did **not** edit it; task tracking is via the linked Gitea
|
||||
issues #559/#560 + this scratchpad. Orchestrator may add a rollup row if desired.
|
||||
3. **Wrapper `test-*.sh` are not CI-wired.** `.woodpecker/ci.yml` runs `pnpm
|
||||
typecheck/lint/format:check/test` (`turbo run test`); the framework dir has no
|
||||
`package.json`, so these shell harnesses run **locally/manually only** — they do not gate
|
||||
the PR in Woodpecker. **ASSUMPTION:** out of scope to wire a shell-test step into CI in
|
||||
this PR (would broaden the diff beyond the wrapper surface). Flagging for a follow-up if
|
||||
the fleet wants these gated.
|
||||
@@ -1,54 +0,0 @@
|
||||
# Fleet CLI Local Canary Review Fixes
|
||||
|
||||
## Objective
|
||||
|
||||
Fix only the two should-fix code review findings:
|
||||
|
||||
1. Ensure `@mosaicstack/mosaic` declares `yaml` and lockfile state is current.
|
||||
2. Validate `mosaic agent status [agent]` against the fleet roster before constructing/running the tmux target.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do not modify `docs/TASKS.md`.
|
||||
- Leave changes uncommitted.
|
||||
- Run requested formatting and quality gates.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Inspect manifest/lockfile state for `yaml`.
|
||||
2. Add failing regression test for `mosaic agent status typo`.
|
||||
3. Patch `registerFleetAgentCommands` status validation.
|
||||
4. Format touched files.
|
||||
5. Run requested tests, typecheck, and lint.
|
||||
6. Review final diff.
|
||||
|
||||
## Progress
|
||||
|
||||
- Loaded required repo/global/runtime instructions.
|
||||
- Confirmed `packages/mosaic/package.json` already declares `yaml`.
|
||||
- Confirmed `pnpm-lock.yaml` already has `packages/mosaic` importer entry for `yaml`.
|
||||
- Found `registerFleetAgentCommands` status path does not validate agent before building tmux target.
|
||||
|
||||
## Verification
|
||||
|
||||
- TDD red check: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts`
|
||||
failed before the production fix because `mosaic agent status typo` resolved instead of
|
||||
rejecting.
|
||||
- Focused green check: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts`
|
||||
passed after adding roster validation.
|
||||
- Formatting: `pnpm exec prettier --write packages/mosaic/src/commands/fleet.ts packages/mosaic/src/commands/fleet.spec.ts docs/scratchpads/fleet-cli-local-canary-review-fixes.md`
|
||||
completed with all files unchanged.
|
||||
- Requested tests: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts src/cli-smoke.spec.ts`
|
||||
passed with 36 tests.
|
||||
- Baseline typecheck: `pnpm typecheck` passed.
|
||||
- Baseline lint: `pnpm lint` passed.
|
||||
- Independent review: `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted`
|
||||
returned approve with 0 findings. Note: reviewer reported broader context inspection was limited
|
||||
by its read-only sandbox, so review was based on the supplied diff.
|
||||
- `docs/TASKS.md` has no diff.
|
||||
|
||||
## Risks
|
||||
|
||||
- `docs/TASKS.md` intentionally untouched per user instruction.
|
||||
- Review finding 1 required no file edit: `packages/mosaic/package.json` already declares
|
||||
`yaml`, and the `packages/mosaic` importer in `pnpm-lock.yaml` already includes `yaml`.
|
||||
@@ -1,26 +0,0 @@
|
||||
# Mosaic Fleet Rosters
|
||||
|
||||
The local fleet canary uses a product-owned roster schema with site-owned roster
|
||||
files. Product examples live here; active local rosters should live outside the
|
||||
package, normally at:
|
||||
|
||||
```text
|
||||
~/.config/mosaic/fleet/roster.yaml
|
||||
```
|
||||
|
||||
The default tmux socket is `mosaic-factory` so fleet commands do not touch the
|
||||
default tmux server.
|
||||
|
||||
## Examples
|
||||
|
||||
- `examples/minimal.yaml` starts one local canary slot.
|
||||
- `examples/local-canary.yaml` starts a small generic dogfood fleet.
|
||||
|
||||
Initialize a roster:
|
||||
|
||||
```bash
|
||||
mosaic fleet init --profile minimal --write
|
||||
mosaic fleet install-systemd
|
||||
mosaic fleet start
|
||||
mosaic fleet verify
|
||||
```
|
||||
@@ -1,27 +0,0 @@
|
||||
version: 1
|
||||
transport: tmux
|
||||
tmux:
|
||||
socket_name: mosaic-factory
|
||||
holder_session: _holder
|
||||
defaults:
|
||||
working_directory: ~/src
|
||||
runtimes:
|
||||
claude:
|
||||
reset_command: /clear
|
||||
codex:
|
||||
reset_command: /clear
|
||||
pi:
|
||||
reset_command: /new
|
||||
agents:
|
||||
- name: lead
|
||||
runtime: claude
|
||||
class: orchestrator
|
||||
persistent_persona: true
|
||||
- name: coder0
|
||||
runtime: codex
|
||||
class: implementer
|
||||
reset_between_tasks: true
|
||||
- name: reviewer0
|
||||
runtime: pi
|
||||
class: reviewer
|
||||
reset_between_tasks: true
|
||||
@@ -1,15 +0,0 @@
|
||||
version: 1
|
||||
transport: tmux
|
||||
tmux:
|
||||
socket_name: mosaic-factory
|
||||
holder_session: _holder
|
||||
defaults:
|
||||
working_directory: ~/src
|
||||
runtimes:
|
||||
pi:
|
||||
reset_command: /new
|
||||
agents:
|
||||
- name: canary-pi
|
||||
runtime: pi
|
||||
class: canary
|
||||
reset_between_tasks: true
|
||||
@@ -1,118 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://mosaicstack.dev/schemas/fleet-roster.schema.json",
|
||||
"title": "Mosaic Fleet Roster",
|
||||
"type": "object",
|
||||
"required": ["version", "transport", "agents"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"version": {
|
||||
"const": 1
|
||||
},
|
||||
"transport": {
|
||||
"const": "tmux"
|
||||
},
|
||||
"tmux": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"socket_name": {
|
||||
"type": "string",
|
||||
"default": "mosaic-factory"
|
||||
},
|
||||
"socketName": {
|
||||
"type": "string",
|
||||
"default": "mosaic-factory"
|
||||
},
|
||||
"holder_session": {
|
||||
"type": "string",
|
||||
"default": "_holder"
|
||||
},
|
||||
"holderSession": {
|
||||
"type": "string",
|
||||
"default": "_holder"
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaults": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"working_directory": {
|
||||
"type": "string",
|
||||
"default": "~/src"
|
||||
},
|
||||
"workingDirectory": {
|
||||
"type": "string",
|
||||
"default": "~/src"
|
||||
}
|
||||
}
|
||||
},
|
||||
"runtimes": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"reset_command": {
|
||||
"type": "string"
|
||||
},
|
||||
"resetCommand": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["name", "runtime"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9_.-]+$"
|
||||
},
|
||||
"runtime": {
|
||||
"type": "string"
|
||||
},
|
||||
"class": {
|
||||
"type": "string"
|
||||
},
|
||||
"working_directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"workingDirectory": {
|
||||
"type": "string"
|
||||
},
|
||||
"model_hint": {
|
||||
"type": "string"
|
||||
},
|
||||
"modelHint": {
|
||||
"type": "string"
|
||||
},
|
||||
"persistent_persona": {
|
||||
"oneOf": [{ "type": "boolean" }, { "type": "string" }]
|
||||
},
|
||||
"persistentPersona": {
|
||||
"oneOf": [{ "type": "boolean" }, { "type": "string" }]
|
||||
},
|
||||
"reset_between_tasks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"resetBetweenTasks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"kickstart_template": {
|
||||
"type": "string"
|
||||
},
|
||||
"kickstartTemplate": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,6 @@
|
||||
This directory contains the first durable tmux-backed fleet primitives for the
|
||||
Mosaic software-factory model.
|
||||
|
||||
The lifecycle model follows the organization-neutral AI Guide playbook
|
||||
`mosaicstack/aiguide:playbooks/tmux-fleet.md` (commit `2a0b0b5`): a dedicated
|
||||
holder owns the tmux server/socket; agent units join it and stop only their own
|
||||
exact-match session.
|
||||
|
||||
## Layout
|
||||
|
||||
- `mosaic-tmux-holder.service` — user-mode holder that owns the named tmux server.
|
||||
|
||||
@@ -14,7 +14,7 @@ Environment=MOSAIC_AGENT_RUNTIME=pi
|
||||
Environment=MOSAIC_AGENT_WORKDIR=%h
|
||||
EnvironmentFile=-%h/.config/mosaic/fleet/agents/%i.env
|
||||
ExecStart=/bin/bash %h/.config/mosaic/tools/fleet/start-agent-session.sh %i
|
||||
ExecStop=-/bin/bash -lc 'tmux -L "${MOSAIC_TMUX_SOCKET:-mosaic-factory}" kill-session -t "=%i"'
|
||||
ExecStop=-/bin/bash -lc 'tmux -L "${MOSAIC_TMUX_SOCKET:-mosaic-factory}" kill-session -t "=%i:0.0"'
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
@@ -18,7 +18,6 @@ grep -qF 'tmux -L' "$HOLDER" || fail "holder does not use named tmux socket"
|
||||
grep -qF '_holder' "$HOLDER" || fail "holder session is not explicit"
|
||||
grep -qF 'Requires=mosaic-tmux-holder.service' "$AGENT" || fail "agent does not require holder"
|
||||
grep -qF 'start-agent-session.sh' "$AGENT" || fail "agent unit does not call start-agent-session.sh"
|
||||
grep -qF 'kill-session -t "=%i"' "$AGENT" || fail "agent stop does not exact-match its session"
|
||||
|
||||
if command -v systemd-analyze >/dev/null 2>&1; then
|
||||
systemd-analyze verify --user "$HOLDER" "$AGENT" >/tmp/mosaic-fleet-systemd-verify.log 2>&1 || {
|
||||
|
||||
@@ -16,12 +16,7 @@
|
||||
# After loading, service-specific env vars are exported.
|
||||
# Run `load_credentials --help` for details.
|
||||
|
||||
if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then
|
||||
for _cand in "$HOME/.config/mosaic/credentials.json" "$HOME/src/jarvis-brain/credentials.json"; do
|
||||
if [[ -f "$_cand" ]]; then MOSAIC_CREDENTIALS_FILE="$_cand"; break; fi
|
||||
done
|
||||
: "${MOSAIC_CREDENTIALS_FILE:=$HOME/src/jarvis-brain/credentials.json}"
|
||||
fi
|
||||
MOSAIC_CREDENTIALS_FILE="${MOSAIC_CREDENTIALS_FILE:-$HOME/src/jarvis-brain/credentials.json}"
|
||||
|
||||
_mosaic_require_jq() {
|
||||
if ! command -v jq &>/dev/null; then
|
||||
@@ -39,19 +34,6 @@ _mosaic_read_cred() {
|
||||
jq -r "$jq_path // empty" "$MOSAIC_CREDENTIALS_FILE"
|
||||
}
|
||||
|
||||
# Decide curl TLS flag for a target URL: validate public hosts (MITM matters on
|
||||
# WAN); allow self-signed only for private-network IP literals (trusted LAN) or an
|
||||
# explicit $MOSAIC_INSECURE_TLS opt-in. Echoes "-k" or "" (empty).
|
||||
_mosaic_tls_opt() {
|
||||
local url="$1" host
|
||||
[[ -n "${MOSAIC_INSECURE_TLS:-}" ]] && { echo "-k"; return; }
|
||||
host=$(printf '%s' "$url" | sed -E 's#^[a-zA-Z]+://([^/:]+).*#\1#')
|
||||
if [[ "$host" =~ ^(10\.|127\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.) ]]; then
|
||||
echo "-k"; return
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
# Sync Woodpecker credentials to ~/.woodpecker/<instance>.env
|
||||
# Only writes when values differ to avoid unnecessary disk writes.
|
||||
_mosaic_sync_woodpecker_env() {
|
||||
@@ -279,8 +261,7 @@ mosaic_http() {
|
||||
local base_url="${4:-}"
|
||||
|
||||
local response
|
||||
local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}")
|
||||
response=$(curl -sS $_tls -w "\n%{http_code}" -X "$method" \
|
||||
response=$(curl -sk -w "\n%{http_code}" -X "$method" \
|
||||
-H "$auth_header" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${base_url}${endpoint}")
|
||||
@@ -298,8 +279,7 @@ mosaic_http_post() {
|
||||
local base_url="${4:-}"
|
||||
|
||||
local response
|
||||
local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}")
|
||||
response=$(curl -sS $_tls -w "\n%{http_code}" -X POST \
|
||||
response=$(curl -sk -w "\n%{http_code}" -X POST \
|
||||
-H "$auth_header" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$data" \
|
||||
@@ -317,8 +297,7 @@ mosaic_http_patch() {
|
||||
local base_url="${4:-}"
|
||||
|
||||
local response
|
||||
local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}")
|
||||
response=$(curl -sS $_tls -w "\n%{http_code}" -X PATCH \
|
||||
response=$(curl -sk -w "\n%{http_code}" -X PATCH \
|
||||
-H "$auth_header" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$data" \
|
||||
|
||||
@@ -169,43 +169,6 @@ raise SystemExit(1)
|
||||
PY
|
||||
}
|
||||
|
||||
# Emit an actionable diagnostic to stderr when no tea login resolves for a host.
|
||||
# Callers that have a working API fallback may ignore the non-zero return of
|
||||
# get_gitea_login_for_host; this turns the previously SILENT failure into a loud,
|
||||
# greppable hint (available logins + override + add-login instructions). Printed to
|
||||
# stderr only, so it never contaminates stdout (the resolved login name) or log
|
||||
# assertions that capture tea/curl invocations.
|
||||
print_gitea_login_diagnostic() {
|
||||
local host="${1:-<unknown>}"
|
||||
local available
|
||||
available=$(
|
||||
command -v tea >/dev/null 2>&1 || { echo "(tea CLI not installed)"; exit 0; }
|
||||
logins_json=$(tea login list --output json 2>/dev/null) || { echo "(could not query tea login list)"; exit 0; }
|
||||
TEA_LOGINS_JSON="$logins_json" python3 - <<'PY'
|
||||
import json, os
|
||||
from urllib.parse import urlparse
|
||||
try:
|
||||
logins = json.loads(os.environ.get("TEA_LOGINS_JSON", "[]"))
|
||||
except Exception:
|
||||
logins = []
|
||||
rows = []
|
||||
for login in logins if isinstance(logins, list) else []:
|
||||
name = str(login.get("name") or login.get("Name") or "")
|
||||
url = str(login.get("url") or login.get("URL") or "")
|
||||
host = urlparse(url).hostname or "?"
|
||||
if name:
|
||||
rows.append(f"{name} (host: {host})")
|
||||
print("; ".join(rows) if rows else "(none configured)")
|
||||
PY
|
||||
)
|
||||
{
|
||||
echo "Error: no Gitea tea login matches host '$host'."
|
||||
echo " Available tea logins: ${available}"
|
||||
echo " Fix: set GITEA_LOGIN to a login whose URL host is '$host',"
|
||||
echo " or add one: tea login add --name <name> --url https://$host --token <token>"
|
||||
} >&2
|
||||
}
|
||||
|
||||
get_gitea_login_for_host() {
|
||||
local host="${1:-}"
|
||||
local login
|
||||
@@ -227,7 +190,6 @@ get_gitea_login_for_host() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
print_gitea_login_diagnostic "$host"
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
@@ -53,15 +53,7 @@ if [[ "$PLATFORM" == "github" ]]; then
|
||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
||||
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
# Build the invocation as an argv array (not unquoted $(get_gitea_repo_args)
|
||||
# word-splitting) so the comment body — including Markdown backticks, $(...),
|
||||
# and quotes — is passed verbatim and never re-split or shell-evaluated.
|
||||
REPO_SLUG=$(get_repo_slug)
|
||||
GITEA_LOGIN_NAME=$(get_gitea_login) || {
|
||||
echo "Error: could not resolve a Gitea login for this repo; cannot comment on issue #$ISSUE_NUMBER." >&2
|
||||
exit 1
|
||||
}
|
||||
tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME"
|
||||
tea issue comment "$ISSUE_NUMBER" "$COMMENT" $(get_gitea_repo_args)
|
||||
echo "Added comment to Gitea issue #$ISSUE_NUMBER"
|
||||
else
|
||||
echo "Error: Unknown platform"
|
||||
|
||||
@@ -72,11 +72,6 @@ elif values and all(v == "success" for v in values):
|
||||
print("success")
|
||||
elif any(v in {"pending", "running", "queued", "waiting"} for v in values):
|
||||
print("pending")
|
||||
elif not values and not state:
|
||||
# No pipeline/status of any kind reported for this commit. Distinct from
|
||||
# "unknown" (an ambiguous/unrecognized status that should keep polling):
|
||||
# this signals a repo/commit that simply has no CI configured.
|
||||
print("no-status")
|
||||
else:
|
||||
print("unknown")
|
||||
PY
|
||||
@@ -147,21 +142,6 @@ gitea_get_commit_status_json() {
|
||||
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url"
|
||||
}
|
||||
|
||||
gitea_get_default_branch() {
|
||||
local host="$1"
|
||||
local repo="$2"
|
||||
local token="$3"
|
||||
local url="https://${host}/api/v1/repos/${repo}"
|
||||
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" | python3 -c '
|
||||
import json, sys
|
||||
print((json.load(sys.stdin) or {}).get("default_branch", ""))
|
||||
'
|
||||
}
|
||||
|
||||
github_get_default_branch() {
|
||||
gh api "repos/${OWNER}/${REPO}" --jq '.default_branch'
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-n|--number)
|
||||
@@ -265,51 +245,6 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# No-CI determination is TWO-TIER (primary: CI history; secondary: empty-poll streak).
|
||||
#
|
||||
# PRIMARY — "does this repo run CI at all?" Probed once, up front, from the DEFAULT
|
||||
# BRANCH's commit status. A repo whose default branch carries CI statuses
|
||||
# demonstrably runs CI, so an EMPTY status on the PR head means the pipeline simply
|
||||
# has not registered YET (webhook/queue lag) — NOT that the repo is CI-less. In that
|
||||
# case we must NEVER fast-green; we keep polling until the pipeline registers or the
|
||||
# timeout fires (both safe). This closes the webhook-lag false-green: a slow-to-
|
||||
# register pipeline feeding a merge gate can no longer be mistaken for "no CI".
|
||||
#
|
||||
# SECONDARY — the empty-poll streak below applies ONLY to genuinely CI-less repos
|
||||
# (default branch also has no CI history, e.g. device-imaging class), where burning
|
||||
# the full timeout would be pure waste. There, NO_CI_MAX empty polls => fast-exit 0.
|
||||
#
|
||||
# Probe failure is treated conservatively as REPO_HAS_CI=1 (assume CI present): we
|
||||
# would rather wait-then-timeout than risk a false-green, per the merge-gate priority.
|
||||
REPO_HAS_CI=1
|
||||
detect_repo_ci() {
|
||||
local def_branch def_status
|
||||
# Every early exit returns 0: a probe miss must leave the conservative
|
||||
# REPO_HAS_CI=1 default in place, never abort the caller under `set -e`.
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
def_branch=$(github_get_default_branch 2>/dev/null) || {
|
||||
echo "[pr-ci-wait] WARN: default-branch probe failed; assuming CI-enabled (will not fast-green on empty status)."; return 0; }
|
||||
[[ -n "$def_branch" ]] || return 0
|
||||
def_status=$(github_get_commit_status_json "$OWNER" "$REPO" "$def_branch" 2>/dev/null | extract_state_from_status_json) || return 0
|
||||
else
|
||||
def_branch=$(gitea_get_default_branch "$HOST" "$OWNER/$REPO" "$TOKEN" 2>/dev/null) || {
|
||||
echo "[pr-ci-wait] WARN: default-branch probe failed; assuming CI-enabled (will not fast-green on empty status)."; return 0; }
|
||||
[[ -n "$def_branch" ]] || return 0
|
||||
def_status=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$TOKEN" "$def_branch" 2>/dev/null | extract_state_from_status_json) || return 0
|
||||
fi
|
||||
if [[ "$def_status" == "no-status" || -z "$def_status" ]]; then
|
||||
REPO_HAS_CI=0
|
||||
echo "[pr-ci-wait] default branch '${def_branch}' has no CI status history — treating repo as CI-less (empty-poll fast-exit enabled)."
|
||||
else
|
||||
REPO_HAS_CI=1
|
||||
echo "[pr-ci-wait] default branch '${def_branch}' has CI history (state=${def_status}) — repo runs CI; empty status on PR head => awaiting registration, will not fast-green."
|
||||
fi
|
||||
}
|
||||
detect_repo_ci || true
|
||||
|
||||
NO_CI_STREAK=0
|
||||
NO_CI_MAX=3
|
||||
|
||||
while true; do
|
||||
NOW_TS=$(date +%s)
|
||||
if (( NOW_TS > DEADLINE_TS )); then
|
||||
@@ -337,35 +272,11 @@ while true; do
|
||||
echo "Error: CI reported ${STATE} for PR #$PR_NUMBER." >&2
|
||||
exit 1
|
||||
;;
|
||||
no-status)
|
||||
if [[ "$REPO_HAS_CI" == "1" ]]; then
|
||||
# PRIMARY tier: repo demonstrably runs CI but this commit's pipeline
|
||||
# has not registered yet (webhook/queue lag). Do NOT fast-green — keep
|
||||
# polling until it registers or the timeout fires. Reset the streak so
|
||||
# a later genuine CI-less misread can't accumulate across this state.
|
||||
NO_CI_STREAK=0
|
||||
echo "[pr-ci-wait] empty status on PR head but repo runs CI — awaiting pipeline registration (webhook lag), not fast-greening."
|
||||
else
|
||||
# SECONDARY tier: genuinely CI-less repo (default branch has no CI
|
||||
# history either). Empty polls => fast-exit green after NO_CI_MAX.
|
||||
NO_CI_STREAK=$((NO_CI_STREAK + 1))
|
||||
if (( NO_CI_STREAK >= NO_CI_MAX )); then
|
||||
echo "[INFO] no CI configured for this repo/commit (PR #$PR_NUMBER, ${NO_CI_STREAK} consecutive empty polls, default branch also CI-less); treating as green."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
sleep "$INTERVAL_SEC"
|
||||
;;
|
||||
pending|unknown)
|
||||
# A pipeline exists but hasn't reached a terminal state (or is
|
||||
# transiently ambiguous) — keep waiting, and reset the no-CI streak
|
||||
# since this commit is not in the "no CI at all" condition.
|
||||
NO_CI_STREAK=0
|
||||
sleep "$INTERVAL_SEC"
|
||||
;;
|
||||
*)
|
||||
echo "[pr-ci-wait] Unrecognized state '${STATE}', continuing to poll..."
|
||||
NO_CI_STREAK=0
|
||||
sleep "$INTERVAL_SEC"
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -230,81 +230,4 @@ if grep -q -- 'tea issue close 536 .*--login mosaicstack' "$LOG_FILE"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #560: loud diagnostic + host-derived login for BOTH instances + override-wins
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Loud diagnostic: a host with no matching tea login must emit an actionable
|
||||
# error to stderr (the previous behavior was a SILENT failure). The original
|
||||
# mock defines only usc/evil-usc logins, so mosaicstack resolution fails here.
|
||||
git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
diag_stderr=$(run_in_repo bash -c '
|
||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||
get_gitea_login_for_host git.mosaicstack.dev
|
||||
' 2>&1 1>/dev/null || true)
|
||||
if ! grep -q "no Gitea tea login matches host 'git.mosaicstack.dev'" <<<"$diag_stderr"; then
|
||||
echo "Expected loud diagnostic naming the unresolved host; got: $diag_stderr" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q "Available tea logins:" <<<"$diag_stderr"; then
|
||||
echo "Expected diagnostic to list available tea logins; got: $diag_stderr" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Both-instance host derivation + override-wins, using a mock that DOES define a
|
||||
# mosaicstack login. Scoped to this section so the API-fallback assertions above
|
||||
# (which rely on mosaicstack having NO tea login) remain valid.
|
||||
BIN_DIR2="$WORK_DIR/bin2"
|
||||
mkdir -p "$BIN_DIR2"
|
||||
cp "$BIN_DIR/curl" "$BIN_DIR2/curl"
|
||||
cat > "$BIN_DIR2/tea" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$*" == "login list --output json" ]]; then
|
||||
cat <<'JSON'
|
||||
[
|
||||
{"name":"mosaicstack","url":"https://git.mosaicstack.dev","user":"jason.woltje"},
|
||||
{"name":"usc","url":"https://git.uscllc.com","user":"jason.woltje"}
|
||||
]
|
||||
JSON
|
||||
exit 0
|
||||
fi
|
||||
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
||||
exit 0
|
||||
SH
|
||||
chmod +x "$BIN_DIR2/tea"
|
||||
|
||||
run_in_repo2() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR2:$PATH" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||
"$@"
|
||||
)
|
||||
}
|
||||
|
||||
git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
mosaic_login=$(run_in_repo2 bash -c 'source "'"$SCRIPT_DIR"'/detect-platform.sh"; get_gitea_login')
|
||||
if [[ "$mosaic_login" != "mosaicstack" ]]; then
|
||||
echo "Expected mosaicstack origin to derive login 'mosaicstack'; got '$mosaic_login'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
|
||||
usc_login_derived=$(run_in_repo2 bash -c 'source "'"$SCRIPT_DIR"'/detect-platform.sh"; get_gitea_login')
|
||||
if [[ "$usc_login_derived" != "usc" ]]; then
|
||||
echo "Expected usc origin to derive login 'usc'; got '$usc_login_derived'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Explicit GITEA_LOGIN override is honored when it matches the host.
|
||||
git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
override_wins=$(run_in_repo2 bash -c 'export GITEA_LOGIN=mosaicstack; source "'"$SCRIPT_DIR"'/detect-platform.sh"; get_gitea_login')
|
||||
if [[ "$override_wins" != "mosaicstack" ]]; then
|
||||
echo "Expected valid GITEA_LOGIN override to win on mosaicstack host; got '$override_wins'" >&2
|
||||
exit 1
|
||||
fi
|
||||
git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
|
||||
|
||||
echo "Gitea login resolution regression harness passed"
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for issue-create.sh Markdown-body safety (#559).
|
||||
#
|
||||
# Guards against reintroduction of eval-based command construction. The wrapper
|
||||
# builds its tea/gh invocation as an argv array, so a body containing command
|
||||
# substitution ($(...)), backticks, quotes, and dollar signs MUST reach tea
|
||||
# verbatim and MUST NOT be shell-evaluated. This test asserts both:
|
||||
# 1. No command-substitution side effect (an injected `touch SENTINEL` never runs).
|
||||
# 2. The --description value tea receives is byte-for-byte the original body.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-body-safety}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
SENTINEL="$WORK_DIR/INJECTION_SENTINEL"
|
||||
BODY_FILE="$WORK_DIR/body.txt"
|
||||
RECEIVED_FILE="$WORK_DIR/received-description.txt"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$REPO_DIR" "$BIN_DIR"
|
||||
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
|
||||
# Hostile Markdown body. The unquoted heredoc expands $SENTINEL (a real path we
|
||||
# want embedded) but every shell metacharacter we care about is backslash-escaped
|
||||
# so the TEST shell writes them literally into the file — the bytes the wrapper
|
||||
# must then preserve.
|
||||
cat > "$BODY_FILE" <<EOF
|
||||
# Release notes
|
||||
|
||||
Inline code: \`rm -rf /\` must stay literal.
|
||||
Command sub attempt: \$(touch $SENTINEL)
|
||||
Backtick cmd attempt: \`touch $SENTINEL\`
|
||||
Dollars: \$HOME \${PATH} \$5.00 and 100% done
|
||||
Quotes: "double" and 'single' and \`mixed\`
|
||||
Trailing pipe-ish: foo | bar && baz ; qux
|
||||
EOF
|
||||
|
||||
BODY="$(cat "$BODY_FILE")"
|
||||
|
||||
# Mock tea: resolve a mosaicstack login, then capture the --description verbatim.
|
||||
cat > "$BIN_DIR/tea" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$*" == "login list --output json" ]]; then
|
||||
cat <<'JSON'
|
||||
[
|
||||
{"name":"mosaicstack","url":"https://git.mosaicstack.dev","user":"jason.woltje"}
|
||||
]
|
||||
JSON
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "issue" && "${2:-}" == "create" ]]; then
|
||||
desc=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--description) desc="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
printf '%s' "$desc" > "$MOSAIC_TEST_RECEIVED"
|
||||
echo "#1 created"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit 0
|
||||
SH
|
||||
chmod +x "$BIN_DIR/tea"
|
||||
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
MOSAIC_TEST_RECEIVED="$RECEIVED_FILE" \
|
||||
"$SCRIPT_DIR/issue-create.sh" -t "Body safety test" -b "$BODY"
|
||||
) >/dev/null
|
||||
|
||||
# 1. No command substitution executed anywhere in the pipeline.
|
||||
if [[ -e "$SENTINEL" ]]; then
|
||||
echo "FAIL: injected command substitution executed (sentinel file created): $SENTINEL" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. tea actually received the body (issue create path taken, not silently dropped).
|
||||
if [[ ! -f "$RECEIVED_FILE" ]]; then
|
||||
echo "FAIL: tea issue create was never invoked with a --description" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. The description tea received is byte-for-byte the original body.
|
||||
if [[ "$(cat "$RECEIVED_FILE")" != "$BODY" ]]; then
|
||||
echo "FAIL: body was not preserved verbatim through issue-create.sh" >&2
|
||||
echo "--- expected ---" >&2; printf '%s\n' "$BODY" >&2
|
||||
echo "--- received ---" >&2; cat "$RECEIVED_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "issue-create.sh Markdown body-safety regression harness passed"
|
||||
@@ -12,7 +12,7 @@ wp_resolve_repo_id() {
|
||||
local full_name="$1"
|
||||
local response http_code body repo_id
|
||||
|
||||
response=$(curl -sS -w "\n%{http_code}" \
|
||||
response=$(curl -sk -w "\n%{http_code}" \
|
||||
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
|
||||
"${WOODPECKER_URL}/api/repos/lookup/${full_name}")
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ fi
|
||||
# Resolve owner/repo to numeric ID (Woodpecker v3 API)
|
||||
REPO_ID=$(wp_resolve_repo_id "$REPO") || exit 1
|
||||
|
||||
response=$(curl -sS -w "\n%{http_code}" \
|
||||
response=$(curl -sk -w "\n%{http_code}" \
|
||||
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
|
||||
"${WOODPECKER_URL}/api/repos/${REPO_ID}/pipelines?perPage=${LIMIT}")
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ REPO_ID=$(wp_resolve_repo_id "$REPO") || exit 1
|
||||
_wp_fetch() {
|
||||
local ep="$1"
|
||||
local resp http_code body
|
||||
resp=$(curl -sS -w "\n%{http_code}" \
|
||||
resp=$(curl -sk -w "\n%{http_code}" \
|
||||
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
|
||||
"$ep")
|
||||
http_code=$(echo "$resp" | tail -n1)
|
||||
|
||||
@@ -46,7 +46,7 @@ REPO_ID=$(wp_resolve_repo_id "$REPO") || exit 1
|
||||
|
||||
echo "Triggering pipeline for $REPO on branch $BRANCH..."
|
||||
|
||||
response=$(curl -sS -w "\n%{http_code}" -X POST \
|
||||
response=$(curl -sk -w "\n%{http_code}" -X POST \
|
||||
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg b "$BRANCH" '{branch: $b}')" \
|
||||
|
||||
@@ -13,7 +13,6 @@ import { registerStorageCommand } from '@mosaicstack/storage';
|
||||
import { registerTelemetryCommand } from './commands/telemetry.js';
|
||||
import { registerAgentCommand } from './commands/agent.js';
|
||||
import { registerConfigCommand } from './commands/config.js';
|
||||
import { registerFleetCommand } from './commands/fleet.js';
|
||||
import { registerMissionCommand } from './commands/mission.js';
|
||||
import { registerUninstallCommand } from './commands/uninstall.js';
|
||||
// prdy is registered via launch.ts
|
||||
@@ -58,7 +57,7 @@ Command Groups:
|
||||
|
||||
Runtime: tui, login, sessions
|
||||
Gateway: gateway
|
||||
Framework: agent, bootstrap, coord, doctor, fleet, init, launch, mission, prdy, seq, sync, upgrade, wizard, yolo
|
||||
Framework: agent, bootstrap, coord, doctor, init, launch, mission, prdy, seq, sync, upgrade, wizard, yolo
|
||||
Platform: update
|
||||
Runtimes: claude, codex, opencode, pi
|
||||
`,
|
||||
@@ -346,10 +345,6 @@ registerFederationCommand(program);
|
||||
|
||||
registerAgentCommand(program);
|
||||
|
||||
// ─── fleet ─────────────────────────────────────────────────────────────
|
||||
|
||||
registerFleetCommand(program);
|
||||
|
||||
// ─── config ────────────────────────────────────────────────────────────
|
||||
|
||||
registerConfigCommand(program);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Command } from 'commander';
|
||||
import { registerFleetAgentCommands, type FleetCommandDeps } from './fleet.js';
|
||||
import { withAuth } from './with-auth.js';
|
||||
import { selectItem } from './select-dialog.js';
|
||||
import {
|
||||
@@ -31,13 +30,11 @@ function showAgentDetail(a: AgentConfigInfo) {
|
||||
console.log(` Created: ${new Date(a.createdAt).toLocaleString()}`);
|
||||
}
|
||||
|
||||
export function registerAgentCommand(program: Command, fleetDeps: FleetCommandDeps = {}) {
|
||||
export function registerAgentCommand(program: Command) {
|
||||
const cmd = program
|
||||
.command('agent')
|
||||
.description('Manage agent configurations and local fleet agents')
|
||||
.description('Manage agent configurations')
|
||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||
.option('--mosaic-home <path>', 'Mosaic home directory')
|
||||
.option('--roster <path>', 'Local fleet roster path')
|
||||
.option('--list', 'List all agents')
|
||||
.option('--new', 'Create a new agent')
|
||||
.option('--show <idOrName>', 'Show agent details')
|
||||
@@ -75,8 +72,6 @@ export function registerAgentCommand(program: Command, fleetDeps: FleetCommandDe
|
||||
},
|
||||
);
|
||||
|
||||
registerFleetAgentCommands(cmd, fleetDeps);
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,563 +0,0 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
buildAgentSendCommand,
|
||||
buildFleetServiceCommand,
|
||||
generateAgentEnv,
|
||||
getRosterAgent,
|
||||
loadFleetRoster,
|
||||
registerFleetCommand,
|
||||
resolveFleetPaths,
|
||||
type CommandRunner,
|
||||
} from './fleet.js';
|
||||
import { registerAgentCommand } from './agent.js';
|
||||
|
||||
function buildProgram(): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program);
|
||||
registerAgentCommand(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
async function tempDir(): Promise<string> {
|
||||
return mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||||
}
|
||||
|
||||
describe('registerFleetCommand', () => {
|
||||
it('registers local canary fleet subcommands', () => {
|
||||
const program = buildProgram();
|
||||
const fleet = program.commands.find((command) => command.name() === 'fleet');
|
||||
|
||||
expect(fleet).toBeDefined();
|
||||
expect(fleet!.commands.map((command) => command.name()).sort()).toEqual([
|
||||
'init',
|
||||
'install',
|
||||
'install-systemd',
|
||||
'restart',
|
||||
'start',
|
||||
'status',
|
||||
'stop',
|
||||
'verify',
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds fleet-backed agent subcommands without removing existing options', () => {
|
||||
const program = buildProgram();
|
||||
const agent = program.commands.find((command) => command.name() === 'agent');
|
||||
|
||||
expect(agent).toBeDefined();
|
||||
expect(agent!.options.map((option) => option.long)).toContain('--list');
|
||||
expect(agent!.commands.map((command) => command.name()).sort()).toEqual([
|
||||
'reset',
|
||||
'roster',
|
||||
'send',
|
||||
'status',
|
||||
'tail',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fleet roster parsing', () => {
|
||||
let cleanup: string | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
if (cleanup) {
|
||||
await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults local canary rosters to the isolated mosaic-factory socket', async () => {
|
||||
cleanup = await tempDir();
|
||||
const rosterPath = join(cleanup, 'roster.yaml');
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: canary-pi',
|
||||
' runtime: pi',
|
||||
' class: canary',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const roster = await loadFleetRoster(rosterPath);
|
||||
|
||||
expect(roster.tmux.socketName).toBe('mosaic-factory');
|
||||
expect(roster.tmux.holderSession).toBe('_holder');
|
||||
expect(roster.agents).toHaveLength(1);
|
||||
expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi');
|
||||
});
|
||||
|
||||
it('generates deterministic per-agent EnvironmentFile content', async () => {
|
||||
cleanup = await tempDir();
|
||||
const rosterPath = join(cleanup, 'roster.json');
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
tmux: { socket_name: 'mosaic-factory' },
|
||||
defaults: { working_directory: '/srv/mosaic' },
|
||||
agents: [{ name: 'coder0', runtime: 'codex', class: 'implementer' }],
|
||||
}),
|
||||
);
|
||||
const roster = await loadFleetRoster(rosterPath);
|
||||
|
||||
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
|
||||
[
|
||||
'MOSAIC_AGENT_NAME=coder0',
|
||||
'MOSAIC_AGENT_RUNTIME=codex',
|
||||
'MOSAIC_AGENT_WORKDIR=/srv/mosaic',
|
||||
'MOSAIC_TMUX_SOCKET=mosaic-factory',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects unknown roster fields instead of silently defaulting', async () => {
|
||||
cleanup = await tempDir();
|
||||
const rosterPath = join(cleanup, 'roster.yaml');
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'tmux:',
|
||||
' socketNamee: prod-fleet',
|
||||
'agents:',
|
||||
' - name: canary-pi',
|
||||
' runtime: pi',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
|
||||
'Fleet roster tmux has unknown field(s): socketNamee.',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects wrong-typed roster fields instead of silently defaulting', async () => {
|
||||
cleanup = await tempDir();
|
||||
const rosterPath = join(cleanup, 'roster.json');
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
tmux: { socket_name: 123 },
|
||||
defaults: { working_directory: '/srv/mosaic' },
|
||||
agents: [{ name: 'canary-pi', runtime: 'pi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
|
||||
'Fleet roster tmux socket_name must be a string.',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects wrong-typed agent fields', async () => {
|
||||
cleanup = await tempDir();
|
||||
const rosterPath = join(cleanup, 'roster.json');
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
agents: [{ name: 'canary-pi', runtime: 42 }],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
|
||||
'Fleet roster agent "canary-pi" runtime must be a string.',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects duplicate agent names before install can overwrite env files', async () => {
|
||||
cleanup = await tempDir();
|
||||
const rosterPath = join(cleanup, 'roster.yaml');
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: canary-pi',
|
||||
' runtime: pi',
|
||||
' - name: canary-pi',
|
||||
' runtime: codex',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
|
||||
'Fleet roster has duplicate agent name: canary-pi.',
|
||||
);
|
||||
});
|
||||
|
||||
it('ships generic minimal and local-canary examples without site-specific defaults', async () => {
|
||||
const examplesDir = resolve(process.cwd(), 'framework', 'fleet', 'examples');
|
||||
const minimal = await loadFleetRoster(join(examplesDir, 'minimal.yaml'));
|
||||
const localCanaryText = await readFile(join(examplesDir, 'local-canary.yaml'), 'utf8');
|
||||
const localCanary = await loadFleetRoster(join(examplesDir, 'local-canary.yaml'));
|
||||
|
||||
expect(minimal.agents.map((agent) => agent.name)).toEqual(['canary-pi']);
|
||||
expect(localCanary.tmux.socketName).toBe('mosaic-factory');
|
||||
expect(localCanary.agents.map((agent) => agent.name)).toEqual(['lead', 'coder0', 'reviewer0']);
|
||||
expect(localCanaryText).not.toMatch(/usc|ultron|secrev/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fleet command construction', () => {
|
||||
it('builds exact systemd user commands for holder and agent operations', () => {
|
||||
expect(buildFleetServiceCommand('status')).toEqual([
|
||||
'systemctl',
|
||||
'--user',
|
||||
'status',
|
||||
'mosaic-tmux-holder.service',
|
||||
]);
|
||||
expect(buildFleetServiceCommand('restart', 'coder0')).toEqual([
|
||||
'systemctl',
|
||||
'--user',
|
||||
'restart',
|
||||
'mosaic-agent@coder0.service',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds socket-scoped agent send commands', () => {
|
||||
const paths = resolveFleetPaths('/home/test/.config/mosaic');
|
||||
expect(buildAgentSendCommand(paths, 'coder0', 'hello', 'mosaic-factory')).toEqual([
|
||||
'/home/test/.config/mosaic/tools/tmux/agent-send.sh',
|
||||
'-L',
|
||||
'mosaic-factory',
|
||||
'-s',
|
||||
'coder0',
|
||||
'-m',
|
||||
'hello',
|
||||
]);
|
||||
});
|
||||
|
||||
it('runs fleet status through injected runner without touching tmux in tests', async () => {
|
||||
const calls: string[][] = [];
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
return { stdout: 'ok\n', stderr: '', exitCode: 0 };
|
||||
};
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner });
|
||||
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
||||
|
||||
expect(calls).toEqual([['systemctl', '--user', 'status', 'mosaic-tmux-holder.service']]);
|
||||
});
|
||||
|
||||
it('writes init output to the explicit roster path', async () => {
|
||||
const home = await tempDir();
|
||||
const rosterPath = join(home, 'custom', 'roster.yaml');
|
||||
const frameworkRoot = resolve(process.cwd(), 'framework');
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { frameworkRoot, mosaicHome: home });
|
||||
|
||||
try {
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'--roster',
|
||||
rosterPath,
|
||||
'init',
|
||||
'--profile',
|
||||
'minimal',
|
||||
'--write',
|
||||
]);
|
||||
|
||||
const content = await readFile(rosterPath, 'utf8');
|
||||
expect(content).toContain('name: canary-pi');
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses to overwrite an existing roster unless --force is provided', async () => {
|
||||
const home = await tempDir();
|
||||
const rosterPath = join(home, 'custom', 'roster.yaml');
|
||||
await mkdir(dirname(rosterPath), { recursive: true });
|
||||
await writeFile(rosterPath, 'site-owned: true\n');
|
||||
const frameworkRoot = resolve(process.cwd(), 'framework');
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { frameworkRoot, mosaicHome: home });
|
||||
|
||||
try {
|
||||
await expect(
|
||||
program.parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'--roster',
|
||||
rosterPath,
|
||||
'init',
|
||||
'--profile',
|
||||
'minimal',
|
||||
'--write',
|
||||
]),
|
||||
).rejects.toThrow('Fleet roster already exists');
|
||||
expect(await readFile(rosterPath, 'utf8')).toBe('site-owned: true\n');
|
||||
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'--roster',
|
||||
rosterPath,
|
||||
'init',
|
||||
'--profile',
|
||||
'minimal',
|
||||
'--write',
|
||||
'--force',
|
||||
]);
|
||||
expect(await readFile(rosterPath, 'utf8')).toContain('name: canary-pi');
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unknown init profiles instead of silently falling back', async () => {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { frameworkRoot: resolve(process.cwd(), 'framework') });
|
||||
|
||||
await expect(
|
||||
program.parseAsync(['node', 'mosaic', 'fleet', 'init', '--profile', 'typo']),
|
||||
).rejects.toThrow('Unsupported fleet profile');
|
||||
});
|
||||
|
||||
it('sets process exitCode when status runner fails', async () => {
|
||||
const originalExitCode = process.exitCode;
|
||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
const runner: CommandRunner = async () => ({ stdout: '', stderr: 'missing\n', exitCode: 3 });
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner });
|
||||
|
||||
try {
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'status']);
|
||||
expect(process.exitCode).toBe(3);
|
||||
} finally {
|
||||
process.exitCode = originalExitCode;
|
||||
stderrSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('loads default fleet/roster.json when roster.yaml is absent', async () => {
|
||||
const home = await tempDir();
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
join(home, 'fleet', 'roster.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
agents: [{ name: 'json-canary', runtime: 'pi' }],
|
||||
}),
|
||||
);
|
||||
const calls: string[][] = [];
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
};
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||
|
||||
try {
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'status', 'json-canary']);
|
||||
expect(calls).toEqual([
|
||||
['systemctl', '--user', 'status', 'mosaic-agent@json-canary.service'],
|
||||
]);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('starts the holder before agents and stops agents before the holder', async () => {
|
||||
const home = await tempDir();
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
const calls: string[][] = [];
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
};
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||
|
||||
try {
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'start']);
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'stop']);
|
||||
|
||||
expect(calls).toEqual([
|
||||
['systemctl', '--user', 'start', 'mosaic-tmux-holder.service'],
|
||||
['systemctl', '--user', 'start', 'mosaic-agent@coder0.service'],
|
||||
['systemctl', '--user', 'stop', 'mosaic-agent@coder0.service'],
|
||||
['systemctl', '--user', 'stop', 'mosaic-tmux-holder.service'],
|
||||
]);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('attempts every agent and the holder during fleet stop even when an agent stop fails', async () => {
|
||||
const home = await tempDir();
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: coder0',
|
||||
' runtime: codex',
|
||||
' - name: reviewer0',
|
||||
' runtime: pi',
|
||||
].join('\n'),
|
||||
);
|
||||
const calls: string[][] = [];
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
if (args.includes('mosaic-agent@coder0.service')) {
|
||||
return { stdout: '', stderr: 'coder0 failed\n', exitCode: 1 };
|
||||
}
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
};
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||
|
||||
try {
|
||||
await expect(program.parseAsync(['node', 'mosaic', 'fleet', 'stop'])).rejects.toThrow(
|
||||
'Fleet stop completed with 1 failure(s)',
|
||||
);
|
||||
expect(calls).toEqual([
|
||||
['systemctl', '--user', 'stop', 'mosaic-agent@coder0.service'],
|
||||
['systemctl', '--user', 'stop', 'mosaic-agent@reviewer0.service'],
|
||||
['systemctl', '--user', 'stop', 'mosaic-tmux-holder.service'],
|
||||
]);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects install-systemd with a non-default Mosaic home because units use %h/.config/mosaic', async () => {
|
||||
const home = await tempDir();
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, {
|
||||
mosaicHome: home,
|
||||
frameworkRoot: resolve(process.cwd(), 'framework'),
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
program.parseAsync(['node', 'mosaic', 'fleet', 'install-systemd']),
|
||||
).rejects.toThrow('install-systemd only supports the default Mosaic home');
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['start', 'stop', 'restart', 'status'] as const)(
|
||||
'rejects single-agent %s for agents outside the roster',
|
||||
async (action) => {
|
||||
const home = await tempDir();
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
const runner = vi.fn<CommandRunner>(async () => ({ stdout: '', stderr: '', exitCode: 0 }));
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||
|
||||
try {
|
||||
await expect(
|
||||
program.parseAsync(['node', 'mosaic', 'fleet', action, 'typo']),
|
||||
).rejects.toThrow('Agent "typo" is not in the fleet roster');
|
||||
expect(runner).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('loads default fleet/roster.json for agent commands when roster.yaml is absent', async () => {
|
||||
const home = await tempDir();
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
join(home, 'fleet', 'roster.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
agents: [{ name: 'json-agent', runtime: 'pi' }],
|
||||
}),
|
||||
);
|
||||
const calls: string[][] = [];
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
};
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommand(program, { runner, mosaicHome: home });
|
||||
|
||||
try {
|
||||
await program.parseAsync(['node', 'mosaic', 'agent', 'status', 'json-agent']);
|
||||
expect(calls).toEqual([
|
||||
['tmux', '-L', 'mosaic-factory', 'has-session', '-t', '=json-agent:0.0'],
|
||||
]);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects agent status typos before invoking the runner', async () => {
|
||||
const home = await tempDir();
|
||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(
|
||||
rosterPath,
|
||||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
const runner = vi.fn<CommandRunner>(async () => ({ stdout: '', stderr: '', exitCode: 0 }));
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerAgentCommand(program, { runner, mosaicHome: home });
|
||||
|
||||
try {
|
||||
await expect(
|
||||
program.parseAsync(['node', 'mosaic', 'agent', 'status', 'typo']),
|
||||
).rejects.toThrow('Agent "typo" is not in the fleet roster');
|
||||
expect(runner).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,851 +0,0 @@
|
||||
import { constants } from 'node:fs';
|
||||
import { access, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { Command } from 'commander';
|
||||
import YAML from 'yaml';
|
||||
|
||||
export interface CommandResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
export type CommandRunner = (command: string, args: string[]) => Promise<CommandResult>;
|
||||
|
||||
export interface FleetCommandDeps {
|
||||
runner?: CommandRunner;
|
||||
mosaicHome?: string;
|
||||
frameworkRoot?: string;
|
||||
}
|
||||
|
||||
interface RawFleetRoster {
|
||||
version?: unknown;
|
||||
transport?: unknown;
|
||||
tmux?: {
|
||||
socket_name?: unknown;
|
||||
socketName?: unknown;
|
||||
holder_session?: unknown;
|
||||
holderSession?: unknown;
|
||||
};
|
||||
defaults?: {
|
||||
working_directory?: unknown;
|
||||
workingDirectory?: unknown;
|
||||
};
|
||||
runtimes?: Record<string, { reset_command?: unknown; resetCommand?: unknown }>;
|
||||
agents?: Array<{
|
||||
name?: unknown;
|
||||
runtime?: unknown;
|
||||
class?: unknown;
|
||||
working_directory?: unknown;
|
||||
workingDirectory?: unknown;
|
||||
model_hint?: unknown;
|
||||
modelHint?: unknown;
|
||||
persistent_persona?: unknown;
|
||||
persistentPersona?: unknown;
|
||||
reset_between_tasks?: unknown;
|
||||
resetBetweenTasks?: unknown;
|
||||
kickstart_template?: unknown;
|
||||
kickstartTemplate?: unknown;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface FleetAgent {
|
||||
name: string;
|
||||
runtime: string;
|
||||
className: string;
|
||||
workingDirectory?: string;
|
||||
modelHint?: string;
|
||||
persistentPersona?: boolean | string;
|
||||
resetBetweenTasks?: boolean;
|
||||
kickstartTemplate?: string;
|
||||
}
|
||||
|
||||
export interface FleetRoster {
|
||||
version: 1;
|
||||
transport: 'tmux';
|
||||
tmux: {
|
||||
socketName: string;
|
||||
holderSession: string;
|
||||
};
|
||||
defaults: {
|
||||
workingDirectory: string;
|
||||
};
|
||||
runtimes: Record<string, { resetCommand: string }>;
|
||||
agents: FleetAgent[];
|
||||
}
|
||||
|
||||
export interface FleetPaths {
|
||||
mosaicHome: string;
|
||||
rosterPath: string;
|
||||
toolsDir: string;
|
||||
fleetToolsDir: string;
|
||||
tmuxToolsDir: string;
|
||||
systemdUserDir: string;
|
||||
agentEnvDir: string;
|
||||
}
|
||||
|
||||
type FleetServiceAction = 'start' | 'stop' | 'restart' | 'status';
|
||||
|
||||
const DEFAULT_SOCKET_NAME = 'mosaic-factory';
|
||||
const DEFAULT_HOLDER_SESSION = '_holder';
|
||||
const DEFAULT_WORKING_DIRECTORY = '~/src';
|
||||
const DEFAULT_RUNTIME_RESETS: Record<string, { resetCommand: string }> = {
|
||||
claude: { resetCommand: '/clear' },
|
||||
codex: { resetCommand: '/clear' },
|
||||
opencode: { resetCommand: '/clear' },
|
||||
pi: { resetCommand: '/new' },
|
||||
};
|
||||
|
||||
export function resolveFleetPaths(mosaicHome = defaultMosaicHome()): FleetPaths {
|
||||
return {
|
||||
mosaicHome,
|
||||
rosterPath: join(mosaicHome, 'fleet', 'roster.yaml'),
|
||||
toolsDir: join(mosaicHome, 'tools'),
|
||||
fleetToolsDir: join(mosaicHome, 'tools', 'fleet'),
|
||||
tmuxToolsDir: join(mosaicHome, 'tools', 'tmux'),
|
||||
systemdUserDir: join(homedir(), '.config', 'systemd', 'user'),
|
||||
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
|
||||
};
|
||||
}
|
||||
|
||||
function defaultMosaicHome(): string {
|
||||
return join(homedir(), '.config', 'mosaic');
|
||||
}
|
||||
|
||||
function assertDefaultMosaicHomeForSystemd(mosaicHome: string): void {
|
||||
if (resolve(mosaicHome) !== resolve(defaultMosaicHome())) {
|
||||
throw new Error(
|
||||
`install-systemd only supports the default Mosaic home (${defaultMosaicHome()}) because the user systemd units use %h/.config/mosaic paths.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadFleetRoster(path: string): Promise<FleetRoster> {
|
||||
const rawText = await readFile(path, 'utf8');
|
||||
const parsed = parseRosterText(rawText, path);
|
||||
return normalizeRoster(parsed);
|
||||
}
|
||||
|
||||
export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
|
||||
const agent = roster.agents.find((candidate) => candidate.name === name);
|
||||
if (!agent) {
|
||||
throw new Error(`Agent "${name}" is not in the fleet roster.`);
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
export function generateAgentEnv(roster: FleetRoster, agent: FleetAgent): string {
|
||||
const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory;
|
||||
return [
|
||||
`MOSAIC_AGENT_NAME=${shellEnvValue(agent.name)}`,
|
||||
`MOSAIC_AGENT_RUNTIME=${shellEnvValue(agent.runtime)}`,
|
||||
`MOSAIC_AGENT_WORKDIR=${shellEnvValue(expandHome(workingDirectory))}`,
|
||||
`MOSAIC_TMUX_SOCKET=${shellEnvValue(roster.tmux.socketName)}`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildFleetServiceCommand(action: FleetServiceAction, agentName?: string): string[] {
|
||||
const service = agentName ? `mosaic-agent@${agentName}.service` : 'mosaic-tmux-holder.service';
|
||||
return ['systemctl', '--user', action, service];
|
||||
}
|
||||
|
||||
export function buildAgentSendCommand(
|
||||
paths: FleetPaths,
|
||||
agentName: string,
|
||||
message: string,
|
||||
socketName = DEFAULT_SOCKET_NAME,
|
||||
): string[] {
|
||||
return [
|
||||
join(paths.tmuxToolsDir, 'agent-send.sh'),
|
||||
'-L',
|
||||
socketName,
|
||||
'-s',
|
||||
agentName,
|
||||
'-m',
|
||||
message,
|
||||
];
|
||||
}
|
||||
|
||||
export function buildAgentResetCommand(
|
||||
paths: FleetPaths,
|
||||
agentName: string,
|
||||
resetCommand: string,
|
||||
socketName = DEFAULT_SOCKET_NAME,
|
||||
): string[] {
|
||||
return [
|
||||
join(paths.tmuxToolsDir, 'send-message.sh'),
|
||||
'-L',
|
||||
socketName,
|
||||
'-t',
|
||||
`=${agentName}`,
|
||||
'-m',
|
||||
resetCommand,
|
||||
];
|
||||
}
|
||||
|
||||
export function buildAgentTailCommand(
|
||||
agentName: string,
|
||||
lines: number,
|
||||
socketName = DEFAULT_SOCKET_NAME,
|
||||
): string[] {
|
||||
return [
|
||||
'tmux',
|
||||
'-L',
|
||||
socketName,
|
||||
'capture-pane',
|
||||
'-t',
|
||||
`=${agentName}:0.0`,
|
||||
'-p',
|
||||
'-S',
|
||||
`-${lines}`,
|
||||
];
|
||||
}
|
||||
|
||||
export function registerFleetCommand(program: Command, deps: FleetCommandDeps = {}): Command {
|
||||
const runner = deps.runner ?? runCommand;
|
||||
const paths = resolveFleetPaths(deps.mosaicHome);
|
||||
const frameworkRoot = deps.frameworkRoot ?? resolveFrameworkRoot();
|
||||
|
||||
const cmd = program
|
||||
.command('fleet')
|
||||
.description('Manage the local Mosaic tmux fleet canary')
|
||||
.option('--mosaic-home <path>', 'Mosaic home directory', paths.mosaicHome)
|
||||
.option('--roster <path>', 'Fleet roster path');
|
||||
|
||||
cmd
|
||||
.command('init')
|
||||
.description('Initialize a local fleet roster')
|
||||
.option('--profile <name>', 'Roster profile: minimal or local-canary', 'minimal')
|
||||
.option('--write', 'Write the roster to Mosaic home')
|
||||
.option('--force', 'Overwrite an existing roster when used with --write')
|
||||
.action(async (opts: { profile: string; write?: boolean; force?: boolean }) => {
|
||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||
const profile = parseInitProfile(opts.profile);
|
||||
const source = join(frameworkRoot, 'fleet', 'examples', `${profile}.yaml`);
|
||||
const content = await readFile(source, 'utf8');
|
||||
if (!opts.write) {
|
||||
console.log(content.trimEnd());
|
||||
return;
|
||||
}
|
||||
const destination = commandOpts.roster ?? activePaths.rosterPath;
|
||||
if (!opts.force && (await canRead(destination))) {
|
||||
throw new Error(
|
||||
`Fleet roster already exists: ${destination}. Re-run with --force to overwrite.`,
|
||||
);
|
||||
}
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await writeFile(destination, content);
|
||||
console.log(`Wrote fleet roster: ${destination}`);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('install')
|
||||
.description('Install local fleet tools and user systemd units')
|
||||
.action(async () => installFleet(cmd, frameworkRoot));
|
||||
|
||||
cmd
|
||||
.command('install-systemd')
|
||||
.description('Install local fleet tools and user systemd units')
|
||||
.action(async () => installFleet(cmd, frameworkRoot));
|
||||
|
||||
for (const action of ['start', 'stop', 'restart'] as const) {
|
||||
cmd
|
||||
.command(`${action} [agent]`)
|
||||
.description(`${action} the fleet holder or one agent`)
|
||||
.action(async (agent?: string) => {
|
||||
const roster = await loadRosterForCommand(cmd);
|
||||
if (agent) {
|
||||
getRosterAgent(roster, agent);
|
||||
await runChecked(runner, buildFleetServiceCommand(action, agent));
|
||||
return;
|
||||
}
|
||||
if (action === 'stop') {
|
||||
await stopFleetBestEffort(
|
||||
runner,
|
||||
roster.agents.map((rosterAgent) => rosterAgent.name),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await runChecked(runner, buildFleetServiceCommand(action));
|
||||
for (const rosterAgent of roster.agents) {
|
||||
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cmd
|
||||
.command('status [agent]')
|
||||
.description('Show fleet holder or agent systemd status')
|
||||
.option('--json', 'Print JSON status')
|
||||
.action(async (agent: string | undefined, opts: { json?: boolean }) => {
|
||||
if (agent) {
|
||||
const roster = await loadRosterForCommand(cmd);
|
||||
getRosterAgent(roster, agent);
|
||||
}
|
||||
const result = await runner(...splitCommand(buildFleetServiceCommand('status', agent)));
|
||||
if (opts.json) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
exitCode: result.exitCode,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
}),
|
||||
);
|
||||
setExitCodeFromResult(result);
|
||||
return;
|
||||
}
|
||||
writeCommandOutput(result);
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('verify')
|
||||
.description('Verify the local canary holder and roster sessions on the isolated socket')
|
||||
.action(async () => {
|
||||
const roster = await loadRosterForCommand(cmd);
|
||||
const socketName = roster.tmux.socketName;
|
||||
await runChecked(runner, [
|
||||
'tmux',
|
||||
'-L',
|
||||
socketName,
|
||||
'has-session',
|
||||
'-t',
|
||||
`=${roster.tmux.holderSession}:0.0`,
|
||||
]);
|
||||
for (const agent of roster.agents) {
|
||||
await runChecked(runner, [
|
||||
'tmux',
|
||||
'-L',
|
||||
socketName,
|
||||
'has-session',
|
||||
'-t',
|
||||
`=${agent.name}:0.0`,
|
||||
]);
|
||||
}
|
||||
console.log(`Verified fleet on tmux socket ${socketName}.`);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
export function registerFleetAgentCommands(
|
||||
agentCommand: Command,
|
||||
deps: FleetCommandDeps = {},
|
||||
): void {
|
||||
const runner = deps.runner ?? runCommand;
|
||||
|
||||
agentCommand
|
||||
.command('roster')
|
||||
.description('List agents from the local fleet roster')
|
||||
.option('--json', 'Print JSON')
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const roster = await loadRosterFromAgentCommand(agentCommand, deps.mosaicHome);
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(roster, null, 2));
|
||||
return;
|
||||
}
|
||||
for (const agent of roster.agents) {
|
||||
console.log(`${agent.name}\t${agent.runtime}\t${agent.className}`);
|
||||
}
|
||||
});
|
||||
|
||||
agentCommand
|
||||
.command('status [agent]')
|
||||
.description('Show tmux status for the local fleet or one agent')
|
||||
.option('--json', 'Print JSON')
|
||||
.action(async (agent: string | undefined, opts: { json?: boolean }) => {
|
||||
const roster = await loadRosterFromAgentCommand(agentCommand, deps.mosaicHome);
|
||||
if (agent) {
|
||||
getRosterAgent(roster, agent);
|
||||
}
|
||||
const command = agent
|
||||
? ['tmux', '-L', roster.tmux.socketName, 'has-session', '-t', `=${agent}:0.0`]
|
||||
: ['tmux', '-L', roster.tmux.socketName, 'ls'];
|
||||
const result = await runner(...splitCommand(command));
|
||||
if (opts.json) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
exitCode: result.exitCode,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
}),
|
||||
);
|
||||
setExitCodeFromResult(result);
|
||||
return;
|
||||
}
|
||||
writeCommandOutput(result);
|
||||
});
|
||||
|
||||
agentCommand
|
||||
.command('send <agent>')
|
||||
.description('Send a message to a local fleet agent')
|
||||
.requiredOption('--message <text>', 'Message text')
|
||||
.action(async (agent: string, opts: { message: string }) => {
|
||||
const roster = await loadRosterFromAgentCommand(agentCommand, deps.mosaicHome);
|
||||
getRosterAgent(roster, agent);
|
||||
const paths = resolveFleetPaths(resolveMosaicHomeFromCommand(agentCommand, deps.mosaicHome));
|
||||
await runChecked(
|
||||
runner,
|
||||
buildAgentSendCommand(paths, agent, opts.message, roster.tmux.socketName),
|
||||
);
|
||||
});
|
||||
|
||||
agentCommand
|
||||
.command('reset <agent>')
|
||||
.description('Reset a local fleet agent by sending the runtime reset command')
|
||||
.option('--clear', 'Send /clear')
|
||||
.option('--new', 'Send /new')
|
||||
.action(async (agent: string, opts: { clear?: boolean; new?: boolean }) => {
|
||||
const roster = await loadRosterFromAgentCommand(agentCommand, deps.mosaicHome);
|
||||
const rosterAgent = getRosterAgent(roster, agent);
|
||||
const paths = resolveFleetPaths(resolveMosaicHomeFromCommand(agentCommand, deps.mosaicHome));
|
||||
const resetCommand = opts.clear
|
||||
? '/clear'
|
||||
: opts.new
|
||||
? '/new'
|
||||
: (roster.runtimes[rosterAgent.runtime]?.resetCommand ?? '/clear');
|
||||
await runChecked(
|
||||
runner,
|
||||
buildAgentResetCommand(paths, agent, resetCommand, roster.tmux.socketName),
|
||||
);
|
||||
});
|
||||
|
||||
agentCommand
|
||||
.command('tail <agent>')
|
||||
.description('Print recent pane output for a local fleet agent')
|
||||
.option('-n, --lines <number>', 'Number of pane history lines', '80')
|
||||
.action(async (agent: string, opts: { lines: string }) => {
|
||||
const roster = await loadRosterFromAgentCommand(agentCommand, deps.mosaicHome);
|
||||
getRosterAgent(roster, agent);
|
||||
const lines = Number.parseInt(opts.lines, 10);
|
||||
const result = await runner(
|
||||
...splitCommand(
|
||||
buildAgentTailCommand(agent, Number.isFinite(lines) ? lines : 80, roster.tmux.socketName),
|
||||
),
|
||||
);
|
||||
writeCommandOutput(result);
|
||||
});
|
||||
}
|
||||
|
||||
async function installFleet(cmd: Command, frameworkRoot: string): Promise<void> {
|
||||
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
|
||||
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
|
||||
const roster = await loadRosterForCommand(cmd);
|
||||
await mkdir(activePaths.fleetToolsDir, { recursive: true });
|
||||
await mkdir(activePaths.tmuxToolsDir, { recursive: true });
|
||||
await mkdir(activePaths.systemdUserDir, { recursive: true });
|
||||
await mkdir(activePaths.agentEnvDir, { recursive: true });
|
||||
|
||||
await copyFile(
|
||||
join(frameworkRoot, 'tools', 'fleet', 'start-agent-session.sh'),
|
||||
join(activePaths.fleetToolsDir, 'start-agent-session.sh'),
|
||||
);
|
||||
await copyFile(
|
||||
join(frameworkRoot, 'tools', 'tmux', 'send-message.sh'),
|
||||
join(activePaths.tmuxToolsDir, 'send-message.sh'),
|
||||
);
|
||||
await copyFile(
|
||||
join(frameworkRoot, 'tools', 'tmux', 'agent-send.sh'),
|
||||
join(activePaths.tmuxToolsDir, 'agent-send.sh'),
|
||||
);
|
||||
await copyFile(
|
||||
join(frameworkRoot, 'systemd', 'user', 'mosaic-tmux-holder.service'),
|
||||
join(activePaths.systemdUserDir, 'mosaic-tmux-holder.service'),
|
||||
);
|
||||
await copyFile(
|
||||
join(frameworkRoot, 'systemd', 'user', 'mosaic-agent@.service'),
|
||||
join(activePaths.systemdUserDir, 'mosaic-agent@.service'),
|
||||
);
|
||||
|
||||
for (const agent of roster.agents) {
|
||||
await writeFile(
|
||||
join(activePaths.agentEnvDir, `${agent.name}.env`),
|
||||
generateAgentEnv(roster, agent),
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Installed fleet files for ${roster.agents.length} agent(s).`);
|
||||
}
|
||||
|
||||
async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
|
||||
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||
return loadFleetRoster(await resolveRosterPath(opts.mosaicHome, opts.roster));
|
||||
}
|
||||
|
||||
async function loadRosterFromAgentCommand(
|
||||
command: Command,
|
||||
mosaicHomeOverride?: string,
|
||||
): Promise<FleetRoster> {
|
||||
const opts = command.optsWithGlobals<{ mosaicHome?: string; roster?: string }>();
|
||||
const mosaicHome = opts.mosaicHome ?? mosaicHomeOverride ?? defaultMosaicHome();
|
||||
return loadFleetRoster(await resolveRosterPath(mosaicHome, opts.roster));
|
||||
}
|
||||
|
||||
function resolveMosaicHomeFromCommand(command: Command, override?: string): string {
|
||||
const opts = command.optsWithGlobals<{ mosaicHome?: string }>();
|
||||
return opts.mosaicHome ?? override ?? defaultMosaicHome();
|
||||
}
|
||||
|
||||
function parseRosterText(text: string, path: string): RawFleetRoster {
|
||||
const trimmed = text.trim();
|
||||
if (path.endsWith('.json')) {
|
||||
return JSON.parse(trimmed) as RawFleetRoster;
|
||||
}
|
||||
return YAML.parse(trimmed) as RawFleetRoster;
|
||||
}
|
||||
|
||||
function normalizeRoster(raw: RawFleetRoster): FleetRoster {
|
||||
assertObject(raw, 'Fleet roster');
|
||||
assertKnownKeys(raw, 'Fleet roster', [
|
||||
'version',
|
||||
'transport',
|
||||
'tmux',
|
||||
'defaults',
|
||||
'runtimes',
|
||||
'agents',
|
||||
]);
|
||||
if (raw.tmux !== undefined) {
|
||||
assertObject(raw.tmux, 'Fleet roster tmux');
|
||||
assertKnownKeys(raw.tmux, 'Fleet roster tmux', [
|
||||
'socket_name',
|
||||
'socketName',
|
||||
'holder_session',
|
||||
'holderSession',
|
||||
]);
|
||||
}
|
||||
if (raw.defaults !== undefined) {
|
||||
assertObject(raw.defaults, 'Fleet roster defaults');
|
||||
assertKnownKeys(raw.defaults, 'Fleet roster defaults', [
|
||||
'working_directory',
|
||||
'workingDirectory',
|
||||
]);
|
||||
}
|
||||
if (raw.runtimes !== undefined) {
|
||||
assertObject(raw.runtimes, 'Fleet roster runtimes');
|
||||
for (const [runtime, config] of Object.entries(raw.runtimes)) {
|
||||
assertObject(config, `Fleet roster runtime "${runtime}"`);
|
||||
assertKnownKeys(config, `Fleet roster runtime "${runtime}"`, [
|
||||
'reset_command',
|
||||
'resetCommand',
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (raw.version !== 1) {
|
||||
throw new Error('Fleet roster version must be 1.');
|
||||
}
|
||||
if (raw.transport !== 'tmux') {
|
||||
throw new Error('Fleet roster transport must be "tmux".');
|
||||
}
|
||||
if (!Array.isArray(raw.agents) || raw.agents.length === 0) {
|
||||
throw new Error('Fleet roster must define at least one agent.');
|
||||
}
|
||||
|
||||
const agents = raw.agents.map(normalizeAgent);
|
||||
assertUniqueAgentNames(agents);
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
transport: 'tmux',
|
||||
tmux: {
|
||||
socketName: stringValue(
|
||||
raw.tmux?.socket_name ?? raw.tmux?.socketName,
|
||||
DEFAULT_SOCKET_NAME,
|
||||
'Fleet roster tmux socket_name',
|
||||
),
|
||||
holderSession: stringValue(
|
||||
raw.tmux?.holder_session ?? raw.tmux?.holderSession,
|
||||
DEFAULT_HOLDER_SESSION,
|
||||
'Fleet roster tmux holder_session',
|
||||
),
|
||||
},
|
||||
defaults: {
|
||||
workingDirectory: stringValue(
|
||||
raw.defaults?.working_directory ?? raw.defaults?.workingDirectory,
|
||||
DEFAULT_WORKING_DIRECTORY,
|
||||
'Fleet roster defaults working_directory',
|
||||
),
|
||||
},
|
||||
runtimes: normalizeRuntimes(raw.runtimes as RawFleetRoster['runtimes']),
|
||||
agents,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): FleetAgent {
|
||||
assertObject(raw, 'Fleet roster agent');
|
||||
assertKnownKeys(raw, 'Fleet roster agent', [
|
||||
'name',
|
||||
'runtime',
|
||||
'class',
|
||||
'working_directory',
|
||||
'workingDirectory',
|
||||
'model_hint',
|
||||
'modelHint',
|
||||
'persistent_persona',
|
||||
'persistentPersona',
|
||||
'reset_between_tasks',
|
||||
'resetBetweenTasks',
|
||||
'kickstart_template',
|
||||
'kickstartTemplate',
|
||||
]);
|
||||
const name = stringValue(raw.name, '', 'Fleet roster agent name');
|
||||
const runtime = stringValue(
|
||||
raw.runtime,
|
||||
'',
|
||||
`Fleet roster agent "${name || '<unknown>'}" runtime`,
|
||||
);
|
||||
if (!name || !/^[A-Za-z0-9_.-]+$/.test(name)) {
|
||||
throw new Error(`Invalid fleet agent name: ${name || '<empty>'}`);
|
||||
}
|
||||
if (!runtime) {
|
||||
throw new Error(`Fleet agent "${name}" must define a runtime.`);
|
||||
}
|
||||
return {
|
||||
name,
|
||||
runtime,
|
||||
className: stringValue(raw.class, 'worker', `Fleet roster agent "${name}" class`),
|
||||
workingDirectory: optionalString(
|
||||
raw.working_directory ?? raw.workingDirectory,
|
||||
`Fleet roster agent "${name}" working_directory`,
|
||||
),
|
||||
modelHint: optionalString(
|
||||
raw.model_hint ?? raw.modelHint,
|
||||
`Fleet roster agent "${name}" model_hint`,
|
||||
),
|
||||
persistentPersona: optionalBooleanOrString(
|
||||
raw.persistent_persona ?? raw.persistentPersona,
|
||||
`Fleet roster agent "${name}" persistent_persona`,
|
||||
),
|
||||
resetBetweenTasks: optionalBoolean(
|
||||
raw.reset_between_tasks ?? raw.resetBetweenTasks,
|
||||
`Fleet roster agent "${name}" reset_between_tasks`,
|
||||
),
|
||||
kickstartTemplate: optionalString(
|
||||
raw.kickstart_template ?? raw.kickstartTemplate,
|
||||
`Fleet roster agent "${name}" kickstart_template`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRuntimes(
|
||||
raw: RawFleetRoster['runtimes'] | undefined,
|
||||
): Record<string, { resetCommand: string }> {
|
||||
const result: Record<string, { resetCommand: string }> = { ...DEFAULT_RUNTIME_RESETS };
|
||||
for (const [runtime, config] of Object.entries(raw ?? {})) {
|
||||
result[runtime] = {
|
||||
resetCommand: stringValue(
|
||||
config.reset_command ?? config.resetCommand,
|
||||
'/clear',
|
||||
`Fleet roster runtime "${runtime}" reset_command`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function assertObject(value: unknown, label: string): asserts value is Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${label} must be an object.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertKnownKeys(
|
||||
value: Record<string, unknown>,
|
||||
label: string,
|
||||
allowedKeys: readonly string[],
|
||||
): void {
|
||||
const allowed = new Set(allowedKeys);
|
||||
const unknownKeys = Object.keys(value).filter((key) => !allowed.has(key));
|
||||
if (unknownKeys.length > 0) {
|
||||
throw new Error(`${label} has unknown field(s): ${unknownKeys.join(', ')}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertUniqueAgentNames(agents: FleetAgent[]): void {
|
||||
const seen = new Set<string>();
|
||||
for (const agent of agents) {
|
||||
if (seen.has(agent.name)) {
|
||||
throw new Error(`Fleet roster has duplicate agent name: ${agent.name}.`);
|
||||
}
|
||||
seen.add(agent.name);
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, fallback = '', label = 'Value'): string {
|
||||
if (value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`${label} must be a string.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, label = 'Value'): string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`${label} must be a string.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalBoolean(value: unknown, label = 'Value'): boolean | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new Error(`${label} must be a boolean.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalBooleanOrString(value: unknown, label = 'Value'): boolean | string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== 'boolean' && typeof value !== 'string') {
|
||||
throw new Error(`${label} must be a boolean or string.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function expandHome(path: string): string {
|
||||
return path === '~' || path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
|
||||
}
|
||||
|
||||
function shellEnvValue(value: string): string {
|
||||
if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
return `'${value.replaceAll("'", "'\"'\"'")}'`;
|
||||
}
|
||||
|
||||
async function stopFleetBestEffort(runner: CommandRunner, agentNames: string[]): Promise<void> {
|
||||
const failures: string[] = [];
|
||||
for (const agentName of agentNames) {
|
||||
const command = buildFleetServiceCommand('stop', agentName);
|
||||
const result = await runner(...splitCommand(command));
|
||||
writeSuccessfulCommandOutput(result);
|
||||
if (result.exitCode !== 0) {
|
||||
failures.push(result.stderr || result.stdout || `Command failed: ${command.join(' ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
const holderCommand = buildFleetServiceCommand('stop');
|
||||
const holderResult = await runner(...splitCommand(holderCommand));
|
||||
writeSuccessfulCommandOutput(holderResult);
|
||||
if (holderResult.exitCode !== 0) {
|
||||
failures.push(
|
||||
holderResult.stderr || holderResult.stdout || `Command failed: ${holderCommand.join(' ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
throw new Error(
|
||||
`Fleet stop completed with ${failures.length} failure(s): ${failures.join('; ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function runChecked(runner: CommandRunner, command: string[]): Promise<void> {
|
||||
const result = await runner(...splitCommand(command));
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(result.stderr || result.stdout || `Command failed: ${command.join(' ')}`);
|
||||
}
|
||||
if (result.stdout) {
|
||||
process.stdout.write(result.stdout);
|
||||
}
|
||||
}
|
||||
|
||||
function splitCommand(command: string[]): [string, string[]] {
|
||||
const [bin, ...args] = command;
|
||||
if (!bin) {
|
||||
throw new Error('Cannot run an empty command.');
|
||||
}
|
||||
return [bin, args];
|
||||
}
|
||||
|
||||
function parseInitProfile(profile: string): 'minimal' | 'local-canary' {
|
||||
if (profile === 'minimal' || profile === 'local-canary') {
|
||||
return profile;
|
||||
}
|
||||
throw new Error(`Unsupported fleet profile "${profile}". Use: minimal, local-canary.`);
|
||||
}
|
||||
|
||||
function writeCommandOutput(result: CommandResult): void {
|
||||
if (result.stdout) {
|
||||
process.stdout.write(result.stdout);
|
||||
} else if (result.stderr) {
|
||||
process.stderr.write(result.stderr);
|
||||
}
|
||||
setExitCodeFromResult(result);
|
||||
}
|
||||
|
||||
function writeSuccessfulCommandOutput(result: CommandResult): void {
|
||||
if (result.exitCode !== 0) {
|
||||
return;
|
||||
}
|
||||
if (result.stdout) {
|
||||
process.stdout.write(result.stdout);
|
||||
}
|
||||
}
|
||||
|
||||
function setExitCodeFromResult(result: CommandResult): void {
|
||||
if (result.exitCode !== 0) {
|
||||
process.exitCode = result.exitCode;
|
||||
}
|
||||
}
|
||||
|
||||
function runCommand(command: string, args: string[]): Promise<CommandResult> {
|
||||
return new Promise((resolvePromise) => {
|
||||
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString('utf8');
|
||||
});
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString('utf8');
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
resolvePromise({ stdout, stderr: error.message, exitCode: 127 });
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
resolvePromise({ stdout, stderr, exitCode: code ?? 1 });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function resolveFrameworkRoot(): string {
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
return resolve(dirname(currentFile), '..', '..', 'framework');
|
||||
}
|
||||
|
||||
async function canRead(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path, constants.R_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveRosterPath(
|
||||
mosaicHome: string,
|
||||
explicitPath?: string,
|
||||
): Promise<string> {
|
||||
if (explicitPath) {
|
||||
return explicitPath;
|
||||
}
|
||||
const yamlPath = resolveFleetPaths(mosaicHome).rosterPath;
|
||||
if (await canRead(yamlPath)) {
|
||||
return yamlPath;
|
||||
}
|
||||
const jsonPath = join(mosaicHome, 'fleet', 'roster.json');
|
||||
return jsonPath;
|
||||
}
|
||||
@@ -6,8 +6,6 @@
|
||||
|
||||
**Architecture:** Mosaic should ship generic tmux fleet primitives in the framework, then layer local rosters through configuration. The holder service owns the tmux socket; each agent service joins the holder-owned server and runs `mosaic yolo <runtime>`. The orchestrator addresses agents through `mosaic agent ...` abstractions so tmux can later be replaced by Matrix-backed agent comms without changing mission flow.
|
||||
|
||||
**Reference:** AI Guide `playbooks/tmux-fleet.md` at commit `2a0b0b5` documents the organization-neutral holder-service pattern, exact-match `=<name>` stop targets, and coupled-server cutover/verification sequence. The Stack implementation should treat that as the lifecycle model and keep concrete Mosaic unit/tooling details here.
|
||||
|
||||
**Tech Stack:** Bash, tmux, user systemd units, Mosaic CLI/framework installer, JSON/YAML roster config, existing `packages/mosaic/framework/tools/tmux/{agent-send.sh,send-message.sh}`.
|
||||
|
||||
---
|
||||
@@ -717,7 +715,7 @@ The implementation is complete when:
|
||||
## Risks and mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
| --------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
|---|---|
|
||||
| Killing existing tmux sessions | Use named `mosaic-factory` socket; no default `tmux kill-server`. |
|
||||
| systemd unit quoting/env expansion bugs | Move logic into shell wrappers; verify with `systemd-analyze --user verify`. |
|
||||
| Runtime reset command mismatch | Make reset command runtime-configurable; fallback to service restart + kickstart. |
|
||||
|
||||
Reference in New Issue
Block a user