Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d258e1d07 | ||
|
|
540d6f17d1 | ||
|
|
a23826ccba | ||
|
|
2c5d208691 | ||
|
|
e291bfb837 | ||
|
|
8199261caa | ||
|
|
57a2f2b40e | ||
|
|
93c1de51e1 |
+12
-14
@@ -115,19 +115,18 @@ gateway-backed agent catalog.
|
||||
|
||||
### Normative requirements
|
||||
|
||||
| ID | Requirement |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `FCM-REQ-01` | The roster SHALL be the sole writable desired-state source for local fleet membership, launch policy, and persisted lifecycle target. Generated environment files, systemd enablement, tmux sessions, and heartbeat state SHALL be non-authoritative projections. |
|
||||
| `FCM-REQ-02` | The implementation SHALL provide one executable structural contract for YAML/JSON input and one shared semantic validator. Roster load, profile validation, provision, migration, and apply SHALL reuse the existing baseline-plus-`roles.local` profile/persona resolver; a parallel role resolver is forbidden. |
|
||||
| `FCM-REQ-03` | The local fleet CLI SHALL expose documented programmatic validate, show, plan, apply/reconcile, create, inspect, update, delete, start, stop, restart, status, verify, and doctor operations with stable JSON and exit-code behavior. Existing `fleet add/remove` compatibility aliases may remain during the stated deprecation window. |
|
||||
| `FCM-REQ-04` | A fresh create SHALL persist `enabled:true` and `desired_state:stopped` unless an explicit persisted start is requested. The model SHALL distinguish enabled state, persisted desired state, and observed state. Migration, apply, reboot, and rollback SHALL not start an agent that was observed stopped before cutover. |
|
||||
| `FCM-REQ-05` | The launch chain SHALL consume deterministic, digest-stamped generated input only. Optional local overrides SHALL be parsed as strict data, may not shadow authoritative generated keys, and may not contain arbitrary commands, credential values, channels, or unknown `MOSAIC_AGENT_*` keys. Forbidden legacy keys, including `MOSAIC_AGENT_COMMAND`, SHALL be privately quarantined before launch and reported only by key name and content hash. |
|
||||
| `FCM-REQ-06` | Mutations and apply SHALL validate before mutation, use an expected generation/lock, write projections atomically, produce a deterministic plan, and emit recovery information on partial failure. Reconciliation SHALL act only on local, enabled, roster-owned projections and SHALL not kill unmanaged tmux sessions by fuzzy name. |
|
||||
| `FCM-REQ-07` | Canonical required classes are `code`, `review`, `validator`, `orchestrator`, `team-leader`, `enhancer`, and `interaction`. `validator` issues an independent final certificate but has no merge authority; `merge-gate` remains sole approve-to-land/merge authority. Team-leader capacity is bounded by an orchestrator-issued lease, and interaction is request/status only. Tess and Ultron are configurable instance/display names, not required machine identities. |
|
||||
| `FCM-REQ-08` | v1 migration SHALL be field-complete, reversible, and explicit about aliases, unresolved classes, lifecycle inference, generated-file regeneration, local override quarantine, schema-only remote/connector fields, and rollback. Every shipped example, profile, and service preset SHALL be migrated and executable, retained as an explicitly versioned v1 fixture, or retired with a replacement and deprecation note. |
|
||||
| `FCM-REQ-09` | M1–M5 SHALL remain local tmux/systemd control-plane work. Remote/SSH reconciliation, connector mutation, secret references, arbitrary command/channel overrides, gateway/API convergence, and UI configuration storage are excluded and require a separate PRD/threat model. |
|
||||
| `FCM-REQ-10` | Documentation and examples are delivery gates. The M0 checklist at [docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md](./fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md) and the baseline disposition inventory at [docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md](./fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md) SHALL be maintained as acceptance evidence. |
|
||||
| `FCM-REQ-11` | Fleet provisioning SHALL validate the fleet CLI and distinct runtimes requested by the roster against the exact PATH construction used by the runtime pane, through one shared implementation rather than the operator PATH or a parallel PATH model. Name resolution alone is insufficient: a resolved script's shebang interpreter SHALL also be reachable, and Node SHALL execute a side-effect-free version probe when it is that interpreter. `fleet install` and `install-systemd` SHALL fail before installation effects when a required executable is absent or unreachable. `fleet doctor` SHALL emit the same named checks as non-green evidence. Every runtime failure SHALL name the runtime, all requesting roster rows, the pane PATH searched, and an exact install command. |
|
||||
| ID | Requirement |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `FCM-REQ-01` | The roster SHALL be the sole writable desired-state source for local fleet membership, launch policy, and persisted lifecycle target. Generated environment files, systemd enablement, tmux sessions, and heartbeat state SHALL be non-authoritative projections. |
|
||||
| `FCM-REQ-02` | The implementation SHALL provide one executable structural contract for YAML/JSON input and one shared semantic validator. Roster load, profile validation, provision, migration, and apply SHALL reuse the existing baseline-plus-`roles.local` profile/persona resolver; a parallel role resolver is forbidden. |
|
||||
| `FCM-REQ-03` | The local fleet CLI SHALL expose documented programmatic validate, show, plan, apply/reconcile, create, inspect, update, delete, start, stop, restart, status, verify, and doctor operations with stable JSON and exit-code behavior. Existing `fleet add/remove` compatibility aliases may remain during the stated deprecation window. |
|
||||
| `FCM-REQ-04` | A fresh create SHALL persist `enabled:true` and `desired_state:stopped` unless an explicit persisted start is requested. The model SHALL distinguish enabled state, persisted desired state, and observed state. Migration, apply, reboot, and rollback SHALL not start an agent that was observed stopped before cutover. |
|
||||
| `FCM-REQ-05` | The launch chain SHALL consume deterministic, digest-stamped generated input only. Optional local overrides SHALL be parsed as strict data, may not shadow authoritative generated keys, and may not contain arbitrary commands, credential values, channels, or unknown `MOSAIC_AGENT_*` keys. Forbidden legacy keys, including `MOSAIC_AGENT_COMMAND`, SHALL be privately quarantined before launch and reported only by key name and content hash. |
|
||||
| `FCM-REQ-06` | Mutations and apply SHALL validate before mutation, use an expected generation/lock, write projections atomically, produce a deterministic plan, and emit recovery information on partial failure. Reconciliation SHALL act only on local, enabled, roster-owned projections and SHALL not kill unmanaged tmux sessions by fuzzy name. |
|
||||
| `FCM-REQ-07` | Canonical required classes are `code`, `review`, `validator`, `orchestrator`, `team-leader`, `enhancer`, and `interaction`. `validator` issues an independent final certificate but has no merge authority; `merge-gate` remains sole approve-to-land/merge authority. Team-leader capacity is bounded by an orchestrator-issued lease, and interaction is request/status only. Tess and Ultron are configurable instance/display names, not required machine identities. |
|
||||
| `FCM-REQ-08` | v1 migration SHALL be field-complete, reversible, and explicit about aliases, unresolved classes, lifecycle inference, generated-file regeneration, local override quarantine, schema-only remote/connector fields, and rollback. Every shipped example, profile, and service preset SHALL be migrated and executable, retained as an explicitly versioned v1 fixture, or retired with a replacement and deprecation note. |
|
||||
| `FCM-REQ-09` | M1–M5 SHALL remain local tmux/systemd control-plane work. Remote/SSH reconciliation, connector mutation, secret references, arbitrary command/channel overrides, gateway/API convergence, and UI configuration storage are excluded and require a separate PRD/threat model. |
|
||||
| `FCM-REQ-10` | Documentation and examples are delivery gates. The M0 checklist at [docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md](./fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md) and the baseline disposition inventory at [docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md](./fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md) SHALL be maintained as acceptance evidence. |
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
@@ -139,7 +138,6 @@ gateway-backed agent catalog.
|
||||
6. `AC-FCM-06`: A v1 roster migration previews field-by-field disposition, preserves observed stopped/running state, inventories rather than reconciles remote/schema-only entries, supports a canary and rollback, and classifies every shipped example, profile, and service preset according to the M0 inventory.
|
||||
7. `AC-FCM-07`: Required role authority is validated: validator certificate is consumed but does not merge, merge-gate is the sole merge authority, team-leader leases do not change roster/credentials/authority, and interaction/Tess cannot claim orchestration or merge powers.
|
||||
8. `AC-FCM-08`: Documentation, examples, migration, troubleshooting, operational recovery, package/update asset drift, schema/example/profile validation, independent code/security review, validator certificate, and terminal-green CI are complete before #758 closes.
|
||||
9. `AC-FCM-09`: Red-first isolated tests create (a) a roster whose runtime exists on the operator PATH but is absent from the constructed pane PATH and (b) a greenfield pane where `mosaic` and a runtime resolve as Node-shebang scripts while Node is absent. They prove `fleet install` fails before effects, the launcher creates no doomed session, and `fleet doctor` reports named non-green checks. Diagnostics include the executable or runtime, all requesting rows, searched pane PATH, shebang dependency when present, and exact runtime install command; repeated rows are checked once per distinct runtime/effective pane path. Tests use temporary `--mosaic-home` state and fixture binaries, never host runtime mutation.
|
||||
|
||||
### M0 implementation gate
|
||||
|
||||
|
||||
@@ -59,28 +59,6 @@ valid allowed local data can move to `.env.local`; invalid legacy input is priva
|
||||
Diagnostics expose only rule code, key name, and a SHA-256 content hash. They do not reveal command
|
||||
text, credentials, or other values.
|
||||
|
||||
## Pane executable preflight
|
||||
|
||||
The fleet install, install-systemd, and doctor commands plus the session launcher use
|
||||
**pane-runtime-path.sh** as the single pane-PATH implementation. Install inspects every distinct
|
||||
roster runtime and effective MOSAIC_RUNTIME_BIN pair before creating holder identity, tool,
|
||||
projection, or unit files. Doctor reports the same checks as JSON.
|
||||
|
||||
A resolved command is not automatically executable. The helper reads a script shebang, unwraps the
|
||||
common “/usr/bin/env node” and “/usr/bin/env -S node …” forms, then resolves the declared command
|
||||
against the pane PATH. When Node is the declared interpreter, the helper runs the side-effect-free
|
||||
“node --version” probe. It does not run “mosaic --version”, whose startup update check can write cache
|
||||
state. Native binaries have no PATH-resolved shebang dependency and retain their normal executable
|
||||
check. Failures name the executable or runtime, requesting roster rows, searched pane PATH,
|
||||
dependency, and runtime install command.
|
||||
|
||||
Supported runtime install commands are:
|
||||
|
||||
- **Claude:** curl -fsSL https://claude.ai/install.sh | bash
|
||||
- **Codex:** npm install -g @openai/codex
|
||||
- **OpenCode:** npm install -g opencode-ai
|
||||
- **Pi:** npm install -g @earendil-works/pi-coding-agent
|
||||
|
||||
## Launch and stop behavior
|
||||
|
||||
The launcher obtains the agent's socket only from the validated generated projection. It creates or
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
# #1256 — Fleet runtime preflight
|
||||
|
||||
**Agent:** tiny
|
||||
|
||||
**Branch:** `fix/1256-fleet-runtime-preflight` from `origin/next@476db12b92971634b67fd2057b7577ee5894e449`
|
||||
|
||||
**Issue:** `mosaicstack/stack#1256` blocker 1
|
||||
|
||||
**Adjacent PR:** `#1258` (`fix/1256-fleet-pane-path-node`) owns the bootstrapped-Node candidate and must remain a separate change
|
||||
|
||||
**Budget:** 30K-token soft cap; one bounded implementation lane
|
||||
|
||||
## Objective
|
||||
|
||||
Make fleet provisioning fail before installation effects when the roster names a runtime binary absent from the exact PATH the tmux pane will receive. Make `mosaic fleet doctor` report the same named runtime check. Diagnostics must name the runtime, every requesting roster row, the pane PATH searched, and an exact install command.
|
||||
|
||||
For Pi the exact command is:
|
||||
|
||||
```text
|
||||
npm install -g @earendil-works/pi-coding-agent
|
||||
```
|
||||
|
||||
## Constraints
|
||||
|
||||
- TDD: add the failing behavior test and capture RED before implementation.
|
||||
- Runtime resolution uses the launcher's pane-PATH construction; a second PATH model is forbidden.
|
||||
- Operator PATH is non-authoritative and must not cause a false pass.
|
||||
- Tests use an isolated `--mosaic-home`/temporary HOME and never mutate host runtime binaries.
|
||||
- No install, removal, or binary-resolution changes on sb-it-1-dt.
|
||||
- PR targets `next` and requires a reviewer other than fred.
|
||||
- Commit identity is `tiny <[email protected]>`.
|
||||
- #1258's Node candidate is a dependency/adjacent change, never reimplemented here.
|
||||
|
||||
## Planned seam
|
||||
|
||||
1. Factor the shell pane-path builder/resolver into one sourceable and executable fleet helper.
|
||||
2. Have `start-agent-session.sh` source that helper, preserving one definition of the pane PATH.
|
||||
3. Have the TypeScript fleet command invoke the same helper under the unit-equivalent clean launcher environment.
|
||||
4. Group roster rows by distinct runtime and effective pane PATH, then report requesting row names.
|
||||
5. Run the preflight before `installFleet` performs any write.
|
||||
6. Add the named result to roster-v2 `fleet doctor` JSON and set a failing exit when a runtime is absent.
|
||||
7. Install/copy the helper alongside `start-agent-session.sh` and update framework manifest/docs as required.
|
||||
|
||||
This seam overlaps #1258 only at the location of the existing shell function. Development may use #1258 as a local dependency, but the final PR diff must exclude #1258's separately owned Node change after that PR lands or after an agreed rebase order.
|
||||
|
||||
## Acceptance evidence
|
||||
|
||||
| Requirement | Evidence |
|
||||
|---|---|
|
||||
| Missing Pi blocks install before effects | isolated CLI test: nonzero + no installed files/runner effects |
|
||||
| Operator PATH cannot create false green | test puts Pi only on operator PATH and omits it from constructed pane PATH |
|
||||
| Exact pane PATH reused | launcher and CLI call one shared shell helper; contract test exercises both |
|
||||
| Actionable diagnosis | runtime + roster rows + searched PATH + exact install command assertions |
|
||||
| Distinct runtimes | repeated rows produce one check with all row names |
|
||||
| Doctor reports named check | JSON assertion + nonzero exit for missing runtime |
|
||||
| Present runtime passes | isolated pane-path fixture with executable binary |
|
||||
| No host mutation | tests use temporary HOME/Mosaic home and fixture binaries only |
|
||||
| Baseline safety | focused tests, package typecheck/lint/format, full relevant suite, CI |
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-08-16: Dispatch received from fred; issue #1256 and PR #1258 measured.
|
||||
- 2026-08-16: Fresh clone created under `~/agent-work/tiny-fleet-runtime-preflight`; local Git identity pinned to tiny so retired global `mos-dt-0` identity cannot win.
|
||||
- 2026-08-16: Design inspection found the pane PATH exists only inside `start-agent-session.sh`; the right seam is a shared shell helper rather than a parallel TypeScript reconstruction.
|
||||
- 2026-08-16: RED measured on `origin/next@476db12b`: focused `fleet-roster-v2-dispatch.spec.ts` ran 11 tests; the new case failed because install returned success, wrote units for two agents, and emitted no `runtime=pi` diagnosis while Pi existed only on operator PATH.
|
||||
- 2026-08-16: Factored pane home/PATH/resolution into sourceable and executable `pane-runtime-path.sh`; install invokes it before the first effect, doctor emits the same named checks, and the launcher sources it.
|
||||
- 2026-08-16: Fred/rhodey review exposed the #1241 name-resolution blind spot: `mosaic` can resolve while its `#!/usr/bin/env node` interpreter cannot. Measurement confirmed every supported current Mosaic package shape is a Node-shebang script, but executing `mosaic --version` is not observational because CLI startup runs the cache-writing/network update checker before Commander handles the flag.
|
||||
- 2026-08-16: Final executable check reads and unwraps direct and `/usr/bin/env` shebangs (including `env -S`), resolves the declared dependency against pane PATH, and runs only side-effect-free `node --version` when Node is declared. Native binaries do not inherit a permanent Node requirement. Install, doctor, and launcher share this implementation.
|
||||
- 2026-08-16: Isolated greenfield fixture places resolved Mosaic and Pi Node-shebang scripts in pane-visible npm-global bin while using an empty system suffix; both checks become `unexecutable` with `dependency=node`, and install leaves holder/tools/units absent. No host binary or HOME is changed.
|
||||
- 2026-08-16: GREEN evidence before #1258 rebase: focused install/doctor/preflight suites pass; `fleet.spec.ts` 209/209; full Vitest 87 files / 1,557 tests; launcher shell suite, typecheck, lint, build, and focused format check pass. Full framework-shell reaches an unrelated host-measurement drift in unchanged `invariant_r_unittest.py` (expected Pi 0.84.1, host resolves 0.84.2); no invariant was changed in this lane.
|
||||
- 2026-08-16: Merge-order gate remains: `origin/next` is still `476db12b`; #1258 is unmerged at `6dc35e5`. Rebase after it lands, relocate its Node candidate into the helper with explicit provenance, rerun gates, then open the PR to `next` for an independent non-fred review.
|
||||
@@ -51,12 +51,8 @@ See `docs/fleet/reference/generated-env-boundary.md` for the full contract.
|
||||
## Manual canary sequence
|
||||
|
||||
Use the roster and the supported installer; do not pre-create the agent environment directory or
|
||||
edit a generated projection. Before it writes any holder identity, tool, projection, or unit file,
|
||||
`mosaic fleet install` validates the fleet CLI and every distinct roster runtime through the exact
|
||||
pane PATH. The shared helper also unwraps `/usr/bin/env` shebangs, so a resolved Node script with no
|
||||
pane-visible Node fails before effects. `mosaic fleet doctor` reports the same named executable
|
||||
checks without mutation. After that preflight, install places the units and helpers and writes private
|
||||
roster-derived projections before any service starts.
|
||||
edit a generated projection. `mosaic fleet install` validates the roster, installs the units and
|
||||
helpers, and writes private roster-derived projections before any service is started.
|
||||
|
||||
```bash
|
||||
# Create a site-owned canary roster. Inspect an existing roster before using --force.
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Canonical fleet-pane PATH construction and executable reachability checks.
|
||||
#
|
||||
# This file is both sourceable by start-agent-session.sh and executable by the
|
||||
# fleet CLI preflight. Keep the pane PATH in one implementation: provisioning
|
||||
# checks and the eventual pane must answer the same question.
|
||||
|
||||
mosaic_fleet_pane_home() {
|
||||
local mosaic_home="$1"
|
||||
local fallback_home="$2"
|
||||
case "$mosaic_home" in
|
||||
*/.config/mosaic) printf '%s' "${mosaic_home%/.config/mosaic}" ;;
|
||||
*) printf '%s' "$fallback_home" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
mosaic_fleet_build_runtime_bin_prefix() {
|
||||
local pane_home="$1"
|
||||
local runtime_bin="${2:-}"
|
||||
local candidates=()
|
||||
if [ -n "$runtime_bin" ]; then candidates+=("$runtime_bin"); fi
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
local npm_prefix
|
||||
npm_prefix=$(npm config get prefix 2>/dev/null) || true
|
||||
if [ -n "$npm_prefix" ]; then candidates+=("${npm_prefix}/bin"); fi
|
||||
fi
|
||||
candidates+=("$pane_home/.npm-global/bin" "$pane_home/.local/bin")
|
||||
|
||||
local prefix="" dir
|
||||
for dir in "${candidates[@]}"; do
|
||||
[ -d "$dir" ] || continue
|
||||
case ":${prefix}:" in *":${dir}:"*) ;; *) prefix="${prefix:+$prefix:}$dir" ;; esac
|
||||
done
|
||||
printf '%s' "$prefix"
|
||||
}
|
||||
|
||||
mosaic_fleet_build_pane_path() {
|
||||
local pane_home="$1"
|
||||
local runtime_bin="${2:-}"
|
||||
local system_path="${3:-/usr/local/bin:/usr/bin:/bin}"
|
||||
local prefix
|
||||
prefix=$(mosaic_fleet_build_runtime_bin_prefix "$pane_home" "$runtime_bin")
|
||||
printf '%s' "${prefix:+${prefix}:}${system_path}"
|
||||
}
|
||||
|
||||
mosaic_fleet_resolve_in_pane_path() {
|
||||
local pane_path="$1"
|
||||
local binary="$2"
|
||||
PATH="$pane_path" command -v -- "$binary" 2>/dev/null
|
||||
}
|
||||
|
||||
# Sets executable evidence in MOSAIC_FLEET_EXECUTABLE_* and returns nonzero when
|
||||
# a resolved script's shebang interpreter cannot run in the pane. Native/ELF
|
||||
# binaries have no PATH-resolved interpreter dependency and pass the executable
|
||||
# bit check. Node receives an additional side-effect-free `node --version`
|
||||
# execution check; invoking `mosaic --version` itself is intentionally avoided
|
||||
# because Mosaic performs a cache-writing/network update check at CLI startup.
|
||||
mosaic_fleet_check_resolved_executable() {
|
||||
local pane_path="$1"
|
||||
local resolved="$2"
|
||||
MOSAIC_FLEET_EXECUTABLE_DEPENDENCY=""
|
||||
MOSAIC_FLEET_EXECUTABLE_PROBE=""
|
||||
MOSAIC_FLEET_EXECUTABLE_EXIT=""
|
||||
MOSAIC_FLEET_EXECUTABLE_OUTPUT=""
|
||||
|
||||
[ -x "$resolved" ] || {
|
||||
MOSAIC_FLEET_EXECUTABLE_OUTPUT="resolved path is not executable"
|
||||
return 70
|
||||
}
|
||||
|
||||
local magic=""
|
||||
IFS= read -r -n 2 magic < "$resolved" || true
|
||||
[ "$magic" = '#!' ] || return 0
|
||||
|
||||
local shebang
|
||||
IFS= read -r shebang < "$resolved" || true
|
||||
shebang=${shebang%$'\r'}
|
||||
shebang=${shebang#\#!}
|
||||
local parts=()
|
||||
read -r -a parts <<< "$shebang"
|
||||
local interpreter="${parts[0]:-}"
|
||||
[[ "$interpreter" = /* ]] && [ -x "$interpreter" ] || {
|
||||
MOSAIC_FLEET_EXECUTABLE_DEPENDENCY="$interpreter"
|
||||
MOSAIC_FLEET_EXECUTABLE_OUTPUT="shebang interpreter is absent or not executable"
|
||||
return 70
|
||||
}
|
||||
|
||||
local dependency="$interpreter"
|
||||
local dependency_path="$interpreter"
|
||||
if [ "${interpreter##*/}" = env ]; then
|
||||
local index=1
|
||||
if [ "${parts[$index]:-}" = -S ]; then index=$((index + 1)); fi
|
||||
dependency="${parts[$index]:-}"
|
||||
if [ -z "$dependency" ] || [[ "$dependency" = -* ]]; then
|
||||
MOSAIC_FLEET_EXECUTABLE_DEPENDENCY="$dependency"
|
||||
MOSAIC_FLEET_EXECUTABLE_OUTPUT="unsupported env shebang"
|
||||
return 70
|
||||
fi
|
||||
fi
|
||||
|
||||
MOSAIC_FLEET_EXECUTABLE_DEPENDENCY="$dependency"
|
||||
if [ "${dependency##*/}" = node ]; then
|
||||
MOSAIC_FLEET_EXECUTABLE_PROBE="node --version"
|
||||
fi
|
||||
if [ "${interpreter##*/}" = env ]; then
|
||||
if ! dependency_path=$(mosaic_fleet_resolve_in_pane_path "$pane_path" "$dependency"); then
|
||||
MOSAIC_FLEET_EXECUTABLE_OUTPUT="shebang command is not on the pane PATH"
|
||||
return 70
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${dependency##*/}" = node ]; then
|
||||
if MOSAIC_FLEET_EXECUTABLE_OUTPUT=$(PATH="$pane_path" "$dependency_path" --version 2>&1); then
|
||||
MOSAIC_FLEET_EXECUTABLE_EXIT=0
|
||||
else
|
||||
MOSAIC_FLEET_EXECUTABLE_EXIT=$?
|
||||
return 70
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
mosaic_fleet_runtime_path_main() {
|
||||
local mosaic_home=""
|
||||
local runtime_bin=""
|
||||
local system_path="/usr/local/bin:/usr/bin:/bin"
|
||||
local binary=""
|
||||
local check_executable=0
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--mosaic-home)
|
||||
[ "$#" -ge 2 ] || return 64
|
||||
mosaic_home="$2"
|
||||
shift 2
|
||||
;;
|
||||
--runtime-bin)
|
||||
[ "$#" -ge 2 ] || return 64
|
||||
runtime_bin="$2"
|
||||
shift 2
|
||||
;;
|
||||
--binary)
|
||||
[ "$#" -ge 2 ] || return 64
|
||||
binary="$2"
|
||||
shift 2
|
||||
;;
|
||||
--check-executable)
|
||||
check_executable=1
|
||||
shift
|
||||
;;
|
||||
# Test seam for measuring a greenfield host with no system Node. The
|
||||
# launcher and production CLI omit it and retain the fixed system suffix.
|
||||
--system-path)
|
||||
[ "$#" -ge 2 ] || return 64
|
||||
system_path="$2"
|
||||
shift 2
|
||||
;;
|
||||
*) return 64 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$mosaic_home" ] && [ -n "$binary" ] || return 64
|
||||
local pane_home pane_path resolved
|
||||
pane_home=$(mosaic_fleet_pane_home "$mosaic_home" "${HOME:-}")
|
||||
# npm config is HOME-sensitive. Pin it to the derived pane home before asking
|
||||
# for its prefix so an operator's unrelated npmrc cannot influence preflight.
|
||||
HOME=$pane_home
|
||||
export HOME
|
||||
pane_path=$(mosaic_fleet_build_pane_path "$pane_home" "$runtime_bin" "$system_path")
|
||||
if ! resolved=$(mosaic_fleet_resolve_in_pane_path "$pane_path" "$binary"); then
|
||||
printf 'pane_path\0%s\0status\0missing\0binary_path\0\0dependency\0\0probe_command\0\0probe_exit\0\0probe_output\0\0' \
|
||||
"$pane_path"
|
||||
return 69
|
||||
fi
|
||||
|
||||
if [ "$check_executable" -eq 1 ]; then
|
||||
if mosaic_fleet_check_resolved_executable "$pane_path" "$resolved"; then
|
||||
printf 'pane_path\0%s\0status\0present\0binary_path\0%s\0dependency\0%s\0probe_command\0%s\0probe_exit\0%s\0probe_output\0%s\0' \
|
||||
"$pane_path" "$resolved" "$MOSAIC_FLEET_EXECUTABLE_DEPENDENCY" \
|
||||
"$MOSAIC_FLEET_EXECUTABLE_PROBE" "$MOSAIC_FLEET_EXECUTABLE_EXIT" \
|
||||
"$MOSAIC_FLEET_EXECUTABLE_OUTPUT"
|
||||
return 0
|
||||
fi
|
||||
printf 'pane_path\0%s\0status\0unexecutable\0binary_path\0%s\0dependency\0%s\0probe_command\0%s\0probe_exit\0%s\0probe_output\0%s\0' \
|
||||
"$pane_path" "$resolved" "$MOSAIC_FLEET_EXECUTABLE_DEPENDENCY" \
|
||||
"$MOSAIC_FLEET_EXECUTABLE_PROBE" "$MOSAIC_FLEET_EXECUTABLE_EXIT" \
|
||||
"$MOSAIC_FLEET_EXECUTABLE_OUTPUT"
|
||||
return 70
|
||||
fi
|
||||
|
||||
printf 'pane_path\0%s\0status\0present\0binary_path\0%s\0dependency\0\0probe_command\0\0probe_exit\0\0probe_output\0\0' \
|
||||
"$pane_path" "$resolved"
|
||||
return 0
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
set -euo pipefail
|
||||
mosaic_fleet_runtime_path_main "$@"
|
||||
fi
|
||||
@@ -258,22 +258,46 @@ if _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
|
||||
fi
|
||||
|
||||
# Systemd passes HOME as %h, and the installed service fixes MOSAIC_HOME under
|
||||
# that home. The provisioning preflight executes this same helper under the
|
||||
# unit's clean launcher environment, so operator PATH cannot produce a false
|
||||
# green result for a binary the pane will never see.
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
# shellcheck source=pane-runtime-path.sh
|
||||
. "$SCRIPT_DIR/pane-runtime-path.sh"
|
||||
PANE_HOME=$(mosaic_fleet_pane_home "$MOSAIC_HOME" "$HOME")
|
||||
PANE_PATH=$(mosaic_fleet_build_pane_path "$PANE_HOME" "$MOSAIC_RUNTIME_BIN")
|
||||
# that home. Derive the pane home from the canonical path when available so an
|
||||
# inherited pane/session HOME cannot become runtime authority.
|
||||
PANE_HOME=$HOME
|
||||
case "$MOSAIC_HOME" in
|
||||
*/.config/mosaic) PANE_HOME=${MOSAIC_HOME%/.config/mosaic} ;;
|
||||
esac
|
||||
|
||||
# #1241/#1256. The pane runs `mosaic yolo <runtime>` under PANE_PATH with a
|
||||
# cleared environment. Resolve both names and validate any shebang interpreter
|
||||
# here, before an effect, where the failure remains attributable. Name
|
||||
# resolution alone is insufficient: an `#!/usr/bin/env node` script resolves
|
||||
# even when the pane cannot execute it because Node is absent.
|
||||
_build_runtime_bin_prefix() {
|
||||
local candidates=()
|
||||
if [ -n "$MOSAIC_RUNTIME_BIN" ]; then candidates+=("$MOSAIC_RUNTIME_BIN"); fi
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
local npm_prefix
|
||||
npm_prefix=$(npm config get prefix 2>/dev/null) || true
|
||||
if [ -n "$npm_prefix" ]; then candidates+=("${npm_prefix}/bin"); fi
|
||||
fi
|
||||
candidates+=("$PANE_HOME/.npm-global/bin" "$PANE_HOME/.local/bin")
|
||||
|
||||
local prefix="" dir
|
||||
for dir in "${candidates[@]}"; do
|
||||
[ -d "$dir" ] || continue
|
||||
case ":${prefix}:" in *":${dir}:"*) ;; *) prefix="${prefix:+$prefix:}$dir" ;; esac
|
||||
done
|
||||
printf '%s' "$prefix"
|
||||
}
|
||||
|
||||
MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix)
|
||||
PANE_PATH=${MOSAIC_RUNTIME_BIN_PREFIX:+${MOSAIC_RUNTIME_BIN_PREFIX}:}/usr/local/bin:/usr/bin:/bin
|
||||
|
||||
# #1241. The pane runs `mosaic yolo <runtime>` under PANE_PATH with a cleared
|
||||
# environment. A binary missing from *that* path is a pane that dies in under a
|
||||
# second, inside a session nobody is attached to, with its diagnostic scrolled
|
||||
# into a pane tmux then destroys. Resolve both here, before any effect, where
|
||||
# the failure is still attributable to the thing that caused it.
|
||||
#
|
||||
# `mosaic yolo <runtime>` runs checkRuntime(runtime) and the binary it looks for
|
||||
# is named exactly like the runtime, so resolving the runtime name is the same
|
||||
# question the pane will ask a moment later — asked while an operator can still
|
||||
# see the answer.
|
||||
_resolve_in_pane_path() {
|
||||
mosaic_fleet_resolve_in_pane_path "$PANE_PATH" "$1"
|
||||
PATH="$PANE_PATH" command -v -- "$1" 2>/dev/null
|
||||
}
|
||||
|
||||
# Exit 69 (EX_UNAVAILABLE): the seat cannot be provided. Distinguished from the
|
||||
@@ -288,15 +312,8 @@ fail_launch() {
|
||||
}
|
||||
|
||||
for required_binary in mosaic "$MOSAIC_AGENT_RUNTIME"; do
|
||||
resolved_binary=$(_resolve_in_pane_path "$required_binary") ||
|
||||
_resolve_in_pane_path "$required_binary" >/dev/null ||
|
||||
fail_launch missing-binary "'${required_binary}' is not on the pane PATH (${PANE_PATH})"
|
||||
if mosaic_fleet_check_resolved_executable "$PANE_PATH" "$resolved_binary"; then
|
||||
continue
|
||||
else
|
||||
executable_exit=$?
|
||||
fi
|
||||
fail_launch unexecutable-binary \
|
||||
"'${required_binary}' resolves to '${resolved_binary}' but dependency '${MOSAIC_FLEET_EXECUTABLE_DEPENDENCY:-unknown}' is not executable on the pane PATH (${PANE_PATH}); check_exit=${executable_exit} detail=${MOSAIC_FLEET_EXECUTABLE_OUTPUT:-unavailable}"
|
||||
done
|
||||
|
||||
_ensure_claude_workdir_trusted() {
|
||||
|
||||
@@ -484,27 +484,6 @@ assert_missing_pane_binary_rejected() {
|
||||
assert_missing_pane_binary_rejected mosaic
|
||||
assert_missing_pane_binary_rejected pi
|
||||
|
||||
# #1256. Name resolution is not executable reachability. A script can resolve
|
||||
# while its /usr/bin/env shebang command is absent from PANE_PATH; reject that
|
||||
# before tmux creates the doomed session.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_UNEXECUTABLE="$ROOT/unexecutable-shebang"
|
||||
write_generated "$HOME_UNEXECUTABLE" "coder-unexecutable"
|
||||
rm -f "$HOME_UNEXECUTABLE/.npm-global/bin/mosaic"
|
||||
printf '#!/usr/bin/env mosaic-test-absent-interpreter\n' > \
|
||||
"$HOME_UNEXECUTABLE/.npm-global/bin/mosaic"
|
||||
chmod +x "$HOME_UNEXECUTABLE/.npm-global/bin/mosaic"
|
||||
if output=$(MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_UNEXECUTABLE" coder-unexecutable 2>&1); then
|
||||
fail "launcher accepted a resolved mosaic script with an absent shebang command"
|
||||
fi
|
||||
echo "$output" | grep -qF 'code=unexecutable-binary' || \
|
||||
fail "unexecutable shebang diagnostic missing: $output"
|
||||
echo "$output" | grep -qF 'mosaic-test-absent-interpreter' || \
|
||||
fail "unexecutable shebang diagnostic did not name the missing dependency"
|
||||
if tr '\0' '\n' < "$TMUX_CALLS" | grep -qF new-session; then
|
||||
fail "launcher created a session after its shebang dependency check failed"
|
||||
fi
|
||||
|
||||
# #1241. tmux destroys a session when its pane command exits, so no pane PID a
|
||||
# second after new-session means the runtime died on startup. This used to be a
|
||||
# WARNING about the heartbeat sidecar followed by exit 0 — three layers above it
|
||||
|
||||
@@ -39,3 +39,20 @@ packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires rea
|
||||
# recorded judgement. These lines ARE that judgement, signed.)
|
||||
packages/mosaic/framework/tools/orchestrator/smoke-test.sh | behavior smoke checks for coord continue/run workflows, run manually by orchestrator seats; unmeasured in CI; #1017 burndown
|
||||
packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh | #973 instrument self-test, run as a precondition of the validate-973 evidence procedure rather than as a standing CI suite; #1017 burndown candidate
|
||||
|
||||
# --- tools/fleet: precondition is unsatisfiable in the CI image (#1271) ---
|
||||
# Signed by fred (sb-it-1-dt, 2026-08-16) at origin/next 476db12.
|
||||
# This suite asserts the launcher's behaviour when `mosaic` and `pi` are MISSING.
|
||||
# It shims fakes into $FAKE_BIN, but the constructed PANE_PATH always ends in the
|
||||
# real system path, so on a host that installs those binaries the missing-binary
|
||||
# cases cannot be measured at all. The suite's own guard (line 103) says so and
|
||||
# fails rather than reporting a pass it cannot back. That guard is correct.
|
||||
# The error was wiring the suite into CI: #1017 (c56483eb) enumerated it and
|
||||
# dropped this exclusion, and the CI image provides `pi` in the system path, so
|
||||
# it has failed on every pipeline since. Measured 2026-08-16 across pipelines
|
||||
# 2444 (#1256), 2438 (#1240) and 2441 (#1017-quality): exactly one FAIL line in
|
||||
# each full log, identical, this assertion; control `zzz-not-present-zzz` -> 0.
|
||||
# Burn-down and the full measurement are tracked in #1271; unwired by PR #1270.
|
||||
# Because test:framework-shell is one && chain and this sat at position 44 of 48,
|
||||
# the four suites after it had not run at all since the merge.
|
||||
packages/mosaic/framework/tools/fleet/test-start-agent-session.sh | precondition unsatisfiable in the CI image: asserts missing-binary behaviour, but PANE_PATH always ends in the system path and the image provides `pi` there; guard at line 103 fails by design rather than passing unmeasured. Burn down by controlling the tail of PANE_PATH inside the test. NOT by removing `pi` from the image: the CI image installs @earendil-works/[email protected] deliberately (measured in pipeline 2444's test-step log), and other suites depend on that pin. Burn-down tracked in #1271
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/fleet/test-start-agent-session.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh"
|
||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { join } from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { type FleetReconcileDeps } from '../fleet/fleet-reconciler.js';
|
||||
import {
|
||||
type FleetRuntimeProbeResult,
|
||||
type FleetRuntimeProbeRunner,
|
||||
} from '../fleet/fleet-runtime-preflight.js';
|
||||
import { registerFleetCommand, type CommandResult, type FleetCommandDeps } from './fleet.js';
|
||||
|
||||
const roster = `
|
||||
@@ -69,15 +65,12 @@ function program(
|
||||
mosaicHome: string,
|
||||
runner: FleetCommandDeps['runner'],
|
||||
reconcileOverrides: Partial<FleetReconcileDeps> = {},
|
||||
runtimeProbeRunner: FleetRuntimeProbeRunner = runtimeProbe('present'),
|
||||
): Command {
|
||||
const result = new Command();
|
||||
result.exitOverride();
|
||||
registerFleetCommand(result, {
|
||||
mosaicHome,
|
||||
runner,
|
||||
frameworkRoot: resolve(process.cwd(), 'framework'),
|
||||
runtimeProbeRunner,
|
||||
reconcileDeps: {
|
||||
homeDirectory: '/home/mosaic',
|
||||
readHolderIdentity: async () => '11111111-1111-4111-8111-111111111111',
|
||||
@@ -90,24 +83,6 @@ function program(
|
||||
return result;
|
||||
}
|
||||
|
||||
function runtimeProbe(status: 'present' | 'missing'): FleetRuntimeProbeRunner {
|
||||
return async (_command, args): Promise<FleetRuntimeProbeResult> => {
|
||||
const binaryFlag = args.indexOf('--binary');
|
||||
const binary = binaryFlag >= 0 ? args[binaryFlag + 1] : undefined;
|
||||
const effectiveStatus = binary === 'mosaic' ? 'present' : status;
|
||||
return {
|
||||
stdout:
|
||||
`pane_path\u0000/fixture/runtime-bin:/usr/bin:/bin\u0000status\u0000${effectiveStatus}\u0000` +
|
||||
`binary_path\u0000${effectiveStatus === 'present' ? `/fixture/runtime-bin/${binary ?? 'unknown'}` : ''}\u0000` +
|
||||
`dependency\u0000${effectiveStatus === 'present' ? 'node' : ''}\u0000` +
|
||||
`probe_command\u0000${effectiveStatus === 'present' ? 'node --version' : ''}\u0000` +
|
||||
`probe_exit\u0000${effectiveStatus === 'present' ? '0' : ''}\u0000probe_output\u0000\u0000`,
|
||||
stderr: '',
|
||||
exitCode: effectiveStatus === 'present' ? 0 : 69,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function capture(): string[] {
|
||||
const lines: string[] = [];
|
||||
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
|
||||
@@ -177,64 +152,13 @@ describe('mosaic fleet reconciler commands', (): void => {
|
||||
|
||||
expect(lines.map((line: string): unknown => JSON.parse(line))).toMatchObject([
|
||||
{ applied: false, lifecycle: 'not-applied' },
|
||||
{
|
||||
applied: false,
|
||||
lifecycle: 'not-applied',
|
||||
checks: {
|
||||
fleetCliExecutable: [
|
||||
{
|
||||
check: 'fleet-cli-executable',
|
||||
status: 'ok',
|
||||
requestedBy: ['coder0'],
|
||||
dependency: 'node',
|
||||
probeCommand: 'node --version',
|
||||
},
|
||||
],
|
||||
fleetRuntimeAvailability: [
|
||||
{
|
||||
check: 'fleet-runtime-available',
|
||||
runtime: 'pi',
|
||||
status: 'ok',
|
||||
requestedBy: ['coder0'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ applied: false, lifecycle: 'not-applied' },
|
||||
]);
|
||||
expect(
|
||||
calls.every((call: string[]): boolean => call[0] !== 'systemctl' || call[2] === 'show'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reports a missing roster runtime as a named non-green doctor check', async (): Promise<void> => {
|
||||
const home = await fleetHome();
|
||||
const lines = capture();
|
||||
|
||||
await program(home, ownedRunner([]), {}, runtimeProbe('missing')).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'doctor',
|
||||
]);
|
||||
|
||||
expect(JSON.parse(lines.pop() ?? '')).toMatchObject({
|
||||
applied: false,
|
||||
checks: {
|
||||
fleetRuntimeAvailability: [
|
||||
{
|
||||
check: 'fleet-runtime-available',
|
||||
runtime: 'pi',
|
||||
status: 'missing',
|
||||
requestedBy: ['coder0'],
|
||||
panePath: '/fixture/runtime-bin:/usr/bin:/bin',
|
||||
installCommand: 'npm install -g @earendil-works/pi-coding-agent',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it.each(['start', 'stop', 'restart'] as const)(
|
||||
'uses exact roster-owned systemd targeting for %s',
|
||||
async (operation: 'start' | 'stop' | 'restart'): Promise<void> => {
|
||||
|
||||
@@ -8,18 +8,10 @@ import {
|
||||
type FleetReconcileCommand,
|
||||
type FleetReconcileDeps,
|
||||
} from '../fleet/fleet-reconciler.js';
|
||||
import {
|
||||
inspectFleetRuntimeAvailability,
|
||||
type FleetRuntimeInspection,
|
||||
type FleetRuntimePreflightCheck,
|
||||
type FleetRuntimeProbeRunner,
|
||||
} from '../fleet/fleet-runtime-preflight.js';
|
||||
import { parseRosterV2 } from '../fleet/roster-v2.js';
|
||||
|
||||
export interface FleetReconcilerCommandDeps {
|
||||
readonly runner: CommandRunner;
|
||||
readonly runtimeProbeRunner?: FleetRuntimeProbeRunner;
|
||||
readonly frameworkRoot?: string;
|
||||
readonly mosaicHome?: string;
|
||||
readonly reconcileDeps?: Omit<FleetReconcileDeps, 'runner' | 'mosaicHome'>;
|
||||
}
|
||||
@@ -79,10 +71,6 @@ export async function executeReconcilerCommand(
|
||||
const mosaicHome = resolveMosaicHome(fleetCommand, deps);
|
||||
const rosterPath = resolveRosterPath(fleetCommand, mosaicHome);
|
||||
const roster = parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml');
|
||||
const runtimeInspection =
|
||||
operation === 'doctor'
|
||||
? await inspectRuntimeAvailability(roster.agents, mosaicHome, deps)
|
||||
: undefined;
|
||||
const mutating = operation === 'apply' || operation === 'reconcile' || isLifecycle(operation);
|
||||
const expectedGeneration = mutating
|
||||
? parseExpectedGeneration(opts.expectedGeneration)
|
||||
@@ -102,31 +90,8 @@ export async function executeReconcilerCommand(
|
||||
...(deps.reconcileDeps ?? {}),
|
||||
},
|
||||
});
|
||||
printJson(operation === 'doctor' ? { ...result, checks: runtimeInspection } : result);
|
||||
const executableFailure =
|
||||
runtimeInspection !== undefined &&
|
||||
[...runtimeInspection.fleetCliExecutable, ...runtimeInspection.fleetRuntimeAvailability].some(
|
||||
(check: FleetRuntimePreflightCheck): boolean => check.status !== 'ok',
|
||||
);
|
||||
process.exitCode =
|
||||
result.recovery === undefined && result.cleanup === undefined && !executableFailure ? 0 : 1;
|
||||
}
|
||||
|
||||
async function inspectRuntimeAvailability(
|
||||
agents: readonly { readonly name: string; readonly runtime: string }[],
|
||||
mosaicHome: string,
|
||||
deps: FleetReconcilerCommandDeps,
|
||||
): Promise<FleetRuntimeInspection> {
|
||||
if (deps.frameworkRoot === undefined || deps.runtimeProbeRunner === undefined) {
|
||||
throw new Error('Fleet doctor runtime preflight dependencies are unavailable.');
|
||||
}
|
||||
return inspectFleetRuntimeAvailability({
|
||||
mosaicHome,
|
||||
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
|
||||
helperPath: join(deps.frameworkRoot, 'tools', 'fleet', 'pane-runtime-path.sh'),
|
||||
agents,
|
||||
runner: deps.runtimeProbeRunner,
|
||||
});
|
||||
printJson(result);
|
||||
process.exitCode = result.recovery === undefined && result.cleanup === undefined ? 0 : 1;
|
||||
}
|
||||
|
||||
function isLifecycle(operation: FleetReconcileCommand): boolean {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { FleetRuntimeProbeRunner } from '../fleet/fleet-runtime-preflight.js';
|
||||
import { registerFleetCommand, type CommandResult, type CommandRunner } from './fleet.js';
|
||||
|
||||
/**
|
||||
@@ -70,7 +69,6 @@ agents:
|
||||
let tempHome: string | undefined;
|
||||
const savedHome = process.env.HOME;
|
||||
const savedMosaicHome = process.env.MOSAIC_HOME;
|
||||
const savedPath = process.env.PATH;
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -79,8 +77,6 @@ afterEach(async (): Promise<void> => {
|
||||
else process.env.HOME = savedHome;
|
||||
if (savedMosaicHome === undefined) delete process.env.MOSAIC_HOME;
|
||||
else process.env.MOSAIC_HOME = savedMosaicHome;
|
||||
if (savedPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = savedPath;
|
||||
if (tempHome) await rm(tempHome, { recursive: true, force: true });
|
||||
tempHome = undefined;
|
||||
});
|
||||
@@ -89,7 +85,7 @@ afterEach(async (): Promise<void> => {
|
||||
* A HOME with a roster-v2 fleet and nothing else — the greenfield shape, before
|
||||
* anything has been installed, applied or started.
|
||||
*/
|
||||
async function v2Home(options: { withPaneRuntime?: boolean } = {}): Promise<string> {
|
||||
async function v2Home(): Promise<string> {
|
||||
tempHome = await mkdtemp(join(tmpdir(), 'mosaic-fleet-v2-dispatch-'));
|
||||
process.env.HOME = tempHome;
|
||||
delete process.env.MOSAIC_HOME;
|
||||
@@ -101,12 +97,6 @@ async function v2Home(options: { withPaneRuntime?: boolean } = {}): Promise<stri
|
||||
await writeFile(join(mosaicHome, 'fleet', 'roles', 'code.md'), '`class: code`\n\n# code\n', {
|
||||
mode: 0o600,
|
||||
});
|
||||
const runtimeDir = join(tempHome, '.npm-global', 'bin');
|
||||
await mkdir(runtimeDir, { recursive: true });
|
||||
await writeFile(join(runtimeDir, 'mosaic'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
if (options.withPaneRuntime !== false) {
|
||||
await writeFile(join(runtimeDir, 'pi'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
}
|
||||
return mosaicHome;
|
||||
}
|
||||
|
||||
@@ -123,17 +113,10 @@ const greenfieldRunner: CommandRunner = async (command): Promise<CommandResult>
|
||||
return { stdout: '', stderr: '', exitCode: 1 };
|
||||
};
|
||||
|
||||
function program(
|
||||
runner: CommandRunner = greenfieldRunner,
|
||||
runtimeProbeRunner?: FleetRuntimeProbeRunner,
|
||||
): Command {
|
||||
function program(runner: CommandRunner = greenfieldRunner): Command {
|
||||
const result = new Command();
|
||||
result.exitOverride();
|
||||
registerFleetCommand(result, {
|
||||
runner,
|
||||
frameworkRoot: resolve(process.cwd(), 'framework'),
|
||||
...(runtimeProbeRunner === undefined ? {} : { runtimeProbeRunner }),
|
||||
});
|
||||
registerFleetCommand(result, { runner, frameworkRoot: resolve(process.cwd(), 'framework') });
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -183,93 +166,6 @@ describe('mosaic fleet ps — roster v2', (): void => {
|
||||
});
|
||||
|
||||
describe('mosaic fleet install — roster v2', (): void => {
|
||||
it('rejects a roster runtime missing from the pane PATH before installing any files', async (): Promise<void> => {
|
||||
const mosaicHome = await v2Home({ withPaneRuntime: false });
|
||||
const operatorBin = join(tempHome!, 'operator-bin');
|
||||
await mkdir(operatorBin, { recursive: true });
|
||||
await writeFile(join(operatorBin, 'pi'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
process.env.PATH = `${operatorBin}:${savedPath ?? '/usr/bin:/bin'}`;
|
||||
|
||||
let message = '';
|
||||
try {
|
||||
await program().parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'--mosaic-home',
|
||||
mosaicHome,
|
||||
'install',
|
||||
'--no-enable',
|
||||
]);
|
||||
} catch (error: unknown) {
|
||||
message = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
expect(message).toContain('runtime=pi');
|
||||
expect(message).toContain('requested_by=coder0,coder1');
|
||||
expect(message).toContain('pane_path=');
|
||||
expect(message).toContain('npm install -g @earendil-works/pi-coding-agent');
|
||||
expect(message).not.toContain(operatorBin);
|
||||
expect(
|
||||
await exists(join(tempHome!, '.config', 'systemd', 'user', '[email protected]')),
|
||||
).toBe(false);
|
||||
expect(await exists(join(mosaicHome, 'fleet', 'holder.id'))).toBe(false);
|
||||
expect(await exists(join(mosaicHome, 'tools'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects resolved Node-shebang commands when Node is absent from the pane PATH', async (): Promise<void> => {
|
||||
const mosaicHome = await v2Home();
|
||||
const runtimeDir = join(tempHome!, '.npm-global', 'bin');
|
||||
const nodeScript = '#!/usr/bin/env node\nconsole.log("should-not-run");\n';
|
||||
await writeFile(join(runtimeDir, 'mosaic'), nodeScript, { mode: 0o755 });
|
||||
await writeFile(join(runtimeDir, 'pi'), nodeScript, { mode: 0o755 });
|
||||
await writeFile(join(tempHome!, '.npmrc'), `prefix=${join(tempHome!, 'absent-prefix')}\n`);
|
||||
const isolatedSystemPath = join(tempHome!, 'system-bin');
|
||||
await mkdir(isolatedSystemPath, { recursive: true });
|
||||
const isolatedProbeRunner: FleetRuntimeProbeRunner = async (
|
||||
command,
|
||||
args,
|
||||
): Promise<CommandResult> =>
|
||||
new Promise((settle) => {
|
||||
const child = execFile(
|
||||
command,
|
||||
[...args, '--system-path', isolatedSystemPath],
|
||||
{ encoding: 'utf8' },
|
||||
(error, stdout, stderr) => {
|
||||
settle({
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode: child.exitCode ?? (error === null ? 0 : 1),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
let message = '';
|
||||
try {
|
||||
await program(greenfieldRunner, isolatedProbeRunner).parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'--mosaic-home',
|
||||
mosaicHome,
|
||||
'install',
|
||||
'--no-enable',
|
||||
]);
|
||||
} catch (error: unknown) {
|
||||
message = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
expect(message).toContain('check=fleet-cli-executable');
|
||||
expect(message).toContain('binary=mosaic');
|
||||
expect(message).toContain('dependency=node');
|
||||
expect(message).toContain('check=fleet-runtime-available');
|
||||
expect(message).toContain('runtime=pi');
|
||||
expect(message).not.toContain('/usr/bin');
|
||||
expect(await exists(join(mosaicHome, 'fleet', 'holder.id'))).toBe(false);
|
||||
expect(await exists(join(mosaicHome, 'tools'))).toBe(false);
|
||||
});
|
||||
|
||||
it('places the tool files and unit templates', async (): Promise<void> => {
|
||||
const mosaicHome = await v2Home();
|
||||
capture();
|
||||
@@ -287,11 +183,9 @@ describe('mosaic fleet install — roster v2', (): void => {
|
||||
]) {
|
||||
expect(await exists(join(systemdUserDir, unit))).toBe(true);
|
||||
}
|
||||
for (const tool of ['start-agent-session.sh', 'pane-runtime-path.sh']) {
|
||||
const toolPath = join(mosaicHome, 'tools', 'fleet', tool);
|
||||
expect(await exists(toolPath)).toBe(true);
|
||||
expect((await stat(toolPath)).mode & 0o777).toBe(0o755);
|
||||
}
|
||||
const launcher = join(mosaicHome, 'tools', 'fleet', 'start-agent-session.sh');
|
||||
expect(await exists(launcher)).toBe(true);
|
||||
expect((await stat(launcher)).mode & 0o777).toBe(0o755);
|
||||
});
|
||||
|
||||
it('writes NO generated env — that file belongs to the reconciler (#791)', async (): Promise<void> => {
|
||||
|
||||
@@ -1277,10 +1277,6 @@ describe('fleet command construction', () => {
|
||||
const home = await tempDir();
|
||||
process.env.HOME = home;
|
||||
delete process.env.MOSAIC_HOME;
|
||||
const runtimeDir = join(home, '.npm-global', 'bin');
|
||||
await mkdir(runtimeDir, { recursive: true });
|
||||
await writeFile(join(runtimeDir, 'pi'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
await writeFile(join(runtimeDir, 'mosaic'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
const mosaicHome = join(home, '.config', 'mosaic');
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
@@ -1319,10 +1315,6 @@ describe('fleet command construction', () => {
|
||||
const originalHome = process.env.HOME;
|
||||
const home = await tempDir();
|
||||
process.env.HOME = home;
|
||||
const runtimeDir = join(home, '.npm-global', 'bin');
|
||||
await mkdir(runtimeDir, { recursive: true });
|
||||
await writeFile(join(runtimeDir, 'pi'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
await writeFile(join(runtimeDir, 'mosaic'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
const mosaicHome = join(home, '.config', 'mosaic');
|
||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
||||
const fleetDir = join(mosaicHome, 'fleet');
|
||||
|
||||
@@ -60,12 +60,6 @@ import {
|
||||
writeAgentEnvironmentProjection,
|
||||
writeManagedFleetRoster,
|
||||
} from '../fleet/generated-env-boundary.js';
|
||||
import {
|
||||
assertFleetRuntimeAvailability,
|
||||
FleetRuntimePreflightError,
|
||||
inspectFleetRuntimeAvailability,
|
||||
type FleetRuntimeProbeRunner,
|
||||
} from '../fleet/fleet-runtime-preflight.js';
|
||||
import { registerFleetBacklogCommand } from './fleet-backlog.js';
|
||||
import { registerFleetPersonaCommand } from './fleet-personas.js';
|
||||
import { registerFleetProfileCommand } from './fleet-profiles.js';
|
||||
@@ -95,8 +89,6 @@ export type SleepFn = (ms: number) => Promise<void>;
|
||||
|
||||
export interface FleetCommandDeps {
|
||||
runner?: CommandRunner;
|
||||
/** Executes the pane-PATH helper under a clean launcher environment. */
|
||||
runtimeProbeRunner?: FleetRuntimeProbeRunner;
|
||||
/** Injectable interactive runner for commands needing inherited TTY (e.g., `tmux attach`). */
|
||||
interactiveRunner?: InteractiveRunner;
|
||||
/**
|
||||
@@ -1437,10 +1429,6 @@ export function isSendAccepted(capturedOutput: string): SendVerifyResult {
|
||||
|
||||
export function registerFleetCommand(program: Command, deps: FleetCommandDeps = {}): Command {
|
||||
const runner = deps.runner ?? runCommand;
|
||||
const runtimeProbeRunner: FleetRuntimeProbeRunner =
|
||||
deps.runtimeProbeRunner ??
|
||||
(async (command: string, args: readonly string[]): Promise<CommandResult> =>
|
||||
runCommand(command, [...args]));
|
||||
const sleepFn = deps.sleepFn ?? defaultSleep;
|
||||
const paths = resolveFleetPaths(deps.mosaicHome);
|
||||
const frameworkRoot = deps.frameworkRoot ?? resolveFrameworkRoot();
|
||||
@@ -1539,7 +1527,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
.description('Install local fleet tools and user systemd units')
|
||||
.option('--no-enable', 'Skip enabling units for boot-survival')
|
||||
.action(async (opts: { enable?: boolean }) => {
|
||||
await installFleet(cmd, frameworkRoot, runtimeProbeRunner);
|
||||
await installFleet(cmd, frameworkRoot);
|
||||
// Unit enablement needs agent names only, so it reads either version.
|
||||
const roster = await loadRosterReadModel(cmd);
|
||||
await enableFleetUnits(runner, roster, opts);
|
||||
@@ -1550,7 +1538,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
.description('Install local fleet tools and user systemd units')
|
||||
.option('--no-enable', 'Skip enabling units for boot-survival')
|
||||
.action(async (opts: { enable?: boolean }) => {
|
||||
await installFleet(cmd, frameworkRoot, runtimeProbeRunner);
|
||||
await installFleet(cmd, frameworkRoot);
|
||||
// Unit enablement needs agent names only, so it reads either version.
|
||||
const roster = await loadRosterReadModel(cmd);
|
||||
await enableFleetUnits(runner, roster, opts);
|
||||
@@ -2096,8 +2084,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
});
|
||||
registerFleetReconcilerCommands(cmd, {
|
||||
runner,
|
||||
runtimeProbeRunner,
|
||||
frameworkRoot,
|
||||
mosaicHome: deps.mosaicHome,
|
||||
reconcileDeps: deps.reconcileDeps,
|
||||
});
|
||||
@@ -2363,68 +2349,18 @@ export function registerFleetAgentCommands(
|
||||
});
|
||||
}
|
||||
|
||||
async function installFleet(
|
||||
cmd: Command,
|
||||
frameworkRoot: string,
|
||||
runtimeProbeRunner: FleetRuntimeProbeRunner,
|
||||
): Promise<void> {
|
||||
async function installFleet(cmd: Command, frameworkRoot: string): Promise<void> {
|
||||
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
|
||||
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
|
||||
// Read and preflight before the first mkdir/copy/chmod/write. A successful
|
||||
// install must mean every roster runtime is executable in the eventual pane,
|
||||
// not merely visible to the operator who invoked this command.
|
||||
// Read model first: every file this function places is roster-independent, and
|
||||
// the v1 parser would reject a v2 roster before any of them were written.
|
||||
const roster = await loadRosterReadModel(cmd);
|
||||
const v1Roster = roster.version === 1 ? await loadRosterForCommand(cmd) : undefined;
|
||||
const preflightV1Projections =
|
||||
v1Roster === undefined
|
||||
? []
|
||||
: await Promise.all(
|
||||
v1Roster.agents.map((agent: FleetAgent) =>
|
||||
prepareAgentEnvironmentProjection({
|
||||
mosaicHome: activePaths.mosaicHome,
|
||||
agentEnvDir: activePaths.agentEnvDir,
|
||||
agentName: agent.name,
|
||||
generated: generateAgentEnvValues(v1Roster, agent),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const preflightAgents =
|
||||
v1Roster === undefined
|
||||
? roster.agents
|
||||
: v1Roster.agents.map((agent: FleetAgent, index: number) => {
|
||||
const prepared = preflightV1Projections[index];
|
||||
if (prepared === undefined) {
|
||||
throw new Error(`Missing prepared environment projection for ${agent.name}.`);
|
||||
}
|
||||
const local = parseAgentEnvironment(prepared.local, 'local');
|
||||
return {
|
||||
name: agent.name,
|
||||
runtime: agent.runtime,
|
||||
runtimeBin: local['MOSAIC_RUNTIME_BIN'] ?? '',
|
||||
};
|
||||
});
|
||||
const runtimeInspection = await inspectFleetRuntimeAvailability({
|
||||
mosaicHome: activePaths.mosaicHome,
|
||||
agentEnvDir: activePaths.agentEnvDir,
|
||||
helperPath: join(frameworkRoot, 'tools', 'fleet', 'pane-runtime-path.sh'),
|
||||
agents: preflightAgents,
|
||||
runner: runtimeProbeRunner,
|
||||
});
|
||||
try {
|
||||
assertFleetRuntimeAvailability(runtimeInspection);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof FleetRuntimePreflightError) {
|
||||
cmd.error(error.message, { code: 'fleet.runtime-preflight', exitCode: 1 });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await ensureFleetHolderIdentity(activePaths.mosaicHome);
|
||||
await mkdir(activePaths.fleetToolsDir, { recursive: true });
|
||||
await mkdir(activePaths.tmuxToolsDir, { recursive: true });
|
||||
await mkdir(activePaths.systemdUserDir, { recursive: true });
|
||||
|
||||
const startAgentSessionPath = join(activePaths.fleetToolsDir, 'start-agent-session.sh');
|
||||
const paneRuntimePath = join(activePaths.fleetToolsDir, 'pane-runtime-path.sh');
|
||||
const startInteractionServicePath = join(
|
||||
activePaths.fleetToolsDir,
|
||||
'start-interaction-service.sh',
|
||||
@@ -2438,7 +2374,6 @@ async function installFleet(
|
||||
const agentSendPath = join(activePaths.tmuxToolsDir, 'agent-send.sh');
|
||||
const executableToolPaths = [
|
||||
startAgentSessionPath,
|
||||
paneRuntimePath,
|
||||
startInteractionServicePath,
|
||||
startTmuxHolderPath,
|
||||
printInteractionPolicyPath,
|
||||
@@ -2449,7 +2384,6 @@ async function installFleet(
|
||||
join(frameworkRoot, 'tools', 'fleet', 'start-agent-session.sh'),
|
||||
startAgentSessionPath,
|
||||
);
|
||||
await copyFile(join(frameworkRoot, 'tools', 'fleet', 'pane-runtime-path.sh'), paneRuntimePath);
|
||||
await copyFile(
|
||||
join(frameworkRoot, 'tools', 'fleet', 'start-interaction-service.sh'),
|
||||
startInteractionServicePath,
|
||||
@@ -2493,9 +2427,7 @@ async function installFleet(
|
||||
return;
|
||||
}
|
||||
|
||||
if (v1Roster === undefined) {
|
||||
throw new Error('Roster version changed while installing fleet files.');
|
||||
}
|
||||
const v1Roster = await loadRosterForCommand(cmd);
|
||||
for (const agent of v1Roster.agents) {
|
||||
await writeAgentEnvironmentProjection({
|
||||
mosaicHome: activePaths.mosaicHome,
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { mkdtemp, readFile, readdir } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { registerMissionCommand } from './mission.js';
|
||||
import { PrdService } from '@mosaicstack/prdy';
|
||||
import type { MissionInfo } from '../tui/gateway-api.js';
|
||||
|
||||
// ── Mocks: the gateway is not available in adapter tests ──────────────────────
|
||||
|
||||
// vi.hoisted: the mock factory is hoisted above imports, so the fixture must
|
||||
// be initialized there too.
|
||||
const MISSION = vi.hoisted(
|
||||
(): MissionInfo => ({
|
||||
id: 'mission-plan-1',
|
||||
name: 'Plan Mission Alpha',
|
||||
description: null,
|
||||
status: 'planning',
|
||||
projectId: null,
|
||||
userId: null,
|
||||
phase: null,
|
||||
milestones: null,
|
||||
config: null,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-03-04T05:06:07.000Z',
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock('./with-auth.js', () => ({
|
||||
withAuth: vi.fn().mockResolvedValue({
|
||||
gateway: 'http://localhost:14242',
|
||||
cookie: 'better-auth.session_token=test',
|
||||
session: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../tui/gateway-api.js', () => ({
|
||||
fetchMissions: vi.fn().mockResolvedValue([MISSION]),
|
||||
fetchMission: vi.fn(),
|
||||
createMission: vi.fn(),
|
||||
updateMission: vi.fn(),
|
||||
fetchMissionTasks: vi.fn().mockResolvedValue([]),
|
||||
createMissionTask: vi.fn(),
|
||||
updateMissionTask: vi.fn(),
|
||||
fetchProjects: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const originalCwd = process.cwd();
|
||||
let projectDir: string;
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleStub: ReturnType<typeof vi.spyOn>[] = [];
|
||||
|
||||
function buildTestProgram(): Command {
|
||||
const program = new Command('mosaic').exitOverride();
|
||||
registerMissionCommand(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
projectDir = await mkdtemp(path.join(os.tmpdir(), 'mosaic-mission-plan-'));
|
||||
process.chdir(projectDir);
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
consoleStub.push(logSpy);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore only the per-test spies; module factory mocks keep their
|
||||
// implementations across tests.
|
||||
for (const stub of consoleStub) stub.mockRestore();
|
||||
consoleStub = [];
|
||||
process.chdir(originalCwd);
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('mosaic mission --plan (thin adapter over PrdService)', () => {
|
||||
it('creates the PRD in the shared docs/prdy authority store and persists the mission linkage', async () => {
|
||||
await buildTestProgram().parseAsync(['mission', '--plan', 'Plan Mission Alpha'], {
|
||||
from: 'user',
|
||||
});
|
||||
|
||||
// PRD landed in the same store `mosaic prdy` uses.
|
||||
const files = await readdir(path.join(projectDir, 'docs', 'prdy'));
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files[0]).toMatch(/\.yaml$/);
|
||||
|
||||
// Fresh service instance (new-process equivalent) reads the linkage back.
|
||||
const service = new PrdService({ projectPath: projectDir });
|
||||
const docs = await service.list();
|
||||
expect(docs).toHaveLength(1);
|
||||
|
||||
const prd = docs[0]!;
|
||||
expect(prd.title).toBe('Plan Mission Alpha');
|
||||
expect(prd.version).toBe(1);
|
||||
|
||||
const links = await service.listMissionLinks(prd.id);
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]).toMatchObject({
|
||||
missionId: MISSION.id,
|
||||
missionVersion: MISSION.updatedAt, // mission version marker
|
||||
prdVersion: 1,
|
||||
});
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('PRD created and linked'));
|
||||
});
|
||||
|
||||
it('linkage is persisted in the YAML authority document itself (survives restart)', async () => {
|
||||
await buildTestProgram().parseAsync(['mission', '--plan', 'Plan Mission Alpha'], {
|
||||
from: 'user',
|
||||
});
|
||||
|
||||
const files = await readdir(path.join(projectDir, 'docs', 'prdy'));
|
||||
const raw = await readFile(path.join(projectDir, 'docs', 'prdy', files[0]!), 'utf8');
|
||||
const persisted = parseYaml(raw) as { missions: Array<Record<string, unknown>> };
|
||||
|
||||
expect(persisted.missions).toHaveLength(1);
|
||||
expect(persisted.missions[0]).toMatchObject({ missionId: 'mission-plan-1' });
|
||||
});
|
||||
|
||||
it('the mission path and the prdy path resolve to the same store with stable ids/versions', async () => {
|
||||
// Mission path.
|
||||
await buildTestProgram().parseAsync(['mission', '--plan', 'Plan Mission Alpha'], {
|
||||
from: 'user',
|
||||
});
|
||||
|
||||
// prdy path (service, non-interactive entry).
|
||||
const service = new PrdService({ projectPath: projectDir });
|
||||
const direct = await service.create({ name: 'Directly Created' });
|
||||
|
||||
const all = await service.list();
|
||||
expect(all.map((doc) => doc.id).sort()).toEqual([...all.map((doc) => doc.id)].sort());
|
||||
expect(all).toHaveLength(2);
|
||||
|
||||
const files = await readdir(path.join(projectDir, 'docs', 'prdy'));
|
||||
expect(files).toContain(`${direct.id}.yaml`);
|
||||
|
||||
// Both are v1 in the same store with distinct stable ids.
|
||||
for (const doc of all) {
|
||||
expect(doc.version).toBe(1);
|
||||
expect(files).toContain(`${doc.id}.yaml`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -256,14 +256,41 @@ async function planMission(
|
||||
console.log(`Planning mission: ${mission.name}\n`);
|
||||
|
||||
try {
|
||||
const { runPrdWizard } = await import('@mosaicstack/prdy');
|
||||
await runPrdWizard({
|
||||
// Thin adapter: the PRD authority (create + mission↔PRD linkage) lives in
|
||||
// PrdService — no second writer path. The mission's updatedAt serves as
|
||||
// its version marker (the gateway exposes no numeric mission version).
|
||||
const { PrdService, runPrdWizard } = await import('@mosaicstack/prdy');
|
||||
const service = new PrdService({ projectPath: process.cwd() });
|
||||
|
||||
if (process.stdout.isTTY) {
|
||||
const created = await runPrdWizard({
|
||||
name: mission.name,
|
||||
projectPath: process.cwd(),
|
||||
interactive: true,
|
||||
});
|
||||
const linked = await service.linkMission({
|
||||
prdId: created.id,
|
||||
missionId: mission.id,
|
||||
missionVersion: mission.updatedAt,
|
||||
requirementIds: [],
|
||||
});
|
||||
console.log(
|
||||
`\nMission ${mission.id} linked to PRD ${linked.id} v${linked.version} (docs/prdy/).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = await service.planForMission({
|
||||
name: mission.name,
|
||||
projectPath: process.cwd(),
|
||||
interactive: true,
|
||||
missionId: mission.id,
|
||||
missionVersion: mission.updatedAt,
|
||||
requirementIds: [],
|
||||
});
|
||||
console.log(
|
||||
`PRD created and linked: ${doc.id} v${doc.version} — mission ${mission.id} (docs/prdy/).`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
console.error(`PRD planning failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
import { Command } from 'commander';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { registerPrdyCommand } from './prdy.js';
|
||||
import { PrdService } from '@mosaicstack/prdy';
|
||||
|
||||
// ── Mocks: keep the adapter test offline (no gateway, no disk side effects
|
||||
// outside the tmp project dir) ──────────────────────────────────────────────
|
||||
|
||||
vi.mock('./with-auth.js', () => ({
|
||||
withAuth: vi.fn().mockResolvedValue({
|
||||
gateway: 'http://localhost:14242',
|
||||
cookie: 'better-auth.session_token=test',
|
||||
session: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../tui/gateway-api.js', () => ({
|
||||
fetchProjects: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class ProcessExitError extends Error {
|
||||
constructor(readonly code: number) {
|
||||
super(`process.exit(${code})`);
|
||||
}
|
||||
}
|
||||
|
||||
function stubProcessExit() {
|
||||
return vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
||||
throw new ProcessExitError(code ?? 0);
|
||||
}) as never);
|
||||
}
|
||||
|
||||
const originalCwd = process.cwd();
|
||||
let projectDir: string;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let exitStub: ReturnType<typeof stubProcessExit>;
|
||||
|
||||
function buildTestProgram(): Command {
|
||||
const program = new Command('mosaic').exitOverride();
|
||||
registerPrdyCommand(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
function runPrdy(args: string[]): Promise<unknown> {
|
||||
return buildTestProgram().parseAsync(['prdy', ...args], { from: 'user' });
|
||||
}
|
||||
|
||||
function importableDocument(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: 'cmd-import-prd',
|
||||
title: 'Command Import PRD',
|
||||
status: 'approved', // must be forced to draft: validity is not approval
|
||||
projectPath: '/tmp/elsewhere',
|
||||
template: 'software',
|
||||
version: 1,
|
||||
sections: [
|
||||
{ id: 'introduction', title: 'Introduction', fields: { context: 'x', objective: 'y' } },
|
||||
],
|
||||
missions: [],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
projectDir = await mkdtemp(path.join(os.tmpdir(), 'mosaic-prdy-'));
|
||||
process.chdir(projectDir);
|
||||
exitStub = stubProcessExit();
|
||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore only the per-test spies: module factory mocks must keep their
|
||||
// implementations for the next test.
|
||||
exitStub.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
logSpy.mockRestore();
|
||||
process.chdir(originalCwd);
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('mosaic prdy (thin adapter over PrdService)', () => {
|
||||
it('non-interactive --init creates a PRD in the docs/prdy authority store', async () => {
|
||||
await runPrdy(['--init', 'Adapter Created']);
|
||||
|
||||
const files = await readdir(path.join(projectDir, 'docs', 'prdy'));
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files[0]).toMatch(/\.yaml$/);
|
||||
|
||||
const docs = await new PrdService({ projectPath: projectDir }).list();
|
||||
expect(docs).toHaveLength(1);
|
||||
expect(docs[0]?.title).toBe('Adapter Created');
|
||||
expect(docs[0]?.version).toBe(1);
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('PRD created'));
|
||||
});
|
||||
|
||||
it('--import <file> creates a valid import through the service', async () => {
|
||||
const filePath = path.join(projectDir, 'incoming.yaml');
|
||||
await writeFile(filePath, stringifyYaml(importableDocument()), 'utf8');
|
||||
|
||||
await runPrdy(['--import', filePath]);
|
||||
|
||||
const docs = await new PrdService({ projectPath: projectDir }).list();
|
||||
expect(docs).toHaveLength(1);
|
||||
expect(docs[0]?.id).toBe('cmd-import-prd');
|
||||
expect(docs[0]?.status).toBe('draft'); // import ≠ approval
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Imported PRD cmd-import-prd'));
|
||||
});
|
||||
|
||||
it('--import of a structurally-invalid file is a typed refusal that creates nothing', async () => {
|
||||
const filePath = path.join(projectDir, 'broken.yaml');
|
||||
await writeFile(filePath, stringifyYaml({ id: 'incomplete', no: 'structure' }), 'utf8');
|
||||
|
||||
await expect(runPrdy(['--import', filePath])).rejects.toBeInstanceOf(ProcessExitError);
|
||||
|
||||
// Typed refusal surfaced to the user, nothing created.
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('PRD wizard failed'));
|
||||
await expect(readdir(path.join(projectDir, 'docs'))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
it('--import on conflict refuses with a successor proposal and leaves bytes untouched', async () => {
|
||||
const service = new PrdService({ projectPath: projectDir });
|
||||
const existing = await service.create({ name: 'Conflict Target' });
|
||||
const storeFile = path.join(projectDir, 'docs', 'prdy', `${existing.id}.yaml`);
|
||||
const beforeBytes = await readFile(storeFile, 'utf8');
|
||||
|
||||
const filePath = path.join(projectDir, 'divergent.yaml');
|
||||
await writeFile(
|
||||
filePath,
|
||||
stringifyYaml(
|
||||
importableDocument({
|
||||
...existing,
|
||||
title: 'Divergent Command Import',
|
||||
}),
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
await expect(runPrdy(['--import', filePath])).rejects.toBeInstanceOf(ProcessExitError);
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('refusing to overwrite'));
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--accept-successor'));
|
||||
|
||||
// Original authority document is byte-identical on disk.
|
||||
expect(await readFile(storeFile, 'utf8')).toBe(beforeBytes);
|
||||
});
|
||||
|
||||
it('--import --accept-successor persists the successor version explicitly', async () => {
|
||||
const service = new PrdService({ projectPath: projectDir });
|
||||
const existing = await service.create({ name: 'Successor Target' });
|
||||
|
||||
const filePath = path.join(projectDir, 'divergent2.yaml');
|
||||
await writeFile(
|
||||
filePath,
|
||||
stringifyYaml(
|
||||
importableDocument({
|
||||
...existing,
|
||||
title: 'Accepted Via CLI',
|
||||
}),
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
await runPrdy(['--import', filePath, '--accept-successor']);
|
||||
|
||||
const doc = await service.get(existing.id);
|
||||
expect(doc.version).toBe(2);
|
||||
expect(doc.title).toBe('Accepted Via CLI');
|
||||
expect(doc.status).toBe('draft');
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('successor'));
|
||||
});
|
||||
|
||||
it('--export writes a labeled generated view and never touches authority', async () => {
|
||||
const service = new PrdService({ projectPath: projectDir });
|
||||
const created = await service.create({ name: 'Export Via CLI' });
|
||||
const before = await service.get(created.id);
|
||||
|
||||
await runPrdy(['--export', created.id]);
|
||||
|
||||
const mdPath = path.join(projectDir, 'docs', 'prdy', `${created.id}.md`);
|
||||
const md = await readFile(mdPath, 'utf8');
|
||||
expect(md).toContain('generated view — do not edit');
|
||||
expect(md).toContain(`prd-id: ${created.id}`);
|
||||
expect(md).toContain('prd-version: 1');
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`Generated view written: ${mdPath}`),
|
||||
);
|
||||
|
||||
// Authority unchanged by the export.
|
||||
expect(await service.get(created.id)).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,10 @@ import type { Command } from 'commander';
|
||||
import { withAuth } from './with-auth.js';
|
||||
import { fetchProjects } from '../tui/gateway-api.js';
|
||||
|
||||
/**
|
||||
* `mosaic prdy` — thin adapter over PrdService (@mosaicstack/prdy).
|
||||
* All reads/writes go through the service; there is no local writer path.
|
||||
*/
|
||||
export function registerPrdyCommand(program: Command) {
|
||||
const cmd = program
|
||||
.command('prdy')
|
||||
@@ -9,12 +13,18 @@ export function registerPrdyCommand(program: Command) {
|
||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||
.option('--init [name]', 'Create a new PRD')
|
||||
.option('--update [name]', 'Update an existing PRD')
|
||||
.option('--import <file>', 'Import a YAML PRD document (validated, conflict-aware)')
|
||||
.option('--accept-successor', 'With --import: accept a conflicted import as next version')
|
||||
.option('--export [id]', 'Export a PRD as a labeled generated-view Markdown file')
|
||||
.option('--project <idOrName>', 'Scope to project')
|
||||
.action(
|
||||
async (opts: {
|
||||
gateway: string;
|
||||
init?: string | boolean;
|
||||
update?: string | boolean;
|
||||
import?: string;
|
||||
acceptSuccessor?: boolean;
|
||||
export?: string | boolean;
|
||||
project?: string;
|
||||
}) => {
|
||||
// Detect project context when --project flag is provided
|
||||
@@ -31,20 +41,69 @@ export function registerPrdyCommand(program: Command) {
|
||||
}
|
||||
}
|
||||
|
||||
const { PrdService, runPrdWizard } = await import('@mosaicstack/prdy');
|
||||
const service = new PrdService({ projectPath: process.cwd() });
|
||||
|
||||
try {
|
||||
const { runPrdWizard } = await import('@mosaicstack/prdy');
|
||||
if (opts.import !== undefined) {
|
||||
const input = { filePath: opts.import };
|
||||
|
||||
if (opts.acceptSuccessor) {
|
||||
const successor = await service.acceptSuccessor(input);
|
||||
console.log(
|
||||
`Import accepted as successor: ${successor.id} v${successor.version} (status: ${successor.status})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await service.importDocument(input);
|
||||
console.log(
|
||||
result.kind === 'created'
|
||||
? `Imported PRD ${result.document.id} v${result.document.version} (status: ${result.document.status})`
|
||||
: `PRD ${result.document.id} already present with identical content — nothing to do.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.export !== undefined) {
|
||||
const id =
|
||||
typeof opts.export === 'string' && opts.export.length > 0 ? opts.export : undefined;
|
||||
const result = await service.exportMarkdown({ id });
|
||||
console.log(
|
||||
`Generated view written: ${result.filePath} (source authority: YAML under docs/prdy/ — do not edit the Markdown)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const name =
|
||||
typeof opts.init === 'string'
|
||||
? opts.init
|
||||
: typeof opts.update === 'string'
|
||||
? opts.update
|
||||
: 'untitled';
|
||||
await runPrdWizard({
|
||||
name,
|
||||
projectPath: process.cwd(),
|
||||
interactive: true,
|
||||
});
|
||||
|
||||
if (process.stdout.isTTY) {
|
||||
await runPrdWizard({
|
||||
name,
|
||||
projectPath: process.cwd(),
|
||||
interactive: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-interactive fallback routes through the service directly.
|
||||
const doc = await service.create({ name });
|
||||
console.log(`PRD created: ${doc.id} v${doc.version} (status: ${doc.status})`);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'PrdImportConflictError') {
|
||||
const conflict = err as { proposal?: { version?: number } };
|
||||
console.error(`${err.message}`);
|
||||
console.error(
|
||||
`Original PRD left untouched. To accept the proposed successor (v${conflict.proposal?.version}), re-run with --accept-successor.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
inspectFleetRuntimeAvailability,
|
||||
type FleetRuntimeProbeResult,
|
||||
type FleetRuntimeProbeRunner,
|
||||
} from './fleet-runtime-preflight.js';
|
||||
|
||||
const helperPath = resolve(process.cwd(), 'framework', 'tools', 'fleet', 'pane-runtime-path.sh');
|
||||
let cleanup: string | undefined;
|
||||
|
||||
afterEach(async (): Promise<void> => {
|
||||
if (cleanup !== undefined) await rm(cleanup, { recursive: true, force: true });
|
||||
cleanup = undefined;
|
||||
});
|
||||
|
||||
interface FleetFixture {
|
||||
readonly root: string;
|
||||
readonly mosaicHome: string;
|
||||
readonly agentEnvDir: string;
|
||||
readonly runtimeDir: string;
|
||||
}
|
||||
|
||||
async function fleetHome(): Promise<FleetFixture> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'mosaic-fleet-runtime-preflight-'));
|
||||
cleanup = root;
|
||||
const mosaicHome = join(root, '.config', 'mosaic');
|
||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
||||
const runtimeDir = join(root, '.npm-global', 'bin');
|
||||
await mkdir(agentEnvDir, { recursive: true, mode: 0o700 });
|
||||
await mkdir(runtimeDir, { recursive: true });
|
||||
for (const directory of [mosaicHome, join(mosaicHome, 'fleet'), agentEnvDir]) {
|
||||
await chmod(directory, 0o700);
|
||||
}
|
||||
await writeExecutable(runtimeDir, 'mosaic', '#!/bin/sh\nexit 0\n');
|
||||
return { root, mosaicHome, agentEnvDir, runtimeDir };
|
||||
}
|
||||
|
||||
async function writeExecutable(directory: string, name: string, content: string): Promise<void> {
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(join(directory, name), content, { mode: 0o755 });
|
||||
}
|
||||
|
||||
const processRunner: FleetRuntimeProbeRunner = async (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
): Promise<FleetRuntimeProbeResult> =>
|
||||
new Promise((settle) => {
|
||||
const child = spawn(command, [...args], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', (chunk: string): void => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on('data', (chunk: string): void => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on('error', (error: Error): void => {
|
||||
settle({ stdout, stderr: `${stderr}${error.message}`, exitCode: 127 });
|
||||
});
|
||||
child.on('close', (code: number | null): void => {
|
||||
settle({ stdout, stderr, exitCode: code ?? 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('fleet runtime preflight', (): void => {
|
||||
it('executes one distinct pane runtime and aggregates every requesting roster row', async (): Promise<void> => {
|
||||
const fixture = await fleetHome();
|
||||
await writeExecutable(fixture.runtimeDir, 'pi', '#!/bin/sh\nexit 0\n');
|
||||
let probes = 0;
|
||||
|
||||
const inspection = await inspectFleetRuntimeAvailability({
|
||||
mosaicHome: fixture.mosaicHome,
|
||||
agentEnvDir: fixture.agentEnvDir,
|
||||
helperPath,
|
||||
agents: [
|
||||
{ name: 'coder1', runtime: 'pi' },
|
||||
{ name: 'coder0', runtime: 'pi' },
|
||||
],
|
||||
runner: async (command, args): Promise<FleetRuntimeProbeResult> => {
|
||||
probes += 1;
|
||||
return processRunner(command, args);
|
||||
},
|
||||
});
|
||||
|
||||
expect(probes).toBe(2);
|
||||
expect(inspection.fleetCliExecutable).toEqual([
|
||||
expect.objectContaining({
|
||||
check: 'fleet-cli-executable',
|
||||
status: 'ok',
|
||||
requestedBy: ['coder0', 'coder1'],
|
||||
binaryPath: join(fixture.runtimeDir, 'mosaic'),
|
||||
dependency: '/bin/sh',
|
||||
}),
|
||||
]);
|
||||
expect(inspection.fleetRuntimeAvailability).toEqual([
|
||||
expect.objectContaining({
|
||||
check: 'fleet-runtime-available',
|
||||
runtime: 'pi',
|
||||
status: 'ok',
|
||||
requestedBy: ['coder0', 'coder1'],
|
||||
binaryPath: join(fixture.runtimeDir, 'pi'),
|
||||
dependency: '/bin/sh',
|
||||
installCommand: 'npm install -g @earendil-works/pi-coding-agent',
|
||||
}),
|
||||
]);
|
||||
expect(inspection.fleetRuntimeAvailability[0]?.panePath).toContain(fixture.runtimeDir);
|
||||
});
|
||||
|
||||
it('returns an actionable non-green check when the pane PATH lacks the runtime', async (): Promise<void> => {
|
||||
const fixture = await fleetHome();
|
||||
|
||||
const inspection = await inspectFleetRuntimeAvailability({
|
||||
mosaicHome: fixture.mosaicHome,
|
||||
agentEnvDir: fixture.agentEnvDir,
|
||||
helperPath,
|
||||
agents: [{ name: 'coder0', runtime: 'pi' }],
|
||||
runner: processRunner,
|
||||
});
|
||||
|
||||
expect(inspection.fleetCliExecutable[0]?.status).toBe('ok');
|
||||
expect(inspection.fleetRuntimeAvailability).toEqual([
|
||||
expect.objectContaining({
|
||||
check: 'fleet-runtime-available',
|
||||
runtime: 'pi',
|
||||
status: 'missing',
|
||||
requestedBy: ['coder0'],
|
||||
installCommand: 'npm install -g @earendil-works/pi-coding-agent',
|
||||
}),
|
||||
]);
|
||||
expect(inspection.fleetRuntimeAvailability[0]?.panePath).not.toContain(
|
||||
process.env['PATH'] ?? 'operator-path-absent',
|
||||
);
|
||||
});
|
||||
|
||||
it('executes the side-effect-free Node version probe for Node-shebang commands', async (): Promise<void> => {
|
||||
const fixture = await fleetHome();
|
||||
const nodeScript = '#!/usr/bin/env node\nconsole.log("should-not-run");\n';
|
||||
await writeExecutable(fixture.runtimeDir, 'mosaic', nodeScript);
|
||||
await writeExecutable(fixture.runtimeDir, 'pi', nodeScript);
|
||||
await writeExecutable(
|
||||
fixture.runtimeDir,
|
||||
'node',
|
||||
'#!/bin/sh\n[ "$1" = --version ] || exit 9\nprintf "v-fixture-node\\n"\n',
|
||||
);
|
||||
|
||||
const inspection = await inspectFleetRuntimeAvailability({
|
||||
mosaicHome: fixture.mosaicHome,
|
||||
agentEnvDir: fixture.agentEnvDir,
|
||||
helperPath,
|
||||
agents: [{ name: 'coder0', runtime: 'pi' }],
|
||||
runner: processRunner,
|
||||
});
|
||||
|
||||
for (const check of [
|
||||
...inspection.fleetCliExecutable,
|
||||
...inspection.fleetRuntimeAvailability,
|
||||
]) {
|
||||
expect(check).toMatchObject({
|
||||
status: 'ok',
|
||||
dependency: 'node',
|
||||
probeCommand: 'node --version',
|
||||
probeExit: 0,
|
||||
probeOutput: 'v-fixture-node',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('reddens when resolved Node-shebang commands cannot execute without pane Node', async (): Promise<void> => {
|
||||
const fixture = await fleetHome();
|
||||
const isolatedSystemPath = join(fixture.root, 'system-bin');
|
||||
await mkdir(isolatedSystemPath, { recursive: true });
|
||||
await writeFile(
|
||||
join(fixture.root, '.npmrc'),
|
||||
`prefix=${join(fixture.root, 'absent-prefix')}\n`,
|
||||
);
|
||||
const nodeScript = '#!/usr/bin/env node\nconsole.log("should-not-run");\n';
|
||||
await writeExecutable(fixture.runtimeDir, 'mosaic', nodeScript);
|
||||
await writeExecutable(fixture.runtimeDir, 'pi', nodeScript);
|
||||
|
||||
const inspection = await inspectFleetRuntimeAvailability({
|
||||
mosaicHome: fixture.mosaicHome,
|
||||
agentEnvDir: fixture.agentEnvDir,
|
||||
helperPath,
|
||||
agents: [{ name: 'coder0', runtime: 'pi' }],
|
||||
runner: processRunner,
|
||||
systemPath: isolatedSystemPath,
|
||||
});
|
||||
|
||||
expect(inspection.fleetCliExecutable).toEqual([
|
||||
expect.objectContaining({
|
||||
check: 'fleet-cli-executable',
|
||||
status: 'unexecutable',
|
||||
binaryPath: join(fixture.runtimeDir, 'mosaic'),
|
||||
dependency: 'node',
|
||||
probeCommand: 'node --version',
|
||||
}),
|
||||
]);
|
||||
expect(inspection.fleetRuntimeAvailability).toEqual([
|
||||
expect.objectContaining({
|
||||
check: 'fleet-runtime-available',
|
||||
runtime: 'pi',
|
||||
status: 'unexecutable',
|
||||
binaryPath: join(fixture.runtimeDir, 'pi'),
|
||||
dependency: 'node',
|
||||
probeCommand: 'node --version',
|
||||
}),
|
||||
]);
|
||||
expect(inspection.fleetCliExecutable[0]?.probeOutput).toBe(
|
||||
'shebang command is not on the pane PATH',
|
||||
);
|
||||
expect(inspection.fleetCliExecutable[0]?.panePath).not.toContain('/usr/bin');
|
||||
});
|
||||
|
||||
it('keeps distinct effective local runtime-bin paths as distinct checks', async (): Promise<void> => {
|
||||
const fixture = await fleetHome();
|
||||
const firstBin = join(fixture.root, 'first-bin');
|
||||
const secondBin = join(fixture.root, 'second-bin');
|
||||
for (const override of [
|
||||
{ agent: 'coder0', runtimeBin: firstBin },
|
||||
{ agent: 'coder1', runtimeBin: secondBin },
|
||||
]) {
|
||||
await writeExecutable(override.runtimeBin, 'pi', '#!/bin/sh\nexit 0\n');
|
||||
await writeFile(
|
||||
join(fixture.agentEnvDir, `${override.agent}.env.local`),
|
||||
`MOSAIC_RUNTIME_BIN=${override.runtimeBin}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
}
|
||||
|
||||
const inspection = await inspectFleetRuntimeAvailability({
|
||||
mosaicHome: fixture.mosaicHome,
|
||||
agentEnvDir: fixture.agentEnvDir,
|
||||
helperPath,
|
||||
agents: [
|
||||
{ name: 'coder0', runtime: 'pi' },
|
||||
{ name: 'coder1', runtime: 'pi' },
|
||||
],
|
||||
runner: processRunner,
|
||||
});
|
||||
|
||||
expect(inspection.fleetCliExecutable).toHaveLength(2);
|
||||
expect(inspection.fleetRuntimeAvailability).toHaveLength(2);
|
||||
expect(inspection.fleetRuntimeAvailability.map((check) => check.requestedBy)).toEqual([
|
||||
['coder0'],
|
||||
['coder1'],
|
||||
]);
|
||||
expect(inspection.fleetRuntimeAvailability.map((check) => check.binaryPath)).toEqual([
|
||||
join(firstBin, 'pi'),
|
||||
join(secondBin, 'pi'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports each distinct roster runtime with its exact install command', async (): Promise<void> => {
|
||||
const fixture = await fleetHome();
|
||||
let probes = 0;
|
||||
|
||||
const inspection = await inspectFleetRuntimeAvailability({
|
||||
mosaicHome: fixture.mosaicHome,
|
||||
agentEnvDir: fixture.agentEnvDir,
|
||||
helperPath,
|
||||
agents: [
|
||||
{ name: 'pi-seat', runtime: 'pi' },
|
||||
{ name: 'claude-seat', runtime: 'claude' },
|
||||
{ name: 'codex-seat', runtime: 'codex' },
|
||||
{ name: 'opencode-seat', runtime: 'opencode' },
|
||||
],
|
||||
runner: async (): Promise<FleetRuntimeProbeResult> => {
|
||||
probes += 1;
|
||||
return {
|
||||
stdout:
|
||||
'pane_path\u0000/fixture/bin:/usr/bin:/bin\u0000status\u0000missing\u0000' +
|
||||
'binary_path\u0000\u0000probe_exit\u0000\u0000probe_output\u0000\u0000',
|
||||
stderr: '',
|
||||
exitCode: 69,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(probes).toBe(5);
|
||||
expect(
|
||||
inspection.fleetRuntimeAvailability.map((check) => ({
|
||||
runtime: check.runtime,
|
||||
installCommand: check.installCommand,
|
||||
})),
|
||||
).toEqual([
|
||||
{
|
||||
runtime: 'claude',
|
||||
installCommand: 'curl -fsSL https://claude.ai/install.sh | bash',
|
||||
},
|
||||
{
|
||||
runtime: 'codex',
|
||||
installCommand: 'npm install -g @openai/codex',
|
||||
},
|
||||
{
|
||||
runtime: 'opencode',
|
||||
installCommand: 'npm install -g opencode-ai',
|
||||
},
|
||||
{
|
||||
runtime: 'pi',
|
||||
installCommand: 'npm install -g @earendil-works/pi-coding-agent',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('fails closed when the shared helper returns malformed evidence', async (): Promise<void> => {
|
||||
const fixture = await fleetHome();
|
||||
|
||||
await expect(
|
||||
inspectFleetRuntimeAvailability({
|
||||
mosaicHome: fixture.mosaicHome,
|
||||
agentEnvDir: fixture.agentEnvDir,
|
||||
helperPath,
|
||||
agents: [{ name: 'coder0', runtime: 'pi' }],
|
||||
runner: async (): Promise<FleetRuntimeProbeResult> => ({
|
||||
stdout: 'not-a-field-protocol',
|
||||
stderr: '',
|
||||
exitCode: 0,
|
||||
}),
|
||||
}),
|
||||
).rejects.toThrow('malformed field output');
|
||||
});
|
||||
});
|
||||
@@ -1,362 +0,0 @@
|
||||
import { homedir } from 'node:os';
|
||||
import { getInstallInstructions } from '../runtime/detector.js';
|
||||
import type { RuntimeName } from '../types.js';
|
||||
import { compareCodePoints } from './deterministic-order.js';
|
||||
import {
|
||||
GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES,
|
||||
readAgentLocalEnvironment,
|
||||
} from './generated-env-boundary.js';
|
||||
|
||||
const RUNTIME_SET = new Set<string>(GENERATED_AGENT_ENV_SUPPORTED_RUNTIMES);
|
||||
|
||||
export interface FleetRuntimeRequestedAgent {
|
||||
readonly name: string;
|
||||
readonly runtime: string;
|
||||
/** Planned effective local override, when provisioning has already prepared it. */
|
||||
readonly runtimeBin?: string;
|
||||
}
|
||||
|
||||
export interface FleetRuntimeProbeResult {
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
readonly exitCode: number;
|
||||
}
|
||||
|
||||
export type FleetRuntimeProbeRunner = (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
) => Promise<FleetRuntimeProbeResult>;
|
||||
|
||||
export interface FleetRuntimePreflightOptions {
|
||||
readonly mosaicHome: string;
|
||||
readonly agentEnvDir: string;
|
||||
readonly helperPath: string;
|
||||
readonly agents: readonly FleetRuntimeRequestedAgent[];
|
||||
readonly runner: FleetRuntimeProbeRunner;
|
||||
/** Test-only system suffix; production and the launcher use the helper default. */
|
||||
readonly systemPath?: string;
|
||||
}
|
||||
|
||||
export type FleetExecutableStatus = 'ok' | 'missing' | 'unexecutable';
|
||||
|
||||
interface FleetExecutableEvidence {
|
||||
readonly status: FleetExecutableStatus;
|
||||
readonly panePath: string;
|
||||
readonly binaryPath?: string;
|
||||
readonly dependency?: string;
|
||||
readonly probeCommand?: string;
|
||||
readonly probeExit?: number;
|
||||
readonly probeOutput?: string;
|
||||
}
|
||||
|
||||
export interface FleetCliExecutableCheck extends FleetExecutableEvidence {
|
||||
readonly check: 'fleet-cli-executable';
|
||||
readonly binary: 'mosaic';
|
||||
readonly requestedBy: readonly string[];
|
||||
}
|
||||
|
||||
export interface FleetRuntimeCheck extends FleetExecutableEvidence {
|
||||
readonly check: 'fleet-runtime-available';
|
||||
readonly runtime: RuntimeName;
|
||||
readonly requestedBy: readonly string[];
|
||||
readonly installCommand: string;
|
||||
}
|
||||
|
||||
export type FleetRuntimePreflightCheck = FleetCliExecutableCheck | FleetRuntimeCheck;
|
||||
|
||||
export interface FleetRuntimeInspection {
|
||||
readonly fleetCliExecutable: readonly FleetCliExecutableCheck[];
|
||||
readonly fleetRuntimeAvailability: readonly FleetRuntimeCheck[];
|
||||
}
|
||||
|
||||
interface EffectiveAgent {
|
||||
readonly name: string;
|
||||
readonly runtime: RuntimeName;
|
||||
readonly runtimeBin: string;
|
||||
}
|
||||
|
||||
interface PaneProbeGroup {
|
||||
readonly runtimeBin: string;
|
||||
readonly requestedBy: string[];
|
||||
}
|
||||
|
||||
interface RuntimeProbeGroup extends PaneProbeGroup {
|
||||
readonly runtime: RuntimeName;
|
||||
}
|
||||
|
||||
interface BinaryProbeRequest {
|
||||
readonly binary: string;
|
||||
readonly runtimeBin: string;
|
||||
}
|
||||
|
||||
export class FleetRuntimePreflightError extends Error {
|
||||
readonly checks: readonly FleetRuntimePreflightCheck[];
|
||||
|
||||
constructor(checks: readonly FleetRuntimePreflightCheck[]) {
|
||||
super(formatFleetRuntimePreflightError(checks));
|
||||
this.name = FleetRuntimePreflightError.name;
|
||||
this.checks = checks;
|
||||
}
|
||||
}
|
||||
|
||||
export class FleetRuntimeProbeError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = FleetRuntimeProbeError.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proves the fleet CLI and every distinct runtime/effective-bin pair resolve
|
||||
* with an executable shebang interpreter through the eventual pane PATH. The
|
||||
* helper runs under the unit's clean launcher environment, so operator PATH can
|
||||
* neither create a false green nor provide a hidden interpreter.
|
||||
*/
|
||||
export async function inspectFleetRuntimeAvailability(
|
||||
options: FleetRuntimePreflightOptions,
|
||||
): Promise<FleetRuntimeInspection> {
|
||||
const agents = await resolveEffectiveAgents(options);
|
||||
const paneGroups = groupPaneRequests(agents);
|
||||
const runtimeGroups = groupRuntimeRequests(agents);
|
||||
|
||||
const fleetCliExecutable: FleetCliExecutableCheck[] = [];
|
||||
for (const group of paneGroups) {
|
||||
const evidence = await probeBinary(options, {
|
||||
binary: 'mosaic',
|
||||
runtimeBin: group.runtimeBin,
|
||||
});
|
||||
fleetCliExecutable.push({
|
||||
check: 'fleet-cli-executable',
|
||||
binary: 'mosaic',
|
||||
requestedBy: sortedRequestedBy(group.requestedBy),
|
||||
...evidence,
|
||||
});
|
||||
}
|
||||
|
||||
const fleetRuntimeAvailability: FleetRuntimeCheck[] = [];
|
||||
for (const group of runtimeGroups) {
|
||||
const evidence = await probeBinary(options, {
|
||||
binary: group.runtime,
|
||||
runtimeBin: group.runtimeBin,
|
||||
});
|
||||
fleetRuntimeAvailability.push({
|
||||
check: 'fleet-runtime-available',
|
||||
runtime: group.runtime,
|
||||
requestedBy: sortedRequestedBy(group.requestedBy),
|
||||
installCommand: getInstallInstructions(group.runtime),
|
||||
...evidence,
|
||||
});
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
fleetCliExecutable: Object.freeze(fleetCliExecutable),
|
||||
fleetRuntimeAvailability: Object.freeze(fleetRuntimeAvailability),
|
||||
});
|
||||
}
|
||||
|
||||
export function assertFleetRuntimeAvailability(inspection: FleetRuntimeInspection): void {
|
||||
const checks: FleetRuntimePreflightCheck[] = [
|
||||
...inspection.fleetCliExecutable,
|
||||
...inspection.fleetRuntimeAvailability,
|
||||
];
|
||||
const failures = checks.filter(
|
||||
(check: FleetRuntimePreflightCheck): boolean => check.status !== 'ok',
|
||||
);
|
||||
if (failures.length > 0) throw new FleetRuntimePreflightError(failures);
|
||||
}
|
||||
|
||||
export function formatFleetRuntimePreflightError(
|
||||
checks: readonly FleetRuntimePreflightCheck[],
|
||||
): string {
|
||||
const lines = ['Fleet runtime preflight failed:'];
|
||||
for (const check of checks) {
|
||||
const dependency = check.dependency === undefined ? '' : ` dependency=${check.dependency}`;
|
||||
const probe = check.probeCommand === undefined ? '' : ` dependency_probe=${check.probeCommand}`;
|
||||
const execution =
|
||||
check.status === 'unexecutable'
|
||||
? ` probe_exit=${check.probeExit?.toString() ?? 'not-run'} ` +
|
||||
`probe_output=${JSON.stringify(check.probeOutput ?? '')}`
|
||||
: '';
|
||||
if (check.check === 'fleet-cli-executable') {
|
||||
lines.push(
|
||||
`check=${check.check} binary=${check.binary} ` +
|
||||
`requested_by=${check.requestedBy.join(',')} pane_path=${check.panePath}` +
|
||||
`${dependency}${probe}${execution} ` +
|
||||
'action=repair the Mosaic installation until its pane dependencies resolve',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
lines.push(
|
||||
`check=${check.check} runtime=${check.runtime} ` +
|
||||
`requested_by=${check.requestedBy.join(',')} pane_path=${check.panePath}` +
|
||||
`${dependency}${probe}${execution} install_command=${check.installCommand}`,
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function resolveEffectiveAgents(
|
||||
options: FleetRuntimePreflightOptions,
|
||||
): Promise<readonly EffectiveAgent[]> {
|
||||
const agents: EffectiveAgent[] = [];
|
||||
for (const agent of options.agents) {
|
||||
if (!isRuntimeName(agent.runtime)) {
|
||||
throw new FleetRuntimeProbeError(`Unsupported fleet runtime: ${agent.runtime}`);
|
||||
}
|
||||
const runtimeBin =
|
||||
agent.runtimeBin ??
|
||||
(
|
||||
await readAgentLocalEnvironment({
|
||||
mosaicHome: options.mosaicHome,
|
||||
agentEnvDir: options.agentEnvDir,
|
||||
agentName: agent.name,
|
||||
})
|
||||
)['MOSAIC_RUNTIME_BIN'] ??
|
||||
'';
|
||||
agents.push({ name: agent.name, runtime: agent.runtime, runtimeBin });
|
||||
}
|
||||
return agents;
|
||||
}
|
||||
|
||||
function groupPaneRequests(agents: readonly EffectiveAgent[]): readonly PaneProbeGroup[] {
|
||||
const groups = new Map<string, PaneProbeGroup>();
|
||||
for (const agent of agents) {
|
||||
const current = groups.get(agent.runtimeBin);
|
||||
if (current === undefined) {
|
||||
groups.set(agent.runtimeBin, { runtimeBin: agent.runtimeBin, requestedBy: [agent.name] });
|
||||
} else {
|
||||
current.requestedBy.push(agent.name);
|
||||
}
|
||||
}
|
||||
return [...groups.values()].sort((left, right): number =>
|
||||
compareCodePoints(left.runtimeBin, right.runtimeBin),
|
||||
);
|
||||
}
|
||||
|
||||
function groupRuntimeRequests(agents: readonly EffectiveAgent[]): readonly RuntimeProbeGroup[] {
|
||||
const groups = new Map<string, RuntimeProbeGroup>();
|
||||
for (const agent of agents) {
|
||||
const key = JSON.stringify([agent.runtime, agent.runtimeBin]);
|
||||
const current = groups.get(key);
|
||||
if (current === undefined) {
|
||||
groups.set(key, {
|
||||
runtime: agent.runtime,
|
||||
runtimeBin: agent.runtimeBin,
|
||||
requestedBy: [agent.name],
|
||||
});
|
||||
} else {
|
||||
current.requestedBy.push(agent.name);
|
||||
}
|
||||
}
|
||||
return [...groups.values()].sort((left, right): number =>
|
||||
compareCodePoints(
|
||||
`${left.runtime}\u0000${left.runtimeBin}`,
|
||||
`${right.runtime}\u0000${right.runtimeBin}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function probeBinary(
|
||||
options: FleetRuntimePreflightOptions,
|
||||
probe: BinaryProbeRequest,
|
||||
): Promise<FleetExecutableEvidence> {
|
||||
const args = [
|
||||
'-i',
|
||||
`HOME=${process.env['HOME'] ?? homedir()}`,
|
||||
'PATH=/usr/bin:/bin',
|
||||
`MOSAIC_HOME=${options.mosaicHome}`,
|
||||
'/bin/bash',
|
||||
'--noprofile',
|
||||
'--norc',
|
||||
options.helperPath,
|
||||
'--mosaic-home',
|
||||
options.mosaicHome,
|
||||
'--binary',
|
||||
probe.binary,
|
||||
'--check-executable',
|
||||
];
|
||||
if (probe.runtimeBin !== '') args.push('--runtime-bin', probe.runtimeBin);
|
||||
if (options.systemPath !== undefined) args.push('--system-path', options.systemPath);
|
||||
|
||||
const result = await options.runner('/usr/bin/env', args);
|
||||
const fields = parseNulFields(result.stdout);
|
||||
const panePath = requiredField(fields, 'pane_path');
|
||||
const status = requiredField(fields, 'status');
|
||||
if (result.exitCode === 0 && status === 'present') {
|
||||
return executableEvidence('ok', panePath, fields);
|
||||
}
|
||||
if (result.exitCode === 69 && status === 'missing') {
|
||||
return { status: 'missing', panePath };
|
||||
}
|
||||
if (result.exitCode === 70 && status === 'unexecutable') {
|
||||
return executableEvidence('unexecutable', panePath, fields);
|
||||
}
|
||||
throw new FleetRuntimeProbeError(
|
||||
`Fleet executable probe failed: binary=${probe.binary} exit=${result.exitCode.toString()} ` +
|
||||
`stderr=${JSON.stringify(result.stderr.trim())}`,
|
||||
);
|
||||
}
|
||||
|
||||
function executableEvidence(
|
||||
status: 'ok' | 'unexecutable',
|
||||
panePath: string,
|
||||
fields: ReadonlyMap<string, string>,
|
||||
): FleetExecutableEvidence {
|
||||
const dependency = requiredField(fields, 'dependency');
|
||||
const probeCommand = requiredField(fields, 'probe_command');
|
||||
const probeExit = requiredField(fields, 'probe_exit');
|
||||
const probeOutput = requiredField(fields, 'probe_output');
|
||||
return {
|
||||
status,
|
||||
panePath,
|
||||
binaryPath: requiredField(fields, 'binary_path'),
|
||||
...(dependency === '' ? {} : { dependency }),
|
||||
...(probeCommand === '' ? {} : { probeCommand }),
|
||||
...(probeExit === '' ? {} : { probeExit: parseProbeExit(probeExit) }),
|
||||
...(probeOutput === '' ? {} : { probeOutput }),
|
||||
};
|
||||
}
|
||||
|
||||
function sortedRequestedBy(requestedBy: readonly string[]): readonly string[] {
|
||||
return Object.freeze(
|
||||
[...requestedBy].sort((left: string, right: string): number => compareCodePoints(left, right)),
|
||||
);
|
||||
}
|
||||
|
||||
function parseNulFields(source: string): ReadonlyMap<string, string> {
|
||||
const parts = source.split('\u0000');
|
||||
if (parts.at(-1) === '') parts.pop();
|
||||
if (parts.length % 2 !== 0) {
|
||||
throw new FleetRuntimeProbeError('Fleet runtime probe returned malformed field output.');
|
||||
}
|
||||
const fields = new Map<string, string>();
|
||||
for (let index = 0; index < parts.length; index += 2) {
|
||||
const key = parts[index];
|
||||
const value = parts[index + 1];
|
||||
if (key === undefined || value === undefined || key === '' || fields.has(key)) {
|
||||
throw new FleetRuntimeProbeError('Fleet runtime probe returned malformed field output.');
|
||||
}
|
||||
fields.set(key, value);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function requiredField(fields: ReadonlyMap<string, string>, key: string): string {
|
||||
const value = fields.get(key);
|
||||
if (value === undefined) {
|
||||
throw new FleetRuntimeProbeError(`Fleet runtime probe omitted ${key}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseProbeExit(value: string): number {
|
||||
const exitCode = Number(value);
|
||||
if (!Number.isSafeInteger(exitCode) || exitCode < 0) {
|
||||
throw new FleetRuntimeProbeError('Fleet runtime probe returned an invalid execution status.');
|
||||
}
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
function isRuntimeName(value: string): value is RuntimeName {
|
||||
return RUNTIME_SET.has(value);
|
||||
}
|
||||
@@ -25,12 +25,6 @@ export interface AgentGeneratedProjectionDeletionOptions {
|
||||
readonly agentName: string;
|
||||
}
|
||||
|
||||
export interface AgentLocalEnvironmentReadOptions {
|
||||
readonly mosaicHome: string;
|
||||
readonly agentEnvDir: string;
|
||||
readonly agentName: string;
|
||||
}
|
||||
|
||||
export interface AgentEnvironmentProjectionResult {
|
||||
readonly generatedPath: string;
|
||||
readonly localPath: string;
|
||||
@@ -151,23 +145,6 @@ export function parseAgentEnvironment(
|
||||
return Object.freeze(values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one agent's optional local overrides through the same path, file-type,
|
||||
* permission, key, and value boundary used by projection/launch handling.
|
||||
*/
|
||||
export async function readAgentLocalEnvironment(
|
||||
options: AgentLocalEnvironmentReadOptions,
|
||||
): Promise<Readonly<Record<string, string>>> {
|
||||
if (!AGENT_NAME.test(options.agentName)) {
|
||||
throw new AgentEnvBoundaryError('unsafe-agent-name', 'MOSAIC_AGENT_NAME', options.agentName);
|
||||
}
|
||||
await validatePrivateProjectionDirectory(options.mosaicHome, options.agentEnvDir);
|
||||
const source = await readOptionalPrivateFile(
|
||||
join(options.agentEnvDir, `${options.agentName}.env.local`),
|
||||
);
|
||||
return source === undefined ? Object.freeze({}) : parseAgentEnvironment(source, 'local');
|
||||
}
|
||||
|
||||
/** Renders the roster-derived generated projection in a stable, complete key order. */
|
||||
export function renderGeneratedAgentEnvironment(values: Readonly<Record<string, string>>): string {
|
||||
const normalized = normalizeGeneratedValues(values);
|
||||
|
||||
@@ -19,7 +19,7 @@ const RUNTIME_DEFS: Record<
|
||||
label: 'Claude Code',
|
||||
command: 'claude',
|
||||
versionFlag: '--version',
|
||||
installHint: 'curl -fsSL https://claude.ai/install.sh | bash',
|
||||
installHint: 'npm install -g @anthropic-ai/claude-code',
|
||||
},
|
||||
codex: {
|
||||
label: 'Codex',
|
||||
@@ -31,13 +31,13 @@ const RUNTIME_DEFS: Record<
|
||||
label: 'OpenCode',
|
||||
command: 'opencode',
|
||||
versionFlag: 'version',
|
||||
installHint: 'npm install -g opencode-ai',
|
||||
installHint: 'See https://opencode.ai for install instructions',
|
||||
},
|
||||
pi: {
|
||||
label: 'Pi',
|
||||
command: 'pi',
|
||||
versionFlag: '--version',
|
||||
installHint: 'npm install -g @earendil-works/pi-coding-agent',
|
||||
installHint: 'curl -fsSL https://pi.dev/install.sh | sh',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+74
-14
@@ -1,6 +1,6 @@
|
||||
import { Command } from 'commander';
|
||||
|
||||
import { createPrd, listPrds, loadPrd } from './prd.js';
|
||||
import { PrdService } from './service.js';
|
||||
import { runPrdWizard } from './wizard.js';
|
||||
|
||||
interface InitCommandOptions {
|
||||
@@ -18,6 +18,22 @@ interface ShowCommandOptions {
|
||||
readonly id?: string;
|
||||
}
|
||||
|
||||
interface ImportCommandOptions {
|
||||
readonly project: string;
|
||||
readonly file: string;
|
||||
readonly acceptSuccessor?: boolean;
|
||||
}
|
||||
|
||||
interface ExportCommandOptions {
|
||||
readonly project: string;
|
||||
readonly id?: string;
|
||||
readonly out?: string;
|
||||
}
|
||||
|
||||
function serviceFor(project: string): PrdService {
|
||||
return new PrdService({ projectPath: project });
|
||||
}
|
||||
|
||||
export function buildPrdyCli(): Command {
|
||||
const program = new Command();
|
||||
program.name('mosaic').description('Mosaic CLI').exitOverride();
|
||||
@@ -38,11 +54,9 @@ export function buildPrdyCli(): Command {
|
||||
template: options.template,
|
||||
interactive: true,
|
||||
})
|
||||
: await createPrd({
|
||||
: await serviceFor(options.project).create({
|
||||
name: options.name,
|
||||
projectPath: options.project,
|
||||
template: options.template,
|
||||
interactive: false,
|
||||
});
|
||||
|
||||
console.log(
|
||||
@@ -52,6 +66,7 @@ export function buildPrdyCli(): Command {
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
status: doc.status,
|
||||
version: doc.version,
|
||||
projectPath: doc.projectPath,
|
||||
},
|
||||
null,
|
||||
@@ -65,7 +80,7 @@ export function buildPrdyCli(): Command {
|
||||
.description('List PRD documents for a project')
|
||||
.requiredOption('--project <path>', 'Project path')
|
||||
.action(async (options: ListCommandOptions) => {
|
||||
const docs = await listPrds(options.project);
|
||||
const docs = await serviceFor(options.project).list();
|
||||
console.log(JSON.stringify(docs, null, 2));
|
||||
});
|
||||
|
||||
@@ -75,20 +90,65 @@ export function buildPrdyCli(): Command {
|
||||
.requiredOption('--project <path>', 'Project path')
|
||||
.option('--id <id>', 'PRD document id')
|
||||
.action(async (options: ShowCommandOptions) => {
|
||||
if (options.id !== undefined) {
|
||||
const docs = await listPrds(options.project);
|
||||
const match = docs.find((doc) => doc.id === options.id);
|
||||
const doc = await serviceFor(options.project).get(options.id);
|
||||
console.log(JSON.stringify(doc, null, 2));
|
||||
});
|
||||
|
||||
if (match === undefined) {
|
||||
throw new Error(`PRD id not found: ${options.id}`);
|
||||
}
|
||||
prdy
|
||||
.command('import')
|
||||
.description('Import a YAML PRD document (validated; conflicts propose a successor)')
|
||||
.requiredOption('--project <path>', 'Project path')
|
||||
.requiredOption('--file <file>', 'Path to YAML PRD document')
|
||||
.option('--accept-successor', 'Accept a conflicted import as the next version')
|
||||
.action(async (options: ImportCommandOptions) => {
|
||||
const service = serviceFor(options.project);
|
||||
const input = { filePath: options.file };
|
||||
|
||||
console.log(JSON.stringify(match, null, 2));
|
||||
if (options.acceptSuccessor) {
|
||||
const successor = await service.acceptSuccessor(input);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
outcome: 'successor-accepted',
|
||||
id: successor.id,
|
||||
version: successor.version,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = await loadPrd(options.project);
|
||||
console.log(JSON.stringify(doc, null, 2));
|
||||
const result = await service.importDocument(input);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
outcome: result.kind,
|
||||
id: result.document.id,
|
||||
version: result.document.version,
|
||||
status: result.document.status,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
prdy
|
||||
.command('export')
|
||||
.description('Render a PRD to a labeled generated-view Markdown file')
|
||||
.requiredOption('--project <path>', 'Project path')
|
||||
.option('--id <id>', 'PRD document id')
|
||||
.option('--out <path>', 'Output path (default docs/prdy/<id>.md)')
|
||||
.action(async (options: ExportCommandOptions) => {
|
||||
const result = await serviceFor(options.project).exportMarkdown({
|
||||
id: options.id,
|
||||
outPath: options.out,
|
||||
});
|
||||
console.log(JSON.stringify({ ok: true, filePath: result.filePath }, null, 2));
|
||||
});
|
||||
|
||||
return program;
|
||||
|
||||
@@ -1,12 +1,35 @@
|
||||
export { createPrd, loadPrd, savePrd, listPrds } from './prd.js';
|
||||
// PrdService is the single authority surface for PRD documents. The raw store
|
||||
// writers (createPrd/savePrd) are deliberately NOT exported: every mutation
|
||||
// goes through the service so there is no second writer path.
|
||||
export { loadPrd, listPrds, parsePrdDocument } from './prd.js';
|
||||
export { runPrdWizard } from './wizard.js';
|
||||
export { buildPrdyCli, runPrdyCli } from './cli.js';
|
||||
export { BUILTIN_PRD_TEMPLATES, resolveTemplate } from './templates.js';
|
||||
export {
|
||||
PrdService,
|
||||
PRD_GENERATED_VIEW_LABEL,
|
||||
PrdError,
|
||||
PrdNotFoundError,
|
||||
PrdUpdateError,
|
||||
PrdImportInvalidError,
|
||||
PrdImportConflictError,
|
||||
} from './service.js';
|
||||
export type {
|
||||
PrdStatus,
|
||||
PrdTemplate,
|
||||
PrdTemplateSection,
|
||||
PrdSection,
|
||||
PrdMissionLinkage,
|
||||
PrdDocument,
|
||||
CreatePrdOptions,
|
||||
PrdServiceOptions,
|
||||
PrdCreateInput,
|
||||
PrdSectionPatch,
|
||||
PrdUpdateInput,
|
||||
PrdLinkMissionInput,
|
||||
PrdPlanForMissionInput,
|
||||
PrdExportInput,
|
||||
PrdExportResult,
|
||||
PrdImportInput,
|
||||
PrdImportResult,
|
||||
} from './types.js';
|
||||
|
||||
@@ -17,17 +17,49 @@ const prdSectionSchema = z.object({
|
||||
fields: z.record(z.string(), z.string()),
|
||||
});
|
||||
|
||||
const prdMissionLinkageSchema = z.object({
|
||||
missionId: z.string().min(1),
|
||||
missionVersion: z.string().min(1),
|
||||
prdVersion: z.number().int().min(1),
|
||||
requirementIds: z.array(z.string()),
|
||||
linkedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
const prdDocumentSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
status: z.enum(['draft', 'review', 'approved', 'archived']),
|
||||
projectPath: z.string().min(1),
|
||||
template: z.string().min(1),
|
||||
// Defaults keep documents written by older prdy versions loadable.
|
||||
version: z.number().int().min(1).default(1),
|
||||
sections: z.array(prdSectionSchema),
|
||||
missions: z.array(prdMissionLinkageSchema).default([]),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
/** YAML timestamp scalars are parsed as Date by some emitters — normalize to ISO strings. */
|
||||
function coerceTimestamps(value: unknown): unknown {
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(coerceTimestamps);
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entry]) => [key, coerceTimestamps(entry)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Validate an unknown value as a PRD document (throws zod errors on failure). */
|
||||
export function parsePrdDocument(value: unknown): PrdDocument {
|
||||
return prdDocumentSchema.parse(coerceTimestamps(value)) as PrdDocument;
|
||||
}
|
||||
|
||||
function expandHome(projectPath: string): string {
|
||||
if (!projectPath.startsWith('~')) {
|
||||
return projectPath;
|
||||
@@ -74,6 +106,8 @@ function prdDirectory(projectPath: string): string {
|
||||
return path.join(projectPath, PRD_DIRECTORY);
|
||||
}
|
||||
|
||||
export { prdDirectory };
|
||||
|
||||
function prdFilePath(projectPath: string, id: string): string {
|
||||
return path.join(prdDirectory(projectPath), `${id}.yaml`);
|
||||
}
|
||||
@@ -113,11 +147,13 @@ export async function createPrd(options: CreatePrdOptions): Promise<PrdDocument>
|
||||
status: 'draft',
|
||||
projectPath: resolvedProjectPath,
|
||||
template: template.id,
|
||||
version: 1,
|
||||
sections: template.sections.map((section) => ({
|
||||
id: section.id,
|
||||
title: section.title,
|
||||
fields: Object.fromEntries(section.fields.map((field) => [field, ''])),
|
||||
})),
|
||||
missions: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -190,7 +226,7 @@ export async function listPrds(projectPath: string): Promise<PrdDocument[]> {
|
||||
throw new Error(`Failed to parse PRD file ${filePath}: ${String(error)}`);
|
||||
}
|
||||
|
||||
const document = prdDocumentSchema.parse(parsed);
|
||||
const document = parsePrdDocument(parsed);
|
||||
documents.push(document);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import yaml from 'js-yaml';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
PRD_GENERATED_VIEW_LABEL,
|
||||
PrdImportConflictError,
|
||||
PrdImportInvalidError,
|
||||
PrdNotFoundError,
|
||||
PrdService,
|
||||
PrdUpdateError,
|
||||
} from './index.js';
|
||||
import type { PrdDocument } from './index.js';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let projectDir: string;
|
||||
|
||||
async function makeProject(): Promise<string> {
|
||||
return mkdtemp(path.join(os.tmpdir(), 'prdy-service-'));
|
||||
}
|
||||
|
||||
function service(): PrdService {
|
||||
return new PrdService({ projectPath: projectDir });
|
||||
}
|
||||
|
||||
function storeDir(): string {
|
||||
return path.join(projectDir, 'docs', 'prdy');
|
||||
}
|
||||
|
||||
/** Handcraft a full, schema-valid PRD document for import scenarios. */
|
||||
function importFixture(overrides: Partial<PrdDocument> = {}): PrdDocument {
|
||||
return {
|
||||
id: 'imported-prd-20260101-000000',
|
||||
title: 'Imported PRD',
|
||||
status: 'draft',
|
||||
projectPath: '/tmp/elsewhere',
|
||||
template: 'software',
|
||||
version: 1,
|
||||
sections: [
|
||||
{ id: 'introduction', title: 'Introduction', fields: { context: '', objective: '' } },
|
||||
{
|
||||
id: 'scope-non-goals',
|
||||
title: 'Scope / Non-Goals',
|
||||
fields: { inScope: '', outOfScope: '' },
|
||||
},
|
||||
],
|
||||
missions: [],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeImportFile(doc: PrdDocument): Promise<string> {
|
||||
const filePath = path.join(projectDir, `${doc.id}.import.yaml`);
|
||||
await writeFile(filePath, yaml.dump(doc), 'utf8');
|
||||
return filePath;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
projectDir = await makeProject();
|
||||
});
|
||||
|
||||
// ── Single authority store (AC: prdy path and mission path resolve to the
|
||||
// SAME store under docs/prdy/ with stable ids/versions) ────────────────────
|
||||
|
||||
describe('PrdService single authority store', () => {
|
||||
it('persists PRDs from the prdy path and the mission path into the same docs/prdy store', async () => {
|
||||
const direct = await service().create({ name: 'Direct PRD' });
|
||||
const viaMission = await service().planForMission({
|
||||
name: 'Mission PRD',
|
||||
missionId: 'mission-1',
|
||||
missionVersion: '2026-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
const files = await readdir(storeDir());
|
||||
expect(files).toContain(`${direct.id}.yaml`);
|
||||
expect(files).toContain(`${viaMission.id}.yaml`);
|
||||
|
||||
// A fresh service instance (new process equivalent) resolves both.
|
||||
const all = await service().list();
|
||||
expect(all.map((doc) => doc.id).sort()).toEqual([direct.id, viaMission.id].sort());
|
||||
|
||||
// Stable versions: creation is v1; linkage writes do not bump content version.
|
||||
expect((await service().get(direct.id)).version).toBe(1);
|
||||
expect((await service().get(viaMission.id)).version).toBe(1);
|
||||
});
|
||||
|
||||
it('round-trips documents through the store with identity intact', async () => {
|
||||
const created = await service().create({ name: 'Round Trip', template: 'feature' });
|
||||
const fresh = await service().get(created.id);
|
||||
|
||||
expect(fresh).toEqual(created);
|
||||
expect(fresh.id).toBe(created.id);
|
||||
expect(fresh.template).toBe('feature');
|
||||
expect(fresh.status).toBe('draft');
|
||||
});
|
||||
|
||||
it('throws a typed error for unknown ids and empty stores', async () => {
|
||||
await expect(service().get('nope')).rejects.toBeInstanceOf(PrdNotFoundError);
|
||||
await expect(service().get()).rejects.toBeInstanceOf(PrdNotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Mission linkage persistence (AC: linkage survives restart via fresh
|
||||
// service instances) ────────────────────────────────────────────────────────
|
||||
|
||||
describe('PrdService mission linkage', () => {
|
||||
it('persists linkage and reads it back from a fresh service instance', async () => {
|
||||
const created = await service().planForMission({
|
||||
name: 'Linked PRD',
|
||||
missionId: 'mission-42',
|
||||
missionVersion: '2026-02-03T04:05:06.000Z',
|
||||
requirementIds: ['FR-1', 'FR-2'],
|
||||
});
|
||||
|
||||
// Fresh instance — nothing in memory from the creating call.
|
||||
const links = await service().listMissionLinks(created.id);
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0]).toMatchObject({
|
||||
missionId: 'mission-42',
|
||||
missionVersion: '2026-02-03T04:05:06.000Z',
|
||||
prdVersion: 1,
|
||||
requirementIds: ['FR-1', 'FR-2'],
|
||||
});
|
||||
|
||||
// Linkage is carried in the YAML authority file itself.
|
||||
const raw = await readFile(path.join(storeDir(), `${created.id}.yaml`), 'utf8');
|
||||
const persisted = yaml.load(raw) as PrdDocument;
|
||||
expect(persisted.missions[0]?.missionId).toBe('mission-42');
|
||||
expect(persisted.missions[0]?.requirementIds).toEqual(['FR-1', 'FR-2']);
|
||||
});
|
||||
|
||||
it('refreshes an existing linkage entry in place instead of duplicating', async () => {
|
||||
const created = await service().planForMission({
|
||||
name: 'Relink PRD',
|
||||
missionId: 'mission-7',
|
||||
missionVersion: 'v1',
|
||||
});
|
||||
|
||||
await service().update({
|
||||
id: created.id,
|
||||
sections: [{ id: 'introduction', fields: { objective: 'Ship it' } }],
|
||||
});
|
||||
|
||||
const relinked = await service().linkMission({
|
||||
prdId: created.id,
|
||||
missionId: 'mission-7',
|
||||
missionVersion: 'v2',
|
||||
requirementIds: ['NFR-1'],
|
||||
});
|
||||
|
||||
expect(relinked.missions).toHaveLength(1);
|
||||
expect(relinked.missions[0]).toMatchObject({ missionVersion: 'v2', prdVersion: 2 });
|
||||
});
|
||||
|
||||
it('does not bump the content version when writing linkage', async () => {
|
||||
const created = await service().create({ name: 'Stable Version' });
|
||||
const linked = await service().linkMission({
|
||||
prdId: created.id,
|
||||
missionId: 'm',
|
||||
missionVersion: 'v1',
|
||||
});
|
||||
expect(linked.version).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Update semantics ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('PrdService update', () => {
|
||||
it('applies section patches and bumps the content version', async () => {
|
||||
const created = await service().create({ name: 'Updatable' });
|
||||
const updated = await service().update({
|
||||
id: created.id,
|
||||
sections: [{ id: 'introduction', fields: { context: 'Some context', objective: 'Goal' } }],
|
||||
});
|
||||
|
||||
expect(updated.version).toBe(2);
|
||||
expect(updated.sections[0]?.fields).toMatchObject({
|
||||
context: 'Some context',
|
||||
objective: 'Goal',
|
||||
});
|
||||
expect((await service().get(created.id)).version).toBe(2);
|
||||
});
|
||||
|
||||
it('refuses unknown section ids with a typed error', async () => {
|
||||
const created = await service().create({ name: 'Strict' });
|
||||
await expect(
|
||||
service().update({ id: created.id, sections: [{ id: 'nope', fields: {} }] }),
|
||||
).rejects.toBeInstanceOf(PrdUpdateError);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Markdown export is a labeled generated view, never authority ──────────────
|
||||
|
||||
describe('PrdService exportMarkdown', () => {
|
||||
it('writes a generated view carrying the label and source identity', async () => {
|
||||
const created = await service().create({ name: 'Exported PRD' });
|
||||
const result = await service().exportMarkdown({ id: created.id });
|
||||
|
||||
expect(result.filePath).toBe(path.join(storeDir(), `${created.id}.md`));
|
||||
expect(result.content).toContain(PRD_GENERATED_VIEW_LABEL);
|
||||
expect(result.content).toContain(`prd-id: ${created.id}`);
|
||||
expect(result.content).toContain('prd-version: 1');
|
||||
expect(result.content).toContain(`source-of-truth: docs/prdy/${created.id}.yaml`);
|
||||
});
|
||||
|
||||
it('reflects the current version after updates', async () => {
|
||||
const created = await service().create({ name: 'Versioned Export' });
|
||||
await service().update({
|
||||
id: created.id,
|
||||
sections: [{ id: 'introduction', fields: { objective: 'v2 goal' } }],
|
||||
});
|
||||
const result = await service().exportMarkdown({ id: created.id });
|
||||
expect(result.content).toContain('prd-version: 2');
|
||||
});
|
||||
|
||||
it('NEGATIVE CONTROL: mutating the exported Markdown cannot change the authority', async () => {
|
||||
const created = await service().create({ name: 'Guarded PRD' });
|
||||
const before = structuredClone(await service().get(created.id));
|
||||
|
||||
const result = await service().exportMarkdown({ id: created.id });
|
||||
await writeFile(
|
||||
result.filePath,
|
||||
`<!-- ${PRD_GENERATED_VIEW_LABEL} -->\n# FAKE\nprd-id: fake-id\nprd-version: 99\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const after = await service().get(created.id);
|
||||
expect(after).toEqual(before);
|
||||
expect(after.version).toBe(1);
|
||||
expect(after.title).toBe(before.title);
|
||||
});
|
||||
|
||||
it('never parses Markdown files that sit in the store directory', async () => {
|
||||
const created = await service().create({ name: 'Decoy Guard' });
|
||||
|
||||
// A decoy .md file with invalid YAML must be invisible to the store.
|
||||
await writeFile(path.join(storeDir(), 'decoy.md'), 'not: [valid: yaml', 'utf8');
|
||||
// And a decoy .yaml-named Markdown body must not silently validate either.
|
||||
await service().exportMarkdown({ id: created.id });
|
||||
|
||||
const listed = await service().list();
|
||||
expect(listed.map((doc) => doc.id)).toEqual([created.id]);
|
||||
await expect(service().get(created.id)).resolves.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Import: validated, conflict-aware, never silently merging ─────────────────
|
||||
|
||||
describe('PrdService importDocument', () => {
|
||||
it('creates a valid import through the service, as draft — validity is not approval', async () => {
|
||||
const filePath = await writeImportFile(importFixture({ status: 'approved' }));
|
||||
|
||||
const result = await service().importDocument({ filePath });
|
||||
|
||||
expect(result.kind).toBe('created');
|
||||
expect(result.document.id).toBe('imported-prd-20260101-000000');
|
||||
expect(result.document.status).toBe('draft'); // structural validity ≠ approval
|
||||
expect(result.document.version).toBe(1);
|
||||
|
||||
const persisted = await service().get('imported-prd-20260101-000000');
|
||||
expect(persisted.status).toBe('draft');
|
||||
|
||||
const files = await readdir(storeDir());
|
||||
expect(files).toContain('imported-prd-20260101-000000.yaml');
|
||||
});
|
||||
|
||||
it('reports identical content as a no-op without writing', async () => {
|
||||
const created = await service().create({ name: 'Existing PRD' });
|
||||
const before = await readFile(path.join(storeDir(), `${created.id}.yaml`), 'utf8');
|
||||
|
||||
const filePath = await writeImportFile(importFixture({ ...created }));
|
||||
const result = await service().importDocument({ filePath });
|
||||
|
||||
expect(result.kind).toBe('identical');
|
||||
const after = await readFile(path.join(storeDir(), `${created.id}.yaml`), 'utf8');
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it('refuses a conflicting import with a typed error, a proposed successor, and untouched bytes', async () => {
|
||||
const existing = await service().create({ name: 'Authority PRD' });
|
||||
await service().linkMission({
|
||||
prdId: existing.id,
|
||||
missionId: 'mission-keep',
|
||||
missionVersion: 'v1',
|
||||
requirementIds: ['FR-0'],
|
||||
});
|
||||
const beforeBytes = await readFile(path.join(storeDir(), `${existing.id}.yaml`), 'utf8');
|
||||
|
||||
const divergent = importFixture({
|
||||
...existing,
|
||||
title: 'Divergent Title',
|
||||
sections: [
|
||||
{
|
||||
id: 'introduction',
|
||||
title: 'Introduction',
|
||||
fields: { context: 'changed', objective: '' },
|
||||
},
|
||||
],
|
||||
});
|
||||
const filePath = await writeImportFile(divergent);
|
||||
|
||||
const attempt = service().importDocument({ filePath });
|
||||
let caught: unknown;
|
||||
try {
|
||||
await attempt;
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PrdImportConflictError);
|
||||
|
||||
const error = caught as PrdImportConflictError;
|
||||
expect(error.code).toBe('PRD_IMPORT_CONFLICT');
|
||||
expect(error.existing.id).toBe(existing.id);
|
||||
expect(error.proposal.version).toBe(existing.version + 1); // successor proposal
|
||||
expect(error.proposal.status).toBe('draft');
|
||||
|
||||
// Original authority content untouched on disk.
|
||||
const afterBytes = await readFile(path.join(storeDir(), `${existing.id}.yaml`), 'utf8');
|
||||
expect(afterBytes).toBe(beforeBytes);
|
||||
});
|
||||
|
||||
it('acceptSuccessor persists the proposal explicitly, carrying linkages forward', async () => {
|
||||
const existing = await service().create({ name: 'Successor Base' });
|
||||
await service().linkMission({
|
||||
prdId: existing.id,
|
||||
missionId: 'mission-keep',
|
||||
missionVersion: 'v1',
|
||||
});
|
||||
|
||||
const divergent = importFixture({
|
||||
...existing,
|
||||
title: 'Accepted Successor Title',
|
||||
});
|
||||
const filePath = await writeImportFile(divergent);
|
||||
|
||||
const successor = await service().acceptSuccessor({ filePath });
|
||||
expect(successor.id).toBe(existing.id);
|
||||
expect(successor.version).toBe(existing.version + 1);
|
||||
expect(successor.title).toBe('Accepted Successor Title');
|
||||
expect(successor.status).toBe('draft');
|
||||
expect(successor.missions.map((m) => m.missionId)).toEqual(['mission-keep']);
|
||||
|
||||
// Persisted for a fresh reader.
|
||||
const fresh = await service().get(existing.id);
|
||||
expect(fresh.version).toBe(2);
|
||||
expect(fresh.title).toBe('Accepted Successor Title');
|
||||
});
|
||||
|
||||
it('refuses structurally-invalid imports with a typed error and creates nothing', async () => {
|
||||
const cases: Array<{ name: string; body: string }> = [
|
||||
{ name: 'missing-title.yaml', body: yaml.dump({ id: 'x', status: 'draft' }) },
|
||||
{
|
||||
name: 'bad-status.yaml',
|
||||
body: yaml.dump(importFixture({ status: 'not-a-status' as PrdDocument['status'] })),
|
||||
},
|
||||
{
|
||||
name: 'bad-version.yaml',
|
||||
body: yaml.dump(importFixture({ version: 0 })),
|
||||
},
|
||||
{ name: 'not-yaml.yaml', body: '::: not yaml [\n - {' },
|
||||
];
|
||||
|
||||
for (const fixture of cases) {
|
||||
const filePath = path.join(projectDir, fixture.name);
|
||||
await writeFile(filePath, fixture.body, 'utf8');
|
||||
|
||||
await expect(service().importDocument({ filePath })).rejects.toBeInstanceOf(
|
||||
PrdImportInvalidError,
|
||||
);
|
||||
}
|
||||
|
||||
// Nothing was created: the authority store does not even exist yet.
|
||||
await expect(readdir(storeDir())).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
it('acceptSuccessor refuses when there is no existing document to succeed', async () => {
|
||||
const filePath = await writeImportFile(importFixture());
|
||||
await expect(service().acceptSuccessor({ filePath })).rejects.toBeInstanceOf(PrdNotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
// ── No second writer: no code path reads exported Markdown back into authority ─
|
||||
|
||||
describe('no-second-writer invariant (source-level)', () => {
|
||||
// Resolve the package source dir whether vitest runs from the package root
|
||||
// (turbo/pnpm test) or from the worktree root.
|
||||
function resolveSrcDir(): string {
|
||||
const candidates = [path.resolve('src'), path.resolve('packages/prdy/src')];
|
||||
return candidates.find((dir) => existsSync(path.join(dir, 'service.ts'))) ?? candidates[0]!;
|
||||
}
|
||||
|
||||
const srcDir = resolveSrcDir();
|
||||
const sourceFiles = [
|
||||
'cli.ts',
|
||||
'index.ts',
|
||||
'prd.ts',
|
||||
'service.ts',
|
||||
'templates.ts',
|
||||
'types.ts',
|
||||
'wizard.ts',
|
||||
];
|
||||
|
||||
it('no source file in @mosaicstack/prdy reads a .md file', async () => {
|
||||
for (const file of sourceFiles) {
|
||||
const text = await readFile(path.join(srcDir, file), 'utf8');
|
||||
const readLines = text
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => /readFile|readFileSync|createReadStream/.test(line));
|
||||
|
||||
for (const line of readLines) {
|
||||
expect(line.includes('.md'), `${file} reads a Markdown file: ${line}`).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('the mosaic prdy/mission adapters never read a .md file', async () => {
|
||||
const adapterDir = path.resolve(srcDir, '..', '..', 'mosaic', 'src', 'commands');
|
||||
for (const file of ['prdy.ts', 'mission.ts']) {
|
||||
const text = await readFile(path.join(adapterDir, file), 'utf8');
|
||||
expect(text.includes("'.md'") || text.includes('.md`'), `${file} references a .md path`).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
import { createPrd, listPrds, parsePrdDocument, prdDirectory, savePrd } from './prd.js';
|
||||
import type {
|
||||
PrdCreateInput,
|
||||
PrdDocument,
|
||||
PrdExportInput,
|
||||
PrdExportResult,
|
||||
PrdImportInput,
|
||||
PrdImportResult,
|
||||
PrdLinkMissionInput,
|
||||
PrdMissionLinkage,
|
||||
PrdPlanForMissionInput,
|
||||
PrdServiceOptions,
|
||||
PrdUpdateInput,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* PrdService is the SINGLE authority surface for PRD documents.
|
||||
*
|
||||
* Every mutation path (CLI wizard, `mosaic mission --plan`, import) routes
|
||||
* through this service; the YAML store under `docs/prdy/` is the authority and
|
||||
* exported Markdown is a generated view that no code path reads back.
|
||||
*/
|
||||
|
||||
// ── Typed errors ───────────────────────────────────────────────────────────────
|
||||
|
||||
export class PrdError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'PrdError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PrdNotFoundError extends PrdError {
|
||||
constructor(message: string) {
|
||||
super(message, 'PRD_NOT_FOUND');
|
||||
this.name = 'PrdNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class PrdUpdateError extends PrdError {
|
||||
constructor(message: string) {
|
||||
super(message, 'PRD_UPDATE_INVALID');
|
||||
this.name = 'PrdUpdateError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Structural refusal: the import payload failed schema validation. Nothing is written. */
|
||||
export class PrdImportInvalidError extends PrdError {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly issues?: string,
|
||||
) {
|
||||
super(message, 'PRD_IMPORT_INVALID');
|
||||
this.name = 'PrdImportInvalidError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Conflict refusal: an existing PRD shares the imported id but the content
|
||||
* diverges. Carries a PROPOSED successor (existing version + 1) that is only
|
||||
* persisted via an explicit {@link PrdService.acceptSuccessor} call — import
|
||||
* never overwrites and never merges.
|
||||
*/
|
||||
export class PrdImportConflictError extends PrdError {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly existing: PrdDocument,
|
||||
readonly proposal: PrdDocument,
|
||||
) {
|
||||
super(message, 'PRD_IMPORT_CONFLICT');
|
||||
this.name = 'PrdImportConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Service ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The generated-view label carried by every Markdown export. */
|
||||
export const PRD_GENERATED_VIEW_LABEL = 'generated view — do not edit';
|
||||
|
||||
export class PrdService {
|
||||
private readonly projectPath: string;
|
||||
|
||||
constructor(options: PrdServiceOptions) {
|
||||
this.projectPath = options.projectPath;
|
||||
}
|
||||
|
||||
/** Create a new PRD (version 1, draft) in the authority store. */
|
||||
async create(input: PrdCreateInput): Promise<PrdDocument> {
|
||||
return createPrd({
|
||||
name: input.name,
|
||||
projectPath: this.projectPath,
|
||||
template: input.template,
|
||||
interactive: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Read a PRD by id, or the most recently updated one. */
|
||||
async get(id?: string): Promise<PrdDocument> {
|
||||
const documents = await listPrds(this.projectPath);
|
||||
|
||||
if (id === undefined) {
|
||||
const latest = documents[0];
|
||||
if (latest === undefined) {
|
||||
throw new PrdNotFoundError(`No PRD documents found under docs/prdy/ for this project`);
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
const match = documents.find((doc) => doc.id === id);
|
||||
if (match === undefined) {
|
||||
throw new PrdNotFoundError(`PRD id not found: ${id}`);
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
/** List all PRDs in the authority store (most recently updated first). */
|
||||
async list(): Promise<PrdDocument[]> {
|
||||
return listPrds(this.projectPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply section field patches and bump the content version.
|
||||
* Linkage entries are preserved; linkage writes do NOT bump the version.
|
||||
*/
|
||||
async update(input: PrdUpdateInput): Promise<PrdDocument> {
|
||||
const doc = await this.get(input.id);
|
||||
|
||||
for (const patch of input.sections) {
|
||||
const section = doc.sections.find((candidate) => candidate.id === patch.id);
|
||||
if (section === undefined) {
|
||||
throw new PrdUpdateError(`Unknown section id: ${patch.id}`);
|
||||
}
|
||||
for (const [field, value] of Object.entries(patch.fields)) {
|
||||
if (!(field in section.fields)) {
|
||||
throw new PrdUpdateError(`Unknown field "${field}" on section "${patch.id}"`);
|
||||
}
|
||||
section.fields[field] = value;
|
||||
}
|
||||
}
|
||||
|
||||
doc.version += 1;
|
||||
doc.updatedAt = new Date().toISOString();
|
||||
await savePrd(doc);
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record (or refresh) a mission ↔ PRD linkage on the PRD document.
|
||||
* Persisted in the YAML authority, so it survives restarts.
|
||||
*/
|
||||
async linkMission(input: PrdLinkMissionInput): Promise<PrdDocument> {
|
||||
const doc = await this.get(input.prdId);
|
||||
return this.applyLinkage(doc, input);
|
||||
}
|
||||
|
||||
/** Read back the mission linkages recorded on a PRD. */
|
||||
async listMissionLinks(prdId?: string): Promise<PrdMissionLinkage[]> {
|
||||
const doc = await this.get(prdId);
|
||||
return doc.missions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mission planning path: create a PRD for a mission AND persist the
|
||||
* mission↔PRD linkage in a single authority write.
|
||||
*/
|
||||
async planForMission(input: PrdPlanForMissionInput): Promise<PrdDocument> {
|
||||
const doc = await this.create({ name: input.name, template: input.template });
|
||||
return this.applyLinkage(doc, {
|
||||
prdId: doc.id,
|
||||
missionId: input.missionId,
|
||||
missionVersion: input.missionVersion,
|
||||
requirementIds: input.requirementIds,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the PRD to a Markdown GENERATED VIEW.
|
||||
*
|
||||
* The output carries source identity (PRD id + version + generated-view
|
||||
* label). It is written under `docs/prdy/<id>.md` and is NEVER read back:
|
||||
* the authority store only loads `.yaml`/`.yml` files, and no code path in
|
||||
* this package parses the exported Markdown.
|
||||
*/
|
||||
async exportMarkdown(input?: PrdExportInput): Promise<PrdExportResult> {
|
||||
const doc = await this.get(input?.id);
|
||||
const content = renderMarkdown(doc);
|
||||
const filePath = input?.outPath ?? path.join(prdDirectory(doc.projectPath), `${doc.id}.md`);
|
||||
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, content, 'utf8');
|
||||
return { filePath, content };
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a YAML PRD document.
|
||||
*
|
||||
* Structural validation (zod) happens BEFORE anything is proposed or
|
||||
* written. A structurally-valid import is persisted as `draft` — validity is
|
||||
* NOT approval. If an existing PRD shares the id with divergent content, a
|
||||
* typed {@link PrdImportConflictError} is thrown carrying a proposed
|
||||
* successor; the original authority document is left byte-identical on disk.
|
||||
*/
|
||||
async importDocument(input: PrdImportInput): Promise<PrdImportResult> {
|
||||
const incoming = await this.readImportFile(input.filePath);
|
||||
|
||||
const existing = (await listPrds(this.projectPath)).find((doc) => doc.id === incoming.id);
|
||||
if (existing === undefined) {
|
||||
const document = this.buildImportedDocument(incoming);
|
||||
await savePrd(document);
|
||||
return { kind: 'created', document };
|
||||
}
|
||||
|
||||
if (canonicalCore(existing) === canonicalCore(incoming)) {
|
||||
return { kind: 'identical', document: existing };
|
||||
}
|
||||
|
||||
throw new PrdImportConflictError(
|
||||
`PRD id "${incoming.id}" already exists with divergent content — refusing to overwrite. ` +
|
||||
`Proposed successor: version ${existing.version + 1} (draft). ` +
|
||||
`Accept explicitly with acceptSuccessor().`,
|
||||
existing,
|
||||
this.buildSuccessor(existing, incoming),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly accept a conflicted import as a successor version of the
|
||||
* existing PRD. Re-validates the source file before writing; the successor
|
||||
* is persisted with status `draft` (acceptance of the import is not approval
|
||||
* of the PRD) and the existing mission linkages are carried forward.
|
||||
*/
|
||||
async acceptSuccessor(input: PrdImportInput): Promise<PrdDocument> {
|
||||
const incoming = await this.readImportFile(input.filePath);
|
||||
|
||||
const existing = (await listPrds(this.projectPath)).find((doc) => doc.id === incoming.id);
|
||||
if (existing === undefined) {
|
||||
throw new PrdNotFoundError(
|
||||
`No existing PRD with id "${incoming.id}" — use importDocument to create it`,
|
||||
);
|
||||
}
|
||||
|
||||
const successor = this.buildSuccessor(existing, incoming);
|
||||
await savePrd(successor);
|
||||
return successor;
|
||||
}
|
||||
|
||||
// ── internals ──────────────────────────────────────────────────────────────
|
||||
|
||||
private async applyLinkage(doc: PrdDocument, input: PrdLinkMissionInput): Promise<PrdDocument> {
|
||||
const entry: PrdMissionLinkage = {
|
||||
missionId: input.missionId,
|
||||
missionVersion: input.missionVersion,
|
||||
prdVersion: doc.version,
|
||||
requirementIds: input.requirementIds ?? [],
|
||||
linkedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// One entry per mission: refresh in place if the mission is already linked.
|
||||
const index = doc.missions.findIndex((m) => m.missionId === entry.missionId);
|
||||
if (index === -1) {
|
||||
doc.missions.push(entry);
|
||||
} else {
|
||||
doc.missions[index] = entry;
|
||||
}
|
||||
|
||||
// Linkage is mission-side metadata, not a content revision: bump the
|
||||
// timestamp only so ids/versions stay stable for consumers.
|
||||
doc.updatedAt = new Date().toISOString();
|
||||
await savePrd(doc);
|
||||
return doc;
|
||||
}
|
||||
|
||||
private async readImportFile(filePath: string): Promise<PrdDocument> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
throw new PrdImportInvalidError(`Cannot read import file ${filePath}: ${String(error)}`);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = yaml.load(raw);
|
||||
} catch (error) {
|
||||
throw new PrdImportInvalidError(`Import file is not valid YAML: ${String(error)}`);
|
||||
}
|
||||
|
||||
try {
|
||||
return parsePrdDocument(parsed);
|
||||
} catch (error) {
|
||||
throw new PrdImportInvalidError(
|
||||
`Import file failed PRD schema validation: ${filePath}`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private buildImportedDocument(incoming: PrdDocument): PrdDocument {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
...incoming,
|
||||
// The import lands in THIS project's authority store.
|
||||
projectPath: this.projectPath,
|
||||
// A structurally-valid import is not thereby approved.
|
||||
status: 'draft',
|
||||
version: 1,
|
||||
missions: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
private buildSuccessor(existing: PrdDocument, incoming: PrdDocument): PrdDocument {
|
||||
return {
|
||||
...incoming,
|
||||
id: existing.id,
|
||||
projectPath: existing.projectPath,
|
||||
status: 'draft',
|
||||
version: existing.version + 1,
|
||||
missions: existing.missions,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Markdown rendering (generated view) ───────────────────────────────────────
|
||||
|
||||
function canonicalCore(doc: PrdDocument): string {
|
||||
return JSON.stringify([doc.title, doc.template, doc.sections]);
|
||||
}
|
||||
|
||||
function renderMarkdown(doc: PrdDocument): string {
|
||||
const lines: string[] = [
|
||||
'<!--',
|
||||
`${PRD_GENERATED_VIEW_LABEL}`,
|
||||
`source-of-truth: docs/prdy/${doc.id}.yaml (YAML authority)`,
|
||||
`prd-id: ${doc.id}`,
|
||||
`prd-version: ${doc.version}`,
|
||||
`generated-at: ${new Date().toISOString()}`,
|
||||
'-->',
|
||||
'',
|
||||
`# ${doc.title}`,
|
||||
'',
|
||||
`**Status:** ${doc.status} · **Version:** ${doc.version} · **Template:** ${doc.template}`,
|
||||
'',
|
||||
];
|
||||
|
||||
if (doc.missions.length > 0) {
|
||||
lines.push('## Mission Linkage', '');
|
||||
for (const mission of doc.missions) {
|
||||
const requirements =
|
||||
mission.requirementIds.length > 0 ? mission.requirementIds.join(', ') : 'none selected';
|
||||
lines.push(
|
||||
`- mission \`${mission.missionId}\` @ version \`${mission.missionVersion}\`` +
|
||||
` (linked at PRD v${mission.prdVersion}) — requirements: ${requirements}`,
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
for (const section of doc.sections) {
|
||||
lines.push(`## ${section.title}`, '');
|
||||
for (const [field, value] of Object.entries(section.fields)) {
|
||||
lines.push(`### ${field}`, '', value.trim().length > 0 ? value : '_Not set_.', '');
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('---', '', `_End of generated view for ${doc.id} v${doc.version}._`, '');
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -19,13 +19,31 @@ export interface PrdSection {
|
||||
fields: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mission ↔ PRD linkage recorded on the PRD document (the YAML authority).
|
||||
*
|
||||
* `missionVersion` is the mission-side revision marker available to the CLI
|
||||
* (the gateway exposes `updatedAt` for missions — there is no numeric mission
|
||||
* version yet). `prdVersion` snapshots the PRD content version at link time.
|
||||
*/
|
||||
export interface PrdMissionLinkage {
|
||||
missionId: string;
|
||||
missionVersion: string;
|
||||
prdVersion: number;
|
||||
requirementIds: string[];
|
||||
linkedAt: string;
|
||||
}
|
||||
|
||||
export interface PrdDocument {
|
||||
id: string;
|
||||
title: string;
|
||||
status: PrdStatus;
|
||||
projectPath: string;
|
||||
template: string;
|
||||
/** Content revision counter. Bumped by updates and accepted imports. */
|
||||
version: number;
|
||||
sections: PrdSection[];
|
||||
missions: PrdMissionLinkage[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -36,3 +54,60 @@ export interface CreatePrdOptions {
|
||||
template?: string;
|
||||
interactive?: boolean;
|
||||
}
|
||||
|
||||
// ── PrdService surface (single authority entry point) ─────────────────────────
|
||||
|
||||
export interface PrdServiceOptions {
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
export interface PrdCreateInput {
|
||||
name: string;
|
||||
template?: string;
|
||||
}
|
||||
|
||||
export interface PrdSectionPatch {
|
||||
id: string;
|
||||
fields: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface PrdUpdateInput {
|
||||
/** Defaults to the most recently updated PRD. */
|
||||
id?: string;
|
||||
sections: PrdSectionPatch[];
|
||||
}
|
||||
|
||||
export interface PrdLinkMissionInput {
|
||||
/** Defaults to the most recently updated PRD. */
|
||||
prdId?: string;
|
||||
missionId: string;
|
||||
missionVersion: string;
|
||||
requirementIds?: string[];
|
||||
}
|
||||
|
||||
export interface PrdPlanForMissionInput extends PrdLinkMissionInput {
|
||||
name: string;
|
||||
template?: string;
|
||||
}
|
||||
|
||||
export interface PrdExportInput {
|
||||
/** Defaults to the most recently updated PRD. */
|
||||
id?: string;
|
||||
/** Override the generated-view output path. */
|
||||
outPath?: string;
|
||||
}
|
||||
|
||||
export interface PrdExportResult {
|
||||
filePath: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** Discriminated result of a non-conflicting import. */
|
||||
export type PrdImportResult =
|
||||
| { kind: 'created'; document: PrdDocument }
|
||||
| { kind: 'identical'; document: PrdDocument };
|
||||
|
||||
export interface PrdImportInput {
|
||||
/** Path to a YAML-serialized PRD document (NOT the generated Markdown view). */
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
+43
-32
@@ -2,8 +2,8 @@ import path from 'node:path';
|
||||
|
||||
import { cancel, intro, isCancel, outro, select, text } from '@clack/prompts';
|
||||
|
||||
import { createPrd, savePrd } from './prd.js';
|
||||
import type { CreatePrdOptions, PrdDocument } from './types.js';
|
||||
import { PrdService } from './service.js';
|
||||
import type { CreatePrdOptions, PrdDocument, PrdSectionPatch } from './types.js';
|
||||
|
||||
interface WizardAnswers {
|
||||
goals: string;
|
||||
@@ -11,20 +11,41 @@ interface WizardAnswers {
|
||||
milestones: string;
|
||||
}
|
||||
|
||||
function updateSectionField(doc: PrdDocument, sectionKeyword: string, value: string): void {
|
||||
const section = doc.sections.find((candidate) => candidate.id.includes(sectionKeyword));
|
||||
/**
|
||||
* Translate wizard answers into section patches using the same keyword
|
||||
* matching the wizard always used (first section whose id contains the
|
||||
* keyword, then first field whose name contains it, else first field).
|
||||
*/
|
||||
function buildWizardPatches(doc: PrdDocument, answers: WizardAnswers): PrdSectionPatch[] {
|
||||
const bySection = new Map<string, PrdSectionPatch>();
|
||||
|
||||
if (section === undefined) {
|
||||
return;
|
||||
}
|
||||
const add = (keyword: string, value: string): void => {
|
||||
const section = doc.sections.find((candidate) => candidate.id.includes(keyword));
|
||||
if (section === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldName =
|
||||
Object.keys(section.fields).find((field) => field.toLowerCase().includes(sectionKeyword)) ??
|
||||
Object.keys(section.fields)[0];
|
||||
const fieldName =
|
||||
Object.keys(section.fields).find((field) => field.toLowerCase().includes(keyword)) ??
|
||||
Object.keys(section.fields)[0];
|
||||
|
||||
if (fieldName !== undefined) {
|
||||
section.fields[fieldName] = value;
|
||||
}
|
||||
if (fieldName === undefined || section.fields[fieldName] === value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = bySection.get(section.id);
|
||||
if (existing === undefined) {
|
||||
bySection.set(section.id, { id: section.id, fields: { [fieldName]: value } });
|
||||
} else {
|
||||
existing.fields[fieldName] = value;
|
||||
}
|
||||
};
|
||||
|
||||
add('goal', answers.goals);
|
||||
add('constraint', answers.constraints);
|
||||
add('milestone', answers.milestones);
|
||||
|
||||
return [...bySection.values()];
|
||||
}
|
||||
|
||||
async function promptText(message: string, initialValue = ''): Promise<string> {
|
||||
@@ -63,15 +84,10 @@ async function promptTemplate(template?: string): Promise<string> {
|
||||
return choice;
|
||||
}
|
||||
|
||||
function applyWizardAnswers(doc: PrdDocument, answers: WizardAnswers): PrdDocument {
|
||||
updateSectionField(doc, 'goal', answers.goals);
|
||||
updateSectionField(doc, 'constraint', answers.constraints);
|
||||
updateSectionField(doc, 'milestone', answers.milestones);
|
||||
|
||||
doc.updatedAt = new Date().toISOString();
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive PRD wizard. All writes go through PrdService — the wizard is a
|
||||
* prompt layer, never a second writer path.
|
||||
*/
|
||||
export async function runPrdWizard(options: CreatePrdOptions): Promise<PrdDocument> {
|
||||
intro('Mosaic PRD wizard');
|
||||
|
||||
@@ -82,20 +98,15 @@ export async function runPrdWizard(options: CreatePrdOptions): Promise<PrdDocume
|
||||
const constraints = await promptText('Key constraints');
|
||||
const milestones = await promptText('Planned milestones');
|
||||
|
||||
const doc = await createPrd({
|
||||
...options,
|
||||
const service = new PrdService({ projectPath: options.projectPath });
|
||||
const doc = await service.create({
|
||||
name,
|
||||
template,
|
||||
interactive: true,
|
||||
});
|
||||
|
||||
const updated = applyWizardAnswers(doc, {
|
||||
goals,
|
||||
constraints,
|
||||
milestones,
|
||||
});
|
||||
|
||||
await savePrd(updated);
|
||||
const patches = buildWizardPatches(doc, { goals, constraints, milestones });
|
||||
const updated =
|
||||
patches.length > 0 ? await service.update({ id: doc.id, sections: patches }) : doc;
|
||||
|
||||
outro(`PRD created: ${path.join(updated.projectPath, 'docs', 'prdy', `${updated.id}.yaml`)}`);
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Scratchpad — RI-4-001 One transitional PRD authority (RI-N3, #1275)
|
||||
|
||||
- Objective: single PrdService authority in `@mosaicstack/prdy`; `mosaic prdy` and
|
||||
`mission --plan` become thin adapters; mission↔PRD linkage persisted on disk;
|
||||
Markdown export is a labeled generated view (never read back); import is
|
||||
validated/conflict-aware with typed refusals.
|
||||
- Budget: ~35K tokens (card cap). Baselines: prdy build/lint rc=0, 0 tests;
|
||||
mosaic build rc=0 (after root turbo build), lint rc=0, 1548 tests pass;
|
||||
root build rc=0.
|
||||
- Plan: (1) extend store schema (version, missions linkage) (2) PrdService +
|
||||
typed errors (3) wizard/cli route through service (4) mosaic adapters
|
||||
(5) contract specs both packages (6) gates (7) sabotage control (8) report
|
||||
to /var/tmp/ri-050/ri-4-001-report.md.
|
||||
- Decisions:
|
||||
- Linkage lives ON the PRD document (`missions` array) — one authority file,
|
||||
survives restart, no sidecar sync problems.
|
||||
- `version` = content revision of sections/status (bumped by update/import
|
||||
accept). Linkage writes bump `updatedAt` only, so ids/versions stay stable
|
||||
for the card's "stable ids/versions" contract.
|
||||
- Mission version marker = `mission.updatedAt` (gateway MissionInfo has no
|
||||
numeric version field).
|
||||
- Import reads YAML documents only — never the exported Markdown (keeps the
|
||||
"no code path reads exported Markdown" invariant).
|
||||
- Import of an existing id with identical core content → `identical` no-op;
|
||||
divergent → typed `PrdImportConflictError` carrying proposed successor
|
||||
(existing.version + 1, status draft, linkages preserved). Original bytes
|
||||
untouched until explicit `acceptSuccessor`.
|
||||
- `requirementIds` default `[]` at the mission command (no requirement
|
||||
selection UI yet) — service accepts ids when a caller has them.
|
||||
- Progress log:
|
||||
- [16:35] baselines captured (prdy 0 tests; mosaic 1548 after root build; root build rc=0)
|
||||
- [16:38] store schema v2 + PrdService + wizard/cli rerouted; prdy build/lint green
|
||||
- [16:40] mosaic adapters done; prdy spec 20/20 (found+fixed: import project-path leak, empty-store typed error, YAML timestamp coercion)
|
||||
- [16:44] mosaic specs 9/9 (fixed commander from:'user' argv, vi.mock hoisting, restoreAllMocks wiping factory mocks)
|
||||
- [16:45] all gates green; 4 commits (e291bfb, 2c5d208, a23826c, 540d6f1)
|
||||
- [16:46] sabotage: linkage write removed → prdy 3 fail / mosaic 2 fail, 1548/1548 pre-existing pass; restored byte-identically; re-green 20/20 + 1557/1557
|
||||
- [16:47] report written to /var/tmp/ri-050/ri-4-001-report.md — card complete
|
||||
Reference in New Issue
Block a user