Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
133c3b67f7 |
@@ -22,9 +22,9 @@ steps:
|
|||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: REGISTRY_USERNAME
|
from_secret: gitea_username
|
||||||
REGISTRY_PASS:
|
REGISTRY_PASS:
|
||||||
from_secret: REGISTRY_PASSWORD
|
from_secret: gitea_password
|
||||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||||
|
|||||||
+1
-24
@@ -30,19 +30,6 @@ steps:
|
|||||||
# the baked pnpm store.
|
# the baked pnpm store.
|
||||||
- pnpm install --frozen-lockfile --prefer-offline
|
- pnpm install --frozen-lockfile --prefer-offline
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# The steps below (sanitization, upgrade-guard, typecheck, lint, format,
|
|
||||||
# test) are the COMPLETE mandatory verification set. SDLC-D-034 mirrors them
|
|
||||||
# one-for-one in the canonical terminal verification command — root
|
|
||||||
# `pnpm verify:release` (scripts/verify-release.mjs) — which the publish
|
|
||||||
# pipeline (.woodpecker/publish.yml `verify` step) runs before ANY publish
|
|
||||||
# effect. These lines stay direct (not routed through the runner) because the
|
|
||||||
# #1017 test-enumeration guard audits framework tool paths through THIS
|
|
||||||
# surface; scripts/verify-release.test.mjs enforces that the runner's stage
|
|
||||||
# table keeps matching these commands exactly, so the two cannot drift.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# Canonical verify:release stage `sanitization`.
|
|
||||||
# Blocking gate: public framework package must contain no operator-specific
|
# Blocking gate: public framework package must contain no operator-specific
|
||||||
# personal data or private $HOME defaults. Runs early (no node_modules needed).
|
# personal data or private $HOME defaults. Runs early (no node_modules needed).
|
||||||
sanitization:
|
sanitization:
|
||||||
@@ -60,7 +47,6 @@ steps:
|
|||||||
# with everything it guards; this direct line keeps one instrument running.
|
# with everything it guards; this direct line keeps one instrument running.
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
|
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
|
||||||
|
|
||||||
# Canonical verify:release stage `upgrade-guard`.
|
|
||||||
# Blocking gate (#791): a framework upgrade must never write or delete an
|
# Blocking gate (#791): a framework upgrade must never write or delete an
|
||||||
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
||||||
# survives a keep-mode reseed byte-identical (with rsync present AND absent —
|
# survives a keep-mode reseed byte-identical (with rsync present AND absent —
|
||||||
@@ -82,8 +68,6 @@ steps:
|
|||||||
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh
|
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
|
- bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
|
||||||
|
|
||||||
# Canonical verify:release stage `typecheck` — the same `pnpm typecheck`
|
|
||||||
# invocation (which runs the checkout preflight first, then turbo).
|
|
||||||
typecheck:
|
typecheck:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
commands:
|
commands:
|
||||||
@@ -94,8 +78,7 @@ steps:
|
|||||||
- sanitization
|
- sanitization
|
||||||
- upgrade-guard
|
- upgrade-guard
|
||||||
|
|
||||||
# lint, format, and test are independent — run in parallel after typecheck.
|
# lint, format, and test are independent — run in parallel after typecheck
|
||||||
# Each runs exactly its canonical verify:release stage command.
|
|
||||||
lint:
|
lint:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
commands:
|
commands:
|
||||||
@@ -112,12 +95,6 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- typecheck
|
- typecheck
|
||||||
|
|
||||||
# Canonical verify:release stage `test` — the `pnpm test` line below is the
|
|
||||||
# shared command; everything else in this step is PIPELINE-LEVEL
|
|
||||||
# prerequisite the canonical command expects its caller to provide (SDLC-D-034):
|
|
||||||
# the ci-postgres service + pg_isready wait + db:migrate (postgres path),
|
|
||||||
# `apk add openssl`, and the pinned pi install. None of those can move into
|
|
||||||
# the runner (it must also work locally on the PGlite path with no database).
|
|
||||||
test:
|
test:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
+6
-66
@@ -1,19 +1,5 @@
|
|||||||
# Build, publish npm packages, and push Docker images
|
# Build, publish npm packages, and push Docker images
|
||||||
# Runs on main for stable publishes and on next for integration-line prereleases/images
|
# Runs on main for stable publishes and on next for integration-line prereleases/images
|
||||||
#
|
|
||||||
# SDLC-D-034 publish gate: every publish effect (publish-npm, publish-next-npm,
|
|
||||||
# and every image build/push step) depends DIRECTLY on the `verify` step below.
|
|
||||||
# `verify` (a) asserts the provider's commit identity matches the actual
|
|
||||||
# checkout (CI_COMMIT_SHA == git rev-parse HEAD, fail closed on mismatch or
|
|
||||||
# emptiness) and (b) runs the canonical terminal verification command
|
|
||||||
# (`pnpm verify:release`), which mirrors the PR CI pipeline's complete
|
|
||||||
# mandatory set (sanitization, upgrade-guard, preflight+typecheck, lint,
|
|
||||||
# format:check, test, build) — see scripts/verify-release.mjs. A missing,
|
|
||||||
# failed, skipped, cancelled, or inconclusive verification therefore skips the
|
|
||||||
# dependent publish effects (fail closed). Path-filtered short-circuits may
|
|
||||||
# skip publish EFFECTS (e.g. docs-only merges) but never bypass `verify` for a
|
|
||||||
# publish that does run: `verify` itself carries no path filter.
|
|
||||||
# scripts/verify-release.test.mjs enforces this DAG invariant at checkout time.
|
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
||||||
@@ -62,45 +48,6 @@ steps:
|
|||||||
# Resolve from the baked pnpm store instead of a cold network fetch.
|
# Resolve from the baked pnpm store instead of a cold network fetch.
|
||||||
- pnpm install --frozen-lockfile --prefer-offline
|
- pnpm install --frozen-lockfile --prefer-offline
|
||||||
|
|
||||||
# SDLC-D-034 exact-commit publish gate. No `when`/path filter on purpose: it
|
|
||||||
# runs for every event this pipeline serves so no publish effect can ever
|
|
||||||
# start without it. Fails closed on commit-identity mismatch (or either SHA
|
|
||||||
# being empty) and on any incomplete verification.
|
|
||||||
verify:
|
|
||||||
image: *node_image
|
|
||||||
commands:
|
|
||||||
- *enable_pnpm
|
|
||||||
# (a) Commit identity: the provider's claimed SHA must equal the actual
|
|
||||||
# checkout HEAD — verification of anything else must never authorize a
|
|
||||||
# publish of this commit.
|
|
||||||
- |
|
|
||||||
if [ -z "$CI_COMMIT_SHA" ]; then
|
|
||||||
echo "[verify] FATAL: CI_COMMIT_SHA is empty — cannot certify commit identity" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
CHECKOUT_SHA="$(git rev-parse HEAD 2>/dev/null || true)"
|
|
||||||
if [ -z "$CHECKOUT_SHA" ]; then
|
|
||||||
echo "[verify] FATAL: git rev-parse HEAD returned nothing — cannot certify commit identity" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ "$CI_COMMIT_SHA" != "$CHECKOUT_SHA" ]; then
|
|
||||||
echo "[verify] FATAL: provider commit ($CI_COMMIT_SHA) != checkout HEAD ($CHECKOUT_SHA)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "[verify] commit identity confirmed: $CHECKOUT_SHA"
|
|
||||||
# (b) Canonical terminal verification. Caller-provided prerequisites the
|
|
||||||
# runner expects (see .woodpecker/ci.yml comments): bash/rsync for the
|
|
||||||
# guard stages, openssl + the pinned pi binary for the test stage. git is
|
|
||||||
# baked into ci-base but re-asserted here so the identity check above can
|
|
||||||
# never silently depend on a stale baked image. DATABASE_URL is
|
|
||||||
# deliberately NOT set: the canonical command must hold on the PGlite
|
|
||||||
# path too and never sets or requires a database itself.
|
|
||||||
- apk add --no-cache bash rsync openssl git
|
|
||||||
- npm install -g @earendil-works/[email protected]
|
|
||||||
- pnpm verify:release
|
|
||||||
depends_on:
|
|
||||||
- install
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
commands:
|
commands:
|
||||||
@@ -108,7 +55,6 @@ steps:
|
|||||||
- pnpm build
|
- pnpm build
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
- verify
|
|
||||||
|
|
||||||
publish-npm:
|
publish-npm:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
@@ -168,7 +114,6 @@ steps:
|
|||||||
exit 1
|
exit 1
|
||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
- verify
|
|
||||||
|
|
||||||
publish-next-npm:
|
publish-next-npm:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
@@ -247,7 +192,6 @@ steps:
|
|||||||
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
|
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
|
||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
- verify
|
|
||||||
|
|
||||||
# TODO: Uncomment when ready to publish to npmjs.org
|
# TODO: Uncomment when ready to publish to npmjs.org
|
||||||
# publish-npmjs:
|
# publish-npmjs:
|
||||||
@@ -261,7 +205,6 @@ steps:
|
|||||||
# - bash scripts/publish-npmjs.sh
|
# - bash scripts/publish-npmjs.sh
|
||||||
# depends_on:
|
# depends_on:
|
||||||
# - build
|
# - build
|
||||||
# - verify
|
|
||||||
# when:
|
# when:
|
||||||
# - event: [tag]
|
# - event: [tag]
|
||||||
|
|
||||||
@@ -270,9 +213,9 @@ steps:
|
|||||||
when: *image_build_when
|
when: *image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: REGISTRY_USERNAME
|
from_secret: gitea_username
|
||||||
REGISTRY_PASS:
|
REGISTRY_PASS:
|
||||||
from_secret: REGISTRY_PASSWORD
|
from_secret: gitea_password
|
||||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||||
@@ -299,16 +242,15 @@ steps:
|
|||||||
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
|
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
|
||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
- verify
|
|
||||||
|
|
||||||
build-appservice:
|
build-appservice:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *main_image_build_when
|
when: *main_image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: REGISTRY_USERNAME
|
from_secret: gitea_username
|
||||||
REGISTRY_PASS:
|
REGISTRY_PASS:
|
||||||
from_secret: REGISTRY_PASSWORD
|
from_secret: gitea_password
|
||||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||||
@@ -326,16 +268,15 @@ steps:
|
|||||||
/kaniko/executor --context . --dockerfile docker/appservice.Dockerfile $DESTINATIONS
|
/kaniko/executor --context . --dockerfile docker/appservice.Dockerfile $DESTINATIONS
|
||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
- verify
|
|
||||||
|
|
||||||
build-web:
|
build-web:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *main_image_build_when
|
when: *main_image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: REGISTRY_USERNAME
|
from_secret: gitea_username
|
||||||
REGISTRY_PASS:
|
REGISTRY_PASS:
|
||||||
from_secret: REGISTRY_PASSWORD
|
from_secret: gitea_password
|
||||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||||
@@ -353,4 +294,3 @@ steps:
|
|||||||
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
|
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
|
||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
- verify
|
|
||||||
|
|||||||
@@ -138,9 +138,9 @@ mosaic brain tasks
|
|||||||
mosaic brain conversations
|
mosaic brain conversations
|
||||||
|
|
||||||
# Agent forge pipeline
|
# Agent forge pipeline
|
||||||
mosaic forge run [--simulate] # fails closed (FORGE_NO_EXECUTOR) with no executor wired; --simulate for typed simulated runs
|
mosaic forge run
|
||||||
mosaic forge status
|
mosaic forge status
|
||||||
mosaic forge resume [--simulate] # same fail-closed rule as forge run
|
mosaic forge resume
|
||||||
mosaic forge personas
|
mosaic forge personas
|
||||||
|
|
||||||
# Structured logging
|
# Structured logging
|
||||||
|
|||||||
@@ -190,13 +190,7 @@ beforeEach((ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
// Cleanup only when the fixture actually installed rows. `handle` is set
|
if (!handle) return;
|
||||||
// before the first query (createDb connects lazily), so on an unreachable
|
|
||||||
// database `handle` is truthy while nothing was inserted — cleanup must
|
|
||||||
// honor `dbAvailable` or the skip path fails the file with ECONNREFUSED in
|
|
||||||
// afterAll (caught live by the publish pipeline's no-DATABASE_URL verify
|
|
||||||
// step, pipeline 2486).
|
|
||||||
if (!handle || !dbAvailable) return;
|
|
||||||
const db = handle.db;
|
const db = handle.db;
|
||||||
|
|
||||||
// Delete in dependency order (FK constraints)
|
// Delete in dependency order (FK constraints)
|
||||||
|
|||||||
+14
-47
@@ -115,18 +115,19 @@ gateway-backed agent catalog.
|
|||||||
|
|
||||||
### Normative requirements
|
### Normative requirements
|
||||||
|
|
||||||
| ID | Requirement |
|
| 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-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-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-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-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-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-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-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-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-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-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. |
|
||||||
|
|
||||||
### Acceptance criteria
|
### Acceptance criteria
|
||||||
|
|
||||||
@@ -138,6 +139,7 @@ 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.
|
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.
|
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.
|
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
|
### M0 implementation gate
|
||||||
|
|
||||||
@@ -1368,38 +1370,3 @@ All work is **alpha** (< 0.1.0) until Jason approves 0.1.0 beta release.
|
|||||||
10. ASSUMPTION: **Conversations and messages get their own PG tables** (not stored in brain's entity model). They follow a chat-specific schema with proper foreign keys to users and projects. Rationale: Chat has different access patterns (streaming, pagination, search) than brain entities.
|
10. ASSUMPTION: **Conversations and messages get their own PG tables** (not stored in brain's entity model). They follow a chat-specific schema with proper foreign keys to users and projects. Rationale: Chat has different access patterns (streaming, pagination, search) than brain entities.
|
||||||
|
|
||||||
11. RESOLVED: **Pi handles all target LLM providers natively.** Anthropic, OpenAI/Codex, Z.ai, Ollama, LM Studio, and llama.cpp are all supported via Pi's built-in providers or `models.json` configuration with `openai-completions` API type. No custom provider adapters needed in @mosaicstack/agent — only configuration management.
|
11. RESOLVED: **Pi handles all target LLM providers natively.** Anthropic, OpenAI/Codex, Z.ai, Ollama, LM Studio, and llama.cpp are all supported via Pi's built-in providers or `models.json` configuration with `openai-completions` API type. No custom provider adapters needed in @mosaicstack/agent — only configuration management.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Release Integrity Workstream (RI, #1275)
|
|
||||||
|
|
||||||
### Problem and objective
|
|
||||||
|
|
||||||
At `next` 476db12b (review of 2026-08-17), publication from `next` is not bound to the full verification pipeline for the same commit: the publish pipeline's publish steps depend on `build` only, while ordinary push CI excludes `next`. Public Forge/MACP paths contain false-success placeholders: a stub executor that reports `completed` with exit zero, planning/remediation gates that execute literal `true`, a review gate that echoes an approving verdict, and a gate runner that treats empty commands and unimplemented CI-provider gates as passing. Shipping UI surfaces can render a failed fetch as an empty, healthy collection.
|
|
||||||
|
|
||||||
Objective: for alpha 0.0.50, the release cannot publish, report, or display work state that the repository has not actually verified. Decisions SDLC-D-033 through SDLC-D-038 (Jason, 2026-08-17) scope this floor; full decision text and required-behavior lists live in jarvis-brain `docs/plans/2026-08-16_mosaic-stack-sdlc-protocol.md` and `data/decisions/mosaic-stack-sdlc-protocol.json`. This section restates only the normative requirements.
|
|
||||||
|
|
||||||
### Normative requirements
|
|
||||||
|
|
||||||
1. **RI-N1 Exact-commit publication verification (SDLC-D-034).** One canonical terminal verification command performs self-contained re-verification in the publish pipeline against the job's checked-out commit before any external publication effect. The command contains or invokes the complete mandatory verification set (semantic parity with the PR merge gate, including sanitization, upgrade-guard, typecheck, lint, format check, tests, and build); CI and publication do not maintain separate semantic checklists. Every publish step depends on the verification step in the executable pipeline DAG. Provider commit identity and `git rev-parse HEAD` must identify the same commit. Missing, skipped, cancelled, stale, or inconclusive checks fail closed. Documentation-only runs may skip publication but cannot bypass verification when a publication effect will occur. A negative control must prove that a broken check blocks every publish step.
|
|
||||||
|
|
||||||
2. **RI-N2 Fail-closed Forge/MACP with explicit simulation (SDLC-D-035).** Simulation requires explicit caller intent (e.g. `--simulate`) and produces a distinct typed `simulated` state that can never satisfy dependencies, acceptance criteria, gates, merge, or release. Normal execution exits nonzero with a typed capability failure when a required executor, reviewer, command, or CI provider is absent — no stub completion, no literal-`true` gates, no synthetic approvals, no empty-command passes. A manual gate with no automation enters a waiting state; it does not pass. Positive tests prove explicit simulation still works; negative controls prove simulation and every missing-provider case cannot advance lifecycle state.
|
|
||||||
|
|
||||||
3. **RI-N3 One transitional PRD authority (SDLC-D-036).** `@mosaicstack/prdy` structured storage under `docs/prdy/`, driven by `mosaic mission --plan`, is the authoritative PRD representation for the alpha. `mosaic prdy` either routes through the same application service or operates only as an explicit, named Markdown import/export adapter; `docs/PRD.md` is not a peer authority. `mission --plan` must persist the mission↔PRD linkage (mission id/version, PRD id/version, selected requirements). Markdown output is a generated view carrying source identity; editing it cannot mutate authority silently. Import is explicit, validated, and conflict-aware (proposed successor, never overwrite). Structural validity is separate from approval.
|
|
||||||
|
|
||||||
4. **RI-N4 One quality-rails evaluator (SDLC-D-037).** The TypeScript quality-rails package is the sole authoritative evaluator. A complete probe inventory maps every current TypeScript and shell check to one canonical check with disposition (preserve/strengthen/retire, each named). Effective shell enforcement probes are absorbed before their independent paths retire; expected-file presence alone is not parity. The evaluator returns typed results (`passed`/`failed`/`blocked`/`error`/`not-applicable`) with check version, subject, and reason; missing implementation, missing input, unknown check, process error, timeout, or malformed output can never become `passed` or an unqualified skip. Check definitions and policy are versioned and digested. Shell commands become thin adapters with no separate verdict logic. The canonical terminal verification command (RI-N1) invokes this evaluator rather than duplicating its logic. Contract, parity, and negative-control tests are required, plus independent review of probe equivalence.
|
|
||||||
|
|
||||||
5. **RI-N5 Consequence-aware stale UI (SDLC-D-038).** Mission Control distinguishes typed freshness states (`current`, `stale`, `partial`, `unknown`, `unavailable`) rather than inferring from empty arrays or null. A failed fetch never renders as an empty healthy collection. Last-known data may display for situational awareness only with source identity, version, and age visibly labeled; any derived completion/assurance/release verdict whose inputs are stale becomes `unknown`; all state-changing actions are disabled until fresh state loads and is revalidated. With no verified snapshot, surfaces show an explicit unavailable state. Cache corruption, cross-workspace data, schema mismatch, and version regression invalidate the snapshot. Tests cover the failure matrix (network, auth, malformed, partial, corruption, stale age, schema mismatch, recovery, stale-action rejection) with negative controls proving no case yields a current green verdict or enabled mutation.
|
|
||||||
|
|
||||||
### Acceptance criteria
|
|
||||||
|
|
||||||
- AC-RI-1: A push to `next` that fails any mandatory verification step publishes nothing (no npm package, no image), demonstrated by a checked-in negative control and by pipeline evidence on a real `next` publish run where the verification step is green and every publish step depends on it.
|
|
||||||
- AC-RI-2: With no executor/reviewer/CI provider wired, Forge and MACP normal runs exit nonzero with typed capability failures; with `--simulate`, runs complete but every result is typed `simulated` and cannot satisfy any gate, dependency, or completion state — proven by unit tests including negative controls.
|
|
||||||
- AC-RI-3: A PRD created or revised through either `mosaic mission --plan` or `mosaic prdy` resolves to one authority under `docs/prdy/` with stable identities and versions; the mission↔PRD linkage survives restart; a Markdown export is labeled as generated and cannot silently become a second writer; divergent legacy content blocks baseline claims until explicitly resolved — proven by contract tests.
|
|
||||||
- AC-RI-4: `quality-rails check` through any entry point (TS CLI, framework shell adapter) returns the same typed verdict for the same subject; the probe inventory names every legacy check's disposition; a deliberately broken probe fails closed — proven by contract/parity/negative-control tests and independent review of probe equivalence.
|
|
||||||
- AC-RI-5: No shipping surface renders a failed fetch as an empty healthy state; stale/partial/unavailable states are typed, labeled, and mutation-disabled — proven by the failure-matrix tests.
|
|
||||||
- AC-RI-6: All cards merged to `next` via squash PR with terminal-green CI; release evidence for 0.0.50 records commit, verification run, and published artifacts.
|
|
||||||
|
|
||||||
### Out of scope
|
|
||||||
|
|
||||||
The canonical dispatcher/control-plane vertical slice (work graph, execution attempts, fenced leases, typed check-in, independent verifier dispatch) is decided post-alpha (SDLC-D-033, option B). Multi-pipeline verification certificates (SDLC-D-034 option B) are post-alpha. Full AF-1..AF-4 objective matrices and Mission Control portfolio surfaces are post-alpha.
|
|
||||||
|
|||||||
@@ -59,6 +59,28 @@ 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
|
Diagnostics expose only rule code, key name, and a SHA-256 content hash. They do not reveal command
|
||||||
text, credentials, or other values.
|
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
|
## Launch and stop behavior
|
||||||
|
|
||||||
The launcher obtains the agent's socket only from the validated generated projection. It creates or
|
The launcher obtains the agent's socket only from the validated generated projection. It creates or
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
# Tasks — Release Integrity Workstream (RI-050, #1275)
|
|
||||||
|
|
||||||
> Single-writer: the RI-050 orchestrator (jarvis, dragon-lin) only. Workers read but never modify.
|
|
||||||
>
|
|
||||||
> **Mission:** alpha 0.0.50 release-integrity floor (decisions SDLC-D-033..038).
|
|
||||||
> **PRD:** [docs/PRD.md § Release Integrity Workstream](../PRD.md#release-integrity-workstream-ri-1275)
|
|
||||||
> **Issue:** #1275 (remains open until RI-V-001 closes)
|
|
||||||
> **Base branch:** `next` (all cards branch from `origin/next`, squash-merge via PR)
|
|
||||||
>
|
|
||||||
> **Execution note:** the `agent` column uses `pi-glm-5.3` — outside the pipeline-cron model
|
|
||||||
> table on purpose. This workstream is executed by jarvis on dragon-lin with local pi workers
|
|
||||||
> (`pi --model zai/glm-5.3:high`); pipeline crons must not auto-claim these rows.
|
|
||||||
>
|
|
||||||
> **Status values:** `not-started` | `in-progress` | `done` | `blocked` | `failed` | `needs-qa`
|
|
||||||
> `done` requires: repo quality gates green, independent review recorded, terminal-green CI on
|
|
||||||
> the PR head, squash merge to `next`, and acceptance evidence in notes.
|
|
||||||
|
|
||||||
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
|
|
||||||
| -------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ---------- | ----------------- | --------------------------------- | ---------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| RI-0-001 | done | Bootstrap: issue #1275, PRD section, this DAG, scratchpad (docs only) | #1275 | pi-glm-5.3 | mosaicstack/stack | docs/ri-050-mission-bootstrap | — | 6K | PR #1276 (head 758659dd): docs-only, CI green (2475). Review requested from fargo. Merges first (no publish run). |
|
|
||||||
| RI-1-001 | done | RI-N1: canonical terminal verification command + publish-pipeline exact-commit gate (every publish step depends on verify; commit identity check; fail closed) | #1275 | pi-glm-5.3 | mosaicstack/stack | feat/ri-050-publish-gate | RI-0-001 | 25K | PR #1277 (head 46784c8d): CI GREEN at head after serialized retry (pipeline 2476, 2026-08-18) - earlier red was CI-agent contention (web SPA timeouts under concurrent pipelines), not code. Review requested from fargo at pinned head (comms 20260818T021025Z). |
|
|
||||||
| RI-1-002 | done | RI-N1 negative control: checked-in tests proving a broken mandatory check blocks every publish step and that DAG edges cannot be bypassed | #1275 | pi-glm-5.3 | mosaicstack/stack | test/ri-050-publish-gate-negative | RI-1-001 | 12K | |
|
|
||||||
| RI-2-001 | done | RI-N2 (Forge): remove stub-executor false success; `--simulate` typed `simulated` results that satisfy nothing; literal-`true` gates and echo-review replaced with real gates or typed waiting-for-authority | #1275 | pi-glm-5.3 | mosaicstack/stack | fix/ri-050-forge-fail-closed | RI-0-001 | 20K | Independent review APPROVED 2026-08-17 (Gitea review 172 on PR #1278, head 99b8f6ea; reviewing seat fargo — recorded under shared host principal mos-dt-0, provenance correction posted by fred; wrapper gap filed by fred). Executed at head: forge tests 116/116, lint green, typecheck green after building macp dist (minimal-install artifact, not a defect), workspace typecheck 45/45, no external type consumers of the changed interfaces. CI red = known lane-wide fleet-test failure only, carries no information about this change (fred, log-content analysis, pipelines 2456-2458). Non-blocking finding: README L141-143 + skills/mosaic-forge/SKILL.md document bare forge run/resume, which now fails closed — fast-follow docs touch. Merge queued behind #1270. UPDATE 2026-08-18: #1270 merged; CI GREEN at head 4917df1f via serialized retry (pipeline 2477) - root cause of prior reds was CI-agent contention (web SPA timeouts under concurrent pipelines), superseding the fleet-test-failure theory. |
|
|
||||||
| RI-2-002 | done | RI-N2 (MACP): gate runner fails closed on empty commands, stub executors, and unimplemented CI-provider gates unless explicit simulate; typed capability failures | #1275 | pi-glm-5.3 | mosaicstack/stack | fix/ri-050-macp-fail-closed | RI-0-001 | 15K | PR #1293 (head 2097379e): CI green (pipeline 2465), independent review APPROVED (Gitea review 173, jarvis seat, 2026-08-17) - macp 109/109 verified at head. Merge queued behind #1276/#1277/#1278. |
|
|
||||||
| RI-3-001 | in-progress | RI-N4: complete probe inventory mapping every TS and shell quality-rail check to one canonical check with disposition (preserve/strengthen/retire, each named) | #1275 | pi-glm-5.3 | mosaicstack/stack | docs/ri-050-qr-probe-inventory | RI-0-001 | 12K | |
|
|
||||||
| RI-3-002 | not-started | RI-N4: TS evaluator absorbs effective shell probes; typed results (passed/failed/blocked/error/not-applicable) with versioned digested check definitions; shell commands become thin adapters; contract/parity/negative-control tests | #1275 | pi-glm-5.3 | mosaicstack/stack | feat/ri-050-qr-evaluator | RI-3-001 | 30K | |
|
|
||||||
| RI-4-001 | in-progress | RI-N3: one PRD application service — `mission --plan` persists mission↔PRD linkage (ids/versions/selected requirements); `mosaic prdy` routes through the service or becomes a named import/export adapter; Markdown is a labeled generated view; explicit conflict-aware import | #1275 | pi-glm-5.3 | mosaicstack/stack | feat/ri-050-prd-authority | RI-0-001 | 35K | PR #1294 (head 8d258e1d): CI green (pipeline 2466), independent review APPROVED (Gitea review 174, jarvis seat, 2026-08-17) - prdy 20/20 + command specs 9/9 at head. Merge queued behind #1276/#1277/#1278. |
|
|
||||||
| RI-5-001 | done | RI-N5: typed freshness states (current/stale/partial/unknown/unavailable); no failed-fetch-renders-empty; stale derived verdicts → unknown; mutations disabled when stale; failure-matrix tests | #1275 | pi-glm-5.3 | mosaicstack/stack | feat/ri-050-web-stale-safety | RI-0-001 | 25K | |
|
|
||||||
| RI-V-001 | not-started | Final verification + release evidence: all cards verified merged, negative controls demonstrated, real `next` publish run green on exact commit, evidence pack recorded | #1275 | pi-glm-5.3 | mosaicstack/stack | docs/ri-050-release-evidence | RI-1-002, RI-2-001, RI-2-002, RI-3-002, RI-4-001, RI-5-001 | 10K | |
|
|
||||||
|
|
||||||
## Dispatch waves (max 2 parallel workers)
|
|
||||||
|
|
||||||
1. RI-1-001 + RI-2-001
|
|
||||||
2. RI-2-002 + RI-4-001
|
|
||||||
3. RI-3-001 + RI-5-001
|
|
||||||
4. RI-1-002 + RI-3-002
|
|
||||||
5. RI-V-001
|
|
||||||
|
|
||||||
## Budget
|
|
||||||
|
|
||||||
Derived soft cap: 250K tokens (no explicit cap given). Projected total: 190K.
|
|
||||||
Conservative mode (1 worker) above 70% projected; freeze above 90%.
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# #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.
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
# Scratchpad — RI-050 orchestrator (jarvis, dragon-lin)
|
|
||||||
|
|
||||||
Mission: alpha 0.0.50 release-integrity floor. Issue #1275. Base `next` @ 476db12b.
|
|
||||||
Design SSOT: jarvis-brain `docs/plans/2026-08-16_mosaic-stack-sdlc-protocol.md` (SDLC-D-033..038).
|
|
||||||
|
|
||||||
## Mode (Jason's directives)
|
|
||||||
|
|
||||||
- Orchestrator: jarvis (this session, dragon-lin). NOT mos-claude; work stays on this host.
|
|
||||||
- Workers: local pi headless — `pi --model zai/glm-5.3:high -p` in the card's worktree, tools read,bash,edit,write.
|
|
||||||
- Delegation override of stack AGENTS.md `agent` column: rows carry `pi-glm-5.3` (outside cron table so no auto-claim).
|
|
||||||
- Target branch: `next`. Cards branch from `origin/next`, squash-merge via PR.
|
|
||||||
|
|
||||||
## Operational constraints (measured this session)
|
|
||||||
|
|
||||||
- Main checkout at `/home/jwoltje/src/mosaic-stack` is a dirty diverged `main` (ahead 1139/behind 711) — NEVER touched. All work in `/home/jwoltje/src/mosaic-stack-worktrees/<branch>`.
|
|
||||||
- Disk: /home 187G free. /tmp only 8.7G — keep pnpm stores/node_modules under /home.
|
|
||||||
- `main` and `next` have DIVERGED; PRs target `next`.
|
|
||||||
- Identity: pin `GITEA_LOGIN=mosaicstack-jarvis` for all wrapper ops. Issue #1275 verified authored by @jarvis.
|
|
||||||
- `ci-queue-wait.sh` on this host is fail-open (board: fix #1032 not installed) — substitute SHA-status checks via `/commits/{sha}/status` and diff failing step names.
|
|
||||||
- CI on PRs runs `pull_request` pipelines (any branch) incl. ci-postgres service. Push CI runs on main only; publish runs on push/tag to next + manual.
|
|
||||||
- Wrapper gaps on this host per board (7 gaps; e.g. no pr-review-list, issue-assign broken, pr-merge makes no trailers): verify outcomes by reading back provider state, never trust rc alone.
|
|
||||||
- Publish pipeline currently: install → build → publish-npm/publish-next-npm (+image). No verify. CI steps: install, sanitization, upgrade-guard, typecheck, lint, format, test, ci-postgres.
|
|
||||||
|
|
||||||
## Budget
|
|
||||||
|
|
||||||
Soft cap 250K. Projected 190K across 10 cards. Track per-card used vs estimate in TASKS.md notes.
|
|
||||||
|
|
||||||
## Progress log
|
|
||||||
|
|
||||||
- 2026-08-16 23:52 — Issue #1275 created (@jarvis verified).
|
|
||||||
- 2026-08-16 23:5x — Bootstrap branch `docs/ri-050-mission-bootstrap` from origin/next@476db12b; PRD section + TASKS.md + this scratchpad written. RI-0-001 in-progress.
|
|
||||||
|
|
||||||
## Wave 1 dispatched (2026-08-17 00:35)
|
|
||||||
|
|
||||||
- RI-1-001 worker: pi glm-5.3:high, pid 2322125, worktree ri-1-001, log /var/tmp/ri-050/ri-1-001-run.log
|
|
||||||
- RI-2-001 worker: pi glm-5.3:high, pid 2322126, worktree ri-2-001, log /var/tmp/ri-050/ri-2-001-run.log
|
|
||||||
- Gotcha recorded: pi has no -f flag (that's pi-do.sh); pass brief as positional message. First launch died "Unknown option: -f" — relaunched.
|
|
||||||
- CI lane: PR #1276 (bootstrap) fails `test` at base like every next PR — fred's green #1270 unblocks (comms sent 2026-08-17T05:21Z, `comms/20260817T052148Z__from-jarvis__650fe8.md`). Merge gate for all RI PRs queues behind #1270.
|
|
||||||
- Live RI-N1 evidence posted to #1275 (comment 22915): pipeline 2439 publish-next-npm SUCCESS beside build-gateway FAILURE.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# HANDOFF — RI-050 continuation (written 2026-08-17 ~08:45 UTC, jarvis/dragon-lin)
|
|
||||||
|
|
||||||
You are taking over the alpha 0.0.50 release-integrity workstream in place. Everything you
|
|
||||||
need is on the remote. Read this whole file, then `docs/release-integrity/TASKS.md` (same
|
|
||||||
branch), then the PRD section (`docs/PRD.md` § Release Integrity Workstream, same branch).
|
|
||||||
|
|
||||||
## Identity / mode
|
|
||||||
|
|
||||||
- Orchestrator identity: `jarvis` (dragon-lin). You continue as the RI-050 orchestrator under
|
|
||||||
whatever identity Jason gives you — if you are NOT jarvis, say so in comms and PR bodies.
|
|
||||||
- Jason's standing directives for this mission: work happens on THIS repo (mosaicstack/stack),
|
|
||||||
PRs target `next` (NOT main), workers are local pi headless sessions on
|
|
||||||
`zai/glm-5.3:high`. Do not hand this to mos-claude. Do not borrow other seats' lanes.
|
|
||||||
- All wrapper ops: pin `GITEA_LOGIN=mosaicstack-jarvis` (issue #1275 was verified authored by
|
|
||||||
@jarvis; keep identity consistent or verify yours with issue-view and READ BACK user.login).
|
|
||||||
- CI substitution rule (this host's ci-queue-wait.sh is fail-open; fix #1032 not installed):
|
|
||||||
judge CI by SHA-status via `/api/v1/repos/mosaicstack/stack/commits/{sha}/status` or the
|
|
||||||
woodpecker API (`pipeline-status.sh -r mosaicstack/stack -n N -f json`), and DIFF THE
|
|
||||||
FAILING STEP NAMES rather than trusting rc.
|
|
||||||
|
|
||||||
## Mission state at handoff
|
|
||||||
|
|
||||||
Mission: alpha 0.0.50 release-integrity floor. Issue #1275 (open, has live-evidence comment).
|
|
||||||
Decisions SDLC-D-033..038 live in jarvis-brain
|
|
||||||
`docs/plans/2026-08-16_mosaic-stack-sdlc-protocol.md` (normative text also mirrored in the
|
|
||||||
PRD section on this branch, so this repo is self-sufficient).
|
|
||||||
|
|
||||||
Base: `origin/next` @ 476db12b. NOTE: `main` and `next` have DIVERGED — never base on main.
|
|
||||||
|
|
||||||
Branches (all pushed, all clean trees):
|
|
||||||
|
|
||||||
- `docs/ri-050-mission-bootstrap` @ 5114faa2 → PR #1276 (open, mergeable) — bootstrap docs +
|
|
||||||
this scratchpad + TASKS.md DAG. STATUS: CI red on `test` only, which is the known lane-wide
|
|
||||||
failure (see blocker below); own prettier issue already fixed.
|
|
||||||
- `feat/ri-050-publish-gate` @ 0aa5ed35 → PR #1277 (open, mergeable) — RI-1-001 COMPLETE
|
|
||||||
(worker reported success, orchestrator review PASSED: verify step asserts CI_COMMIT_SHA ==
|
|
||||||
git rev-parse HEAD then runs canonical `pnpm verify:release`; every publish/image step
|
|
||||||
depends_on verify directly, confirmed by parsing the DAG: publish-npm, publish-next-npm,
|
|
||||||
build-gateway/appservice/web all -> [build, verify]; invariant test
|
|
||||||
scripts/verify-release.test.mjs passes 7/7 locally with negative fixtures). CI: same known
|
|
||||||
lane-red `test` step only.
|
|
||||||
- `fix/ri-050-forge-fail-closed` @ 99b8f6ea → PR #1278 (open, mergeable) — RI-2-001 worker
|
|
||||||
reported success (typed `FORGE_*` capability errors, --simulate typed simulated everywhere,
|
|
||||||
vacuous true/echo gates replaced, closed ForgeOutcome set, 116 tests green incl. 16 new).
|
|
||||||
ORCHESTRATOR REVIEW NOT YET DONE — your first job. Review the diff
|
|
||||||
(1391 insertions across forge src), check the fail-closed paths and that simulated
|
|
||||||
results cannot satisfy any consumer, run `pnpm --filter @mosaicstack/forge test`.
|
|
||||||
|
|
||||||
## The one blocker
|
|
||||||
|
|
||||||
Every `next` PR pipeline is red on ONE assertion:
|
|
||||||
`packages/mosaic/framework/tools/fleet/test-start-agent-session.sh:103` ("host provides 'pi'
|
|
||||||
in the system path"). Pre-existing at base; affects PRs #1276/#1277/#1278 identically.
|
|
||||||
fred's PR #1270 ("unblocks every PR on next") is green and open — it is HIS to merge; do not
|
|
||||||
merge it yourself. jarvis sent comms (`comms/20260817T052148Z__from-jarvis__650fe8.md` in
|
|
||||||
jarvis-brain) asking merge timing; no reply yet as of handoff. Merge gates for ALL RI PRs
|
|
||||||
queue behind #1270 landing. Until then: review/develop freely, merge nothing that needs the
|
|
||||||
green gate (docs-only #1276 arguably could merge red-lane with Jason's explicit call — ask,
|
|
||||||
don't assume).
|
|
||||||
|
|
||||||
## Remaining DAG (docs/release-integrity/TASKS.md is canonical)
|
|
||||||
|
|
||||||
Wave 2 (next): RI-2-002 MACP fail-closed (brief pattern: mirror RI-2-001 for
|
|
||||||
packages/macp/src/gate-runner.ts — empty commands, stub executors, unimplemented CI-provider
|
|
||||||
gates fail closed; explicit simulate) and RI-4-001 PRD authority (one PRD service;
|
|
||||||
@mosaicstack/prdy docs/prdy authoritative via `mosaic mission --plan`; `mosaic prdy` routes
|
|
||||||
or becomes named Markdown adapter; mission<->PRD linkage persists — see PRD RI-N3).
|
|
||||||
Wave 3: RI-3-001 probe inventory (docs), RI-5-001 web stale-safety.
|
|
||||||
Wave 4: RI-1-002 negative-control tests, RI-3-002 TS evaluator absorbs shell probes.
|
|
||||||
Final: RI-V-001 evidence pack (real green next publish run post-gate + all cards verified).
|
|
||||||
|
|
||||||
## Worker mechanics (measured, reuse)
|
|
||||||
|
|
||||||
- Dispatch: create worktree `git -C /home/jwoltje/src/mosaic-stack worktree add
|
|
||||||
/home/jwoltje/src/mosaic-stack-worktrees/<id> -b <branch> origin/next`, write a brief to
|
|
||||||
/var/tmp/ri-050/, then run from INSIDE the worktree:
|
|
||||||
`pi -p --no-session --model zai/glm-5.3:high --tools read,bash,edit,write "$(cat brief.md)"`
|
|
||||||
(pi has NO -f flag — pass the brief as a positional message; first dispatch died on that).
|
|
||||||
- Briefs for 1-001/2-001 are at /var/tmp/ri-050/ on dragon-lin (may not survive; the
|
|
||||||
pattern is fully described above and in TASKS.md).
|
|
||||||
- Briefs must carry: worktree path, branch, base, requirements, known base-red list (so the
|
|
||||||
worker doesn't chase it), gates to run, PR creation command with GITEA_LOGIN pin, "do NOT
|
|
||||||
merge, do NOT touch docs/TASKS.md", and the JSON report format.
|
|
||||||
- Verify worker claims: read the PR, run their tests yourself, parse pipeline step names.
|
|
||||||
|
|
||||||
## Do-not-touch
|
|
||||||
|
|
||||||
- Main checkout at /home/jwoltje/src/mosaic-stack (dirty diverged main) — never touch.
|
|
||||||
- fred's open PRs (#1270 and others) — review evidence welcome, merging his is not yours.
|
|
||||||
- Other RI PRs' authors' lanes: #1277/#1278 are yours to gate and merge ONCE lane is green
|
|
||||||
and review is recorded.
|
|
||||||
- Never `--no-verify`; never bypass the wrapper-fails-closed rule (wrapper failure ⇒
|
|
||||||
`blocked + report exact command + stop`).
|
|
||||||
|
|
||||||
## Session-restore command sequence
|
|
||||||
|
|
||||||
1. `git -C /home/jwoltje/src/mosaic-stack-worktrees/ri-050 fetch origin --prune`
|
|
||||||
2. Read this file + `docs/release-integrity/TASKS.md` + PRD section.
|
|
||||||
3. Check PR states (#1270, #1276, #1277, #1278) and lane CI (SHA-status per above).
|
|
||||||
4. Review RI-2-001 (PR #1278) if not yet done; then dispatch wave 2.
|
|
||||||
|
|
||||||
— jarvis, 2026-08-17
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# CONTINUATION — fargo (sb-it-1-dt)
|
|
||||||
|
|
||||||
Orchestrator seat is now **fargo** on sb-it-1-dt (Jason, 2026-08-17): Claude seat, worktree discipline
|
|
||||||
per fred's ruling (`~/agent-work/<slug>`, create → work → commit → push → remove as one act; the
|
|
||||||
helper's `/src` refusal is a web1 convention, does not bind here). fred supports; lane rulings are
|
|
||||||
his. Workers remain local pi `zai/glm-5.3:high` + limited Claude per Jason.
|
|
||||||
|
|
||||||
## 2026-08-17 — RI-2-001 independent review DONE
|
|
||||||
|
|
||||||
- **PR #1278 APPROVED** (Gitea review 172, pinned to head 99b8f6ea). Executed evidence, not read-only:
|
|
||||||
forge suite 116/116 at head (matches PR claim), forge lint green, forge typecheck green after
|
|
||||||
building `@mosaicstack/macp` dist (TS2307 on bare `pnpm install --frozen-lockfile` is a
|
|
||||||
minimal-install build-order artifact — the macp import is type-only, vitest passes unbuilt; CI
|
|
||||||
installs build workspace deps, hence green there), **workspace typecheck 45/45 at head**,
|
|
||||||
consumer sweep: no external type consumers of RunManifest/StageStatus/ForgeTaskResult/
|
|
||||||
TaskExecutor; only importer of the package is packages/mosaic via registerForgeCommand
|
|
||||||
(smoke test asserts registration/help only — cannot break). Digest gate (shaggy's) before==after
|
|
||||||
with both-arm reactivity controls.
|
|
||||||
- CI red on #1276/#1277/#1278: lane-wide `test` failure only
|
|
||||||
(test-start-agent-session.sh:103, fred's guard mis-wired; #1270 unwires it). Fred measured log
|
|
||||||
content: one real byte-identical failure per pipeline (2456/2457/2458); 13 of ~14 `FAIL` grep
|
|
||||||
hits are passing fail-loud test NAMES. **The red carries no information about the RI changes.**
|
|
||||||
- Non-blocking finding: README L141-143 + skills/mosaic-forge/SKILL.md document bare
|
|
||||||
`mosaic forge run`/`resume`, which now exits 1 FORGE_NO_EXECUTOR — fast-follow docs touch.
|
|
||||||
- **Identity incident, ruled on by fred:** review 172 recorded under shared host principal
|
|
||||||
mos-dt-0, not fargo. Mechanism (measured, wrapper source): pr-review.sh resolves its acting login
|
|
||||||
from the tea login list only; no fargo tea login on this host → silent host-default fallback;
|
|
||||||
MOSAIC_GIT_IDENTITY is only read in detect-platform.sh get_gitea_token's fallback arm, never
|
|
||||||
reached. Exact-id read-back verifies against the writing token, so it passed while attribution
|
|
||||||
was wrong — durable-provenance machinery proves the write, not the seat. Fred's ruling: review
|
|
||||||
172 stands (substance/verdict/pin correct; label wrong); NO re-approval (one approval,
|
|
||||||
annotated, is the stronger record); fred posts the provenance correction under @fred with
|
|
||||||
--login fred-ms (hard-fail path); no fargo tea login ever (freeze + Jason's to authorize);
|
|
||||||
tooling gap filed by fred. Also explains (does not reopen) #1228's mos-dt-0 attribution.
|
|
||||||
- Merge gate: all RI PRs queue behind fred's green #1270 (Jason's call).
|
|
||||||
|
|
||||||
## Next
|
|
||||||
|
|
||||||
1. Wave 2 dispatch: RI-2-002 (MACP fail-closed, mirror RI-2-001 pattern for
|
|
||||||
packages/macp/src/gate-runner.ts) + RI-4-001 (PRD authority). Two parallel workers max.
|
|
||||||
2. Docs fast-follow (README + mosaic-forge skill) — fold into #1276 or a tiny docs card.
|
|
||||||
3. RI-V-001 evidence at the end.
|
|
||||||
|
|
||||||
— fargo, 2026-08-17
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# RESUMPTION + DAILY-HANDOFF PROTOCOL (Jason, 2026-08-17)
|
|
||||||
|
|
||||||
Orchestrator seat is back with **jarvis** (dragon-lin). Expect daily handoff between jarvis
|
|
||||||
and fargo. Protocol (both seats, every handoff):
|
|
||||||
|
|
||||||
1. **This file is the shared mission log.** Append a dated section per session: state
|
|
||||||
measured, actions taken, PR/review states, next actions. Never rewrite prior sections.
|
|
||||||
2. **TASKS.md stays current within one session** — status, PR number in notes, review
|
|
||||||
evidence. Stale rows are handoff debt.
|
|
||||||
3. **Cross-review rule (SDLC-D-011 in practice):** the reviewing seat must differ from the
|
|
||||||
producing seat. jarvis reviews fargo-dispatched PRs, fargo reviews jarvis-dispatched
|
|
||||||
PRs. Producers are always pi workers; dispatching seats verify before push; the other
|
|
||||||
seat records the Gitea review.
|
|
||||||
4. Handoff = append here + push + (optional) issue #1275 comment if a decision changed.
|
|
||||||
|
|
||||||
## RESUMED — jarvis/dragon-lin, 2026-08-17 (afternoon)
|
|
||||||
|
|
||||||
- Measured: next = 8199261c (#1270 merged — lane unblocked for new PRs). #1293/#1294
|
|
||||||
(fargo, wave 2) CI-green, mergeable, no recorded reviews. #1276/#1277/#1278 still based
|
|
||||||
on 476db12b with stale red CI → need rebase onto 8199261c. #1278 review pinned to old
|
|
||||||
head 99b8f6ea by @mos-dt-0 (fargo's, mis-attributed per his note) — rebase will dismiss
|
|
||||||
it; re-approval must come from fargo/fred (author is @jarvis, cannot self-approve).
|
|
||||||
- Live evidence #2: push pipeline 2462 (the #1270 merge itself) ran publish-next-npm
|
|
||||||
SUCCESS beside build-gateway FAILURE again.
|
|
||||||
- Plan: rebase the three original branches; independently review #1293/#1294; merge order
|
|
||||||
once green+reviewed: #1276 (docs) → #1277 (publish gate) → #1278/#1293/#1294 (code).
|
|
||||||
After #1277 merges, watch the next push pipeline prove the verify gate live.
|
|
||||||
- fargo's non-RI PRs (#1291/#1296/#1297/#1281) stay strictly his lane.
|
|
||||||
|
|
||||||
## jarvis session 2026-08-17 (evening) — reviews, rebases, merge plan
|
|
||||||
|
|
||||||
- Rebased #1276/#1277/#1278 onto 8199261c (heads 59e2c460 / 46784c8d / 4917df1f);
|
|
||||||
invariant tests 7/7 and forge 116/116 re-run green at new heads. #1270 touched
|
|
||||||
test-enumeration-exclusions.txt + package.json, NOT ci.yml — no semantic overlap with
|
|
||||||
#1277's ci.yml changes (checked, was a real concern).
|
|
||||||
- Independent reviews recorded: #1293 APPROVED (review 173; macp 109/109; fail-closed paths
|
|
||||||
+ aggregate state machine verified), #1294 APPROVED (review 174; prdy 20/20 + command
|
|
||||||
specs 9/9; single-writer + linkage persistence + labeled export + conflict-aware import
|
|
||||||
verified). Note: 19 unrelated mosaic suites fail on bare minimal install (known workspace
|
|
||||||
build-order artifact, documented by fargo) — not this change.
|
|
||||||
- Measured: `next` has NO branch protection (API: only main listed). Cross-seat review
|
|
||||||
discipline is protocol-enforced, not Gitea-enforced. Flagged to fargo for Jason: direct
|
|
||||||
pushes to next trigger ungated publishes; protection is Jason's call (#1231 adjacent).
|
|
||||||
- Merge order planned: #1276 (docs-only — no publish run) -> #1277 (first gated publish)
|
|
||||||
-> #1278 -> #1293 -> #1294. Sent fargo review requests with pinned head SHAs
|
|
||||||
(comms/20260818T011932Z__from-jarvis__a9c02b.md). Not merging #1293/#1294 before my three
|
|
||||||
clear fargo's review — order optimality beats speed; every pre-#1277 merge publishes ungated.
|
|
||||||
- CI on the three rebased heads: pending at time of this entry.
|
|
||||||
@@ -34,7 +34,6 @@ export default tseslint.config(
|
|||||||
'packages/storage/vitest.config.ts',
|
'packages/storage/vitest.config.ts',
|
||||||
'packages/mosaic/vitest.config.ts',
|
'packages/mosaic/vitest.config.ts',
|
||||||
'packages/mosaic/__tests__/*.ts',
|
'packages/mosaic/__tests__/*.ts',
|
||||||
'packages/forge/__tests__/*.ts',
|
|
||||||
'tools/federation-harness/*.ts',
|
'tools/federation-harness/*.ts',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
"preflight": "node scripts/preflight.mjs",
|
"preflight": "node scripts/preflight.mjs",
|
||||||
"clean:generated": "node scripts/clean-generated.mjs",
|
"clean:generated": "node scripts/clean-generated.mjs",
|
||||||
"typecheck": "pnpm preflight && turbo run typecheck",
|
"typecheck": "pnpm preflight && turbo run typecheck",
|
||||||
"verify:release": "node scripts/verify-release.mjs",
|
|
||||||
"test:checkout": "node --test scripts/*.test.mjs",
|
"test:checkout": "node --test scripts/*.test.mjs",
|
||||||
"test": "pnpm test:checkout && turbo run test && pnpm run test:installer",
|
"test": "pnpm test:checkout && turbo run test && pnpm run test:installer",
|
||||||
"test:installer": "bash tools/install-next-lane.test.sh",
|
"test:installer": "bash tools/install-next-lane.test.sh",
|
||||||
|
|||||||
@@ -539,43 +539,3 @@ Not every brief needs full Board of Directors review. The classification system
|
|||||||
### Backward compatibility
|
### Backward compatibility
|
||||||
|
|
||||||
Existing briefs without a `class` field are auto-classified. The default (no matching keywords) is `strategic`, so all existing runs get the full pipeline unless keywords trigger `technical`.
|
Existing briefs without a `class` field are auto-classified. The default (no matching keywords) is `strategic`, so all existing runs get the full pipeline unless keywords trigger `technical`.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Fail-Closed Execution & Explicit Simulation (SDLC-D-035)
|
|
||||||
|
|
||||||
**Added:** 2026-08-17
|
|
||||||
|
|
||||||
Forge fails closed when a required capability is missing. It never runs a
|
|
||||||
pipeline with a stub executor and reports success.
|
|
||||||
|
|
||||||
### Normal mode (default)
|
|
||||||
|
|
||||||
- No task executor wired → the CLI exits nonzero with the typed capability
|
|
||||||
error `FORGE_NO_EXECUTOR`. No run is created.
|
|
||||||
- A stage whose gate is approval-based (board approval, planning approvals,
|
|
||||||
remediation re-review, discovery/analysis attestations) records a typed
|
|
||||||
`waiting-for-authority` stage result and raises `FORGE_AUTHORITY_REQUIRED`.
|
|
||||||
It never passes vacuously.
|
|
||||||
- A stage whose gate requires an unwired provider (AI reviewer, CI pipeline)
|
|
||||||
records a typed `blocked` stage result and raises `FORGE_NO_REVIEWER` /
|
|
||||||
`FORGE_NO_CI_PIPELINE`. The synthetic echo-review approval in `06-review`
|
|
||||||
and all vacuous `true` gates were removed.
|
|
||||||
|
|
||||||
### Explicit simulation (`--simulate`)
|
|
||||||
|
|
||||||
Opts into stub/synthetic execution. Every stage result, every gate result, and
|
|
||||||
the run manifest carry the distinct typed status `simulated` (manifest also
|
|
||||||
records `mode: "simulated"`). `simulated` is a non-satisfying outcome:
|
|
||||||
`isSatisfyingOutcome()` and all completion/gate consumers treat only `passed`
|
|
||||||
as satisfying. The CLI exits 0 for a simulated run only because the caller
|
|
||||||
explicitly passed `--simulate`, and prints a loud SIMULATED banner.
|
|
||||||
|
|
||||||
### Typed outcome model
|
|
||||||
|
|
||||||
Every gate/task outcome is one of the closed set
|
|
||||||
`passed | failed | blocked | error | waiting-for-authority | simulated |
|
|
||||||
not-applicable`, with the reason recorded on the stage status and each gate
|
|
||||||
result in `manifest.json`. Missing implementations, missing gate evidence,
|
|
||||||
unknown stages, process errors, and timeouts map to fail-closed members —
|
|
||||||
never to `passed`.
|
|
||||||
|
|||||||
@@ -1,319 +0,0 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
||||||
|
|
||||||
import { generateBoardTasks } from '../src/board-tasks.js';
|
|
||||||
import { STAGE_SPECS } from '../src/constants.js';
|
|
||||||
import { ForgeCapabilityError } from '../src/errors.js';
|
|
||||||
import {
|
|
||||||
evaluateStageGates,
|
|
||||||
gateLabel,
|
|
||||||
isCommandGate,
|
|
||||||
isSatisfyingOutcome,
|
|
||||||
} from '../src/outcomes.js';
|
|
||||||
import { loadManifest, runPipeline } from '../src/pipeline-runner.js';
|
|
||||||
import type { ForgeTask, ForgeTaskResult, TaskExecutor } from '../src/types.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mock real executor that returns typed results.
|
|
||||||
*
|
|
||||||
* Command gates are "verified" by the mock so normal-mode runs can pass
|
|
||||||
* mechanically gated stages; authority/provider gates are never reported
|
|
||||||
* because they have no mechanical implementation.
|
|
||||||
*/
|
|
||||||
function createTypedExecutor(options?: {
|
|
||||||
failStage?: string;
|
|
||||||
gateOutcomes?: Record<string, 'passed' | 'failed' | 'simulated' | 'error' | 'blocked'>;
|
|
||||||
}): TaskExecutor & { submittedTasks: ForgeTask[] } {
|
|
||||||
const submittedTasks: ForgeTask[] = [];
|
|
||||||
return {
|
|
||||||
submittedTasks,
|
|
||||||
async submitTask(task: ForgeTask) {
|
|
||||||
submittedTasks.push(task);
|
|
||||||
},
|
|
||||||
async waitForCompletion(taskId: string): Promise<ForgeTaskResult> {
|
|
||||||
const task = submittedTasks.find((t) => t.id === taskId);
|
|
||||||
const stageName = task?.metadata?.['stageName'] as string | undefined;
|
|
||||||
|
|
||||||
if (options?.failStage && stageName === options.failStage) {
|
|
||||||
return {
|
|
||||||
task_id: taskId,
|
|
||||||
outcome: 'failed',
|
|
||||||
reason: 'mock task failure',
|
|
||||||
completed_at: new Date().toISOString(),
|
|
||||||
exit_code: 1,
|
|
||||||
gate_results: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const gateResults = (task?.qualityGates ?? [])
|
|
||||||
.filter((gate) => isCommandGate(gate))
|
|
||||||
.map((gate) => {
|
|
||||||
const label = gateLabel(gate);
|
|
||||||
const outcome = options?.gateOutcomes?.[label] ?? 'passed';
|
|
||||||
return {
|
|
||||||
gate: label,
|
|
||||||
outcome,
|
|
||||||
reason: outcome === 'passed' ? 'mock verified' : `mock gate outcome: ${outcome}`,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
task_id: taskId,
|
|
||||||
outcome: 'passed',
|
|
||||||
reason: 'mock verified',
|
|
||||||
completed_at: new Date().toISOString(),
|
|
||||||
exit_code: 0,
|
|
||||||
gate_results: gateResults,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async getTaskStatus() {
|
|
||||||
return 'completed' as const;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('fail-closed: no executor wired', () => {
|
|
||||||
let tmpDir: string;
|
|
||||||
let briefPath: string;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-failclosed-'));
|
|
||||||
briefPath = path.join(tmpDir, 'brief.md');
|
|
||||||
fs.writeFileSync(briefPath, '# Fix bug\n\nA bugfix for lint cleanup.');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws a typed FORGE_NO_EXECUTOR capability error without --simulate', async () => {
|
|
||||||
await expect(
|
|
||||||
runPipeline(briefPath, tmpDir, {
|
|
||||||
// no executor, no simulate — must fail closed, never run with a stub
|
|
||||||
stages: ['00-intake'],
|
|
||||||
}),
|
|
||||||
).rejects.toMatchObject({
|
|
||||||
name: 'ForgeCapabilityError',
|
|
||||||
code: 'FORGE_NO_EXECUTOR',
|
|
||||||
capability: 'task-executor',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not create a run directory when failing closed on a missing executor', async () => {
|
|
||||||
try {
|
|
||||||
await runPipeline(briefPath, tmpDir, { stages: ['00-intake'] });
|
|
||||||
} catch {
|
|
||||||
// expected
|
|
||||||
}
|
|
||||||
expect(fs.existsSync(path.join(tmpDir, '.forge', 'runs'))).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('completes with every result typed simulated when simulate is set', async () => {
|
|
||||||
const result = await runPipeline(briefPath, tmpDir, {
|
|
||||||
simulate: true,
|
|
||||||
stages: ['00-intake', '00b-discovery', '02-planning-1', '06-review'],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.manifest.mode).toBe('simulated');
|
|
||||||
expect(result.manifest.status).toBe('simulated');
|
|
||||||
|
|
||||||
for (const stage of result.stages) {
|
|
||||||
const stageStatus = result.manifest.stages[stage];
|
|
||||||
expect(stageStatus?.status, `stage ${stage}`).toBe('simulated');
|
|
||||||
expect(stageStatus?.status, `stage ${stage}`).not.toBe('passed');
|
|
||||||
expect(stageStatus?.reason, `stage ${stage}`).toBeTruthy();
|
|
||||||
for (const gateResult of stageStatus?.gateResults ?? []) {
|
|
||||||
expect(gateResult.outcome, `gate ${gateResult.gate} of ${stage}`).toBe('simulated');
|
|
||||||
expect(gateResult.outcome, `gate ${gateResult.gate} of ${stage}`).not.toBe('passed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The persisted manifest agrees.
|
|
||||||
const persisted = loadManifest(result.runDir);
|
|
||||||
expect(persisted.mode).toBe('simulated');
|
|
||||||
expect(persisted.status).toBe('simulated');
|
|
||||||
expect(persisted.stages['02-planning-1']?.status).toBe('simulated');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fail-closed: typed outcome model', () => {
|
|
||||||
it('only passed satisfies the gate/dependency predicate', () => {
|
|
||||||
expect(isSatisfyingOutcome('passed')).toBe(true);
|
|
||||||
expect(isSatisfyingOutcome('failed')).toBe(false);
|
|
||||||
expect(isSatisfyingOutcome('blocked')).toBe(false);
|
|
||||||
expect(isSatisfyingOutcome('error')).toBe(false);
|
|
||||||
expect(isSatisfyingOutcome('waiting-for-authority')).toBe(false);
|
|
||||||
expect(isSatisfyingOutcome('simulated')).toBe(false);
|
|
||||||
expect(isSatisfyingOutcome('not-applicable')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a simulated gate result cannot satisfy the stage gate evaluation', () => {
|
|
||||||
const evaluation = evaluateStageGates('05-coding', STAGE_SPECS['05-coding']!.qualityGates, {
|
|
||||||
task_id: 'FORGE-x-05',
|
|
||||||
outcome: 'passed',
|
|
||||||
reason: 'executor claims success',
|
|
||||||
completed_at: new Date().toISOString(),
|
|
||||||
exit_code: 0,
|
|
||||||
gate_results: [{ gate: 'pnpm lint', outcome: 'simulated', reason: 'simulated gate' }],
|
|
||||||
});
|
|
||||||
expect(isSatisfyingOutcome(evaluation.outcome)).toBe(false);
|
|
||||||
expect(evaluation.outcome).toBe('error');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a simulated task outcome cannot satisfy evaluation in normal mode', () => {
|
|
||||||
const evaluation = evaluateStageGates('00-intake', [], {
|
|
||||||
task_id: 'FORGE-x-00',
|
|
||||||
outcome: 'simulated',
|
|
||||||
reason: 'executor reported simulated',
|
|
||||||
completed_at: new Date().toISOString(),
|
|
||||||
exit_code: 0,
|
|
||||||
gate_results: [],
|
|
||||||
});
|
|
||||||
expect(isSatisfyingOutcome(evaluation.outcome)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a missing gate result blocks the stage instead of passing vacuously', () => {
|
|
||||||
const evaluation = evaluateStageGates('05-coding', STAGE_SPECS['05-coding']!.qualityGates, {
|
|
||||||
task_id: 'FORGE-x-05',
|
|
||||||
outcome: 'passed',
|
|
||||||
reason: 'executor claims success',
|
|
||||||
completed_at: new Date().toISOString(),
|
|
||||||
exit_code: 0,
|
|
||||||
gate_results: [],
|
|
||||||
});
|
|
||||||
expect(evaluation.outcome).toBe('blocked');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fail-closed: authority and provider gates', () => {
|
|
||||||
let tmpDir: string;
|
|
||||||
let briefPath: string;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-authority-'));
|
|
||||||
briefPath = path.join(tmpDir, 'brief.md');
|
|
||||||
fs.writeFileSync(briefPath, '# Fix bug\n\nA bugfix for lint cleanup.');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(['02-planning-1', '03-planning-2', '04-planning-3', '07-remediate'])(
|
|
||||||
'planning/remediation stage %s yields waiting-for-authority (not passed) in normal mode',
|
|
||||||
async (stage) => {
|
|
||||||
const executor = createTypedExecutor();
|
|
||||||
let runDir: string | undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await runPipeline(briefPath, tmpDir, {
|
|
||||||
executor,
|
|
||||||
stages: [stage as string],
|
|
||||||
});
|
|
||||||
expect.unreachable('runPipeline should have failed closed');
|
|
||||||
} catch (err) {
|
|
||||||
expect(err).toBeInstanceOf(ForgeCapabilityError);
|
|
||||||
expect((err as ForgeCapabilityError).code).toBe('FORGE_AUTHORITY_REQUIRED');
|
|
||||||
runDir = path.join(tmpDir, '.forge', 'runs');
|
|
||||||
}
|
|
||||||
|
|
||||||
const runIds = fs.readdirSync(runDir!);
|
|
||||||
expect(runIds).toHaveLength(1);
|
|
||||||
const manifest = loadManifest(path.join(runDir!, runIds[0]!));
|
|
||||||
expect(manifest.stages[stage]?.status).toBe('waiting-for-authority');
|
|
||||||
expect(manifest.stages[stage]?.status).not.toBe('passed');
|
|
||||||
expect(manifest.status).toBe('waiting-for-authority');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('review stage fails closed with a typed FORGE_NO_REVIEWER error in normal mode', async () => {
|
|
||||||
const executor = createTypedExecutor();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await runPipeline(briefPath, tmpDir, {
|
|
||||||
executor,
|
|
||||||
stages: ['06-review'],
|
|
||||||
});
|
|
||||||
expect.unreachable('runPipeline should have failed closed');
|
|
||||||
} catch (err) {
|
|
||||||
expect(err).toBeInstanceOf(ForgeCapabilityError);
|
|
||||||
expect((err as ForgeCapabilityError).code).toBe('FORGE_NO_REVIEWER');
|
|
||||||
expect((err as ForgeCapabilityError).capability).toBe('reviewer');
|
|
||||||
}
|
|
||||||
|
|
||||||
const runsDir = path.join(tmpDir, '.forge', 'runs');
|
|
||||||
const runIds = fs.readdirSync(runsDir);
|
|
||||||
const manifest = loadManifest(path.join(runsDir, runIds[0]!));
|
|
||||||
expect(manifest.stages['06-review']?.status).toBe('blocked');
|
|
||||||
expect(manifest.stages['06-review']?.status).not.toBe('passed');
|
|
||||||
expect(manifest.status).toBe('failed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('review stage produces simulated results under --simulate', async () => {
|
|
||||||
const result = await runPipeline(briefPath, tmpDir, {
|
|
||||||
simulate: true,
|
|
||||||
stages: ['06-review'],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.manifest.mode).toBe('simulated');
|
|
||||||
expect(result.manifest.stages['06-review']?.status).toBe('simulated');
|
|
||||||
for (const gateResult of result.manifest.stages['06-review']?.gateResults ?? []) {
|
|
||||||
expect(gateResult.outcome).toBe('simulated');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deploy stage fails closed without a wired ci-pipeline provider in normal mode', async () => {
|
|
||||||
const executor = createTypedExecutor();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
runPipeline(briefPath, tmpDir, {
|
|
||||||
executor,
|
|
||||||
stages: ['09-deploy'],
|
|
||||||
}),
|
|
||||||
).rejects.toMatchObject({
|
|
||||||
name: 'ForgeCapabilityError',
|
|
||||||
code: 'FORGE_NO_CI_PIPELINE',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fail-closed: no vacuous gate commands remain', () => {
|
|
||||||
it('stage constants contain no echo/synthetic-approval, vacuous true, or empty gate commands', () => {
|
|
||||||
for (const [stageName, spec] of Object.entries(STAGE_SPECS)) {
|
|
||||||
for (const gate of spec.qualityGates) {
|
|
||||||
const serialized = JSON.stringify(gate);
|
|
||||||
// The echo-review synthetic approval must be gone.
|
|
||||||
expect(serialized, `stage ${stageName} gate ${serialized}`).not.toContain('echo');
|
|
||||||
expect(serialized, `stage ${stageName} gate ${serialized}`).not.toMatch(/"verdict"\s*:/);
|
|
||||||
expect(serialized, `stage ${stageName} gate ${serialized}`).not.toMatch(
|
|
||||||
/"summary"\s*:\s*"review-pass"/,
|
|
||||||
);
|
|
||||||
// No vacuous literal `true` gate.
|
|
||||||
expect(gate, `stage ${stageName}`).not.toBe('true');
|
|
||||||
// Command gates must carry a real, non-empty command.
|
|
||||||
if (isCommandGate(gate)) {
|
|
||||||
const command = typeof gate === 'string' ? gate : gate.command;
|
|
||||||
expect(command.trim().length, `stage ${stageName} gate ${serialized}`).toBeGreaterThan(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('board tasks contain no vacuous true gates', () => {
|
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-board-gates-'));
|
|
||||||
try {
|
|
||||||
const tasks = generateBoardTasks('# Brief', [], tmpDir, 'BOARD-TEST');
|
|
||||||
for (const task of tasks) {
|
|
||||||
for (const gate of task.qualityGates) {
|
|
||||||
expect(gate, `task ${task.id}`).not.toBe('true');
|
|
||||||
const serialized = JSON.stringify(gate);
|
|
||||||
expect(serialized, `task ${task.id} gate ${serialized}`).not.toContain('echo');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -12,10 +12,10 @@ import {
|
|||||||
resumePipeline,
|
resumePipeline,
|
||||||
getPipelineStatus,
|
getPipelineStatus,
|
||||||
} from '../src/pipeline-runner.js';
|
} from '../src/pipeline-runner.js';
|
||||||
import type { ForgeTask, ForgeTaskResult, RunManifest, TaskExecutor } from '../src/types.js';
|
import type { ForgeTask, RunManifest, TaskExecutor } from '../src/types.js';
|
||||||
import { gateLabel, isCommandGate } from '../src/outcomes.js';
|
import type { TaskResult } from '@mosaicstack/macp';
|
||||||
|
|
||||||
/** Mock TaskExecutor that records submitted tasks and returns typed results. */
|
/** Mock TaskExecutor that records submitted tasks and returns success. */
|
||||||
function createMockExecutor(options?: {
|
function createMockExecutor(options?: {
|
||||||
failStage?: string;
|
failStage?: string;
|
||||||
}): TaskExecutor & { submittedTasks: ForgeTask[] } {
|
}): TaskExecutor & { submittedTasks: ForgeTask[] } {
|
||||||
@@ -25,7 +25,7 @@ function createMockExecutor(options?: {
|
|||||||
async submitTask(task: ForgeTask) {
|
async submitTask(task: ForgeTask) {
|
||||||
submittedTasks.push(task);
|
submittedTasks.push(task);
|
||||||
},
|
},
|
||||||
async waitForCompletion(taskId: string): Promise<ForgeTaskResult> {
|
async waitForCompletion(taskId: string): Promise<TaskResult> {
|
||||||
const failStage = options?.failStage;
|
const failStage = options?.failStage;
|
||||||
const task = submittedTasks.find((t) => t.id === taskId);
|
const task = submittedTasks.find((t) => t.id === taskId);
|
||||||
const stageName = task?.metadata?.['stageName'] as string | undefined;
|
const stageName = task?.metadata?.['stageName'] as string | undefined;
|
||||||
@@ -33,8 +33,7 @@ function createMockExecutor(options?: {
|
|||||||
if (failStage && stageName === failStage) {
|
if (failStage && stageName === failStage) {
|
||||||
return {
|
return {
|
||||||
task_id: taskId,
|
task_id: taskId,
|
||||||
outcome: 'failed',
|
status: 'failed',
|
||||||
reason: 'mock task failure',
|
|
||||||
completed_at: new Date().toISOString(),
|
completed_at: new Date().toISOString(),
|
||||||
exit_code: 1,
|
exit_code: 1,
|
||||||
gate_results: [],
|
gate_results: [],
|
||||||
@@ -42,17 +41,10 @@ function createMockExecutor(options?: {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
task_id: taskId,
|
task_id: taskId,
|
||||||
outcome: 'passed',
|
status: 'completed',
|
||||||
reason: 'mock verified',
|
|
||||||
completed_at: new Date().toISOString(),
|
completed_at: new Date().toISOString(),
|
||||||
exit_code: 0,
|
exit_code: 0,
|
||||||
gate_results: (task?.qualityGates ?? [])
|
gate_results: [],
|
||||||
.filter((gate) => isCommandGate(gate))
|
|
||||||
.map((gate) => ({
|
|
||||||
gate: gateLabel(gate),
|
|
||||||
outcome: 'passed' as const,
|
|
||||||
reason: 'mock verified',
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
async getTaskStatus() {
|
async getTaskStatus() {
|
||||||
@@ -164,13 +156,12 @@ describe('runPipeline', () => {
|
|||||||
const executor = createMockExecutor();
|
const executor = createMockExecutor();
|
||||||
const result = await runPipeline(briefPath, tmpDir, {
|
const result = await runPipeline(briefPath, tmpDir, {
|
||||||
executor,
|
executor,
|
||||||
stages: ['00-intake', '05-coding'],
|
stages: ['00-intake', '00b-discovery'],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.runId).toMatch(/^\d{8}-\d{6}$/);
|
expect(result.runId).toMatch(/^\d{8}-\d{6}$/);
|
||||||
expect(result.stages).toEqual(['00-intake', '05-coding']);
|
expect(result.stages).toEqual(['00-intake', '00b-discovery']);
|
||||||
expect(result.manifest.status).toBe('completed');
|
expect(result.manifest.status).toBe('completed');
|
||||||
expect(result.manifest.mode).toBe('normal');
|
|
||||||
expect(executor.submittedTasks).toHaveLength(2);
|
expect(executor.submittedTasks).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -189,17 +180,12 @@ describe('runPipeline', () => {
|
|||||||
const executor = createMockExecutor();
|
const executor = createMockExecutor();
|
||||||
const result = await runPipeline(briefPath, tmpDir, {
|
const result = await runPipeline(briefPath, tmpDir, {
|
||||||
executor,
|
executor,
|
||||||
stages: ['00-intake', '05-coding'],
|
stages: ['00-intake', '00b-discovery'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const manifest = loadManifest(result.runDir);
|
const manifest = loadManifest(result.runDir);
|
||||||
expect(manifest.stages['00-intake']?.status).toBe('passed');
|
expect(manifest.stages['00-intake']?.status).toBe('passed');
|
||||||
expect(manifest.stages['05-coding']?.status).toBe('passed');
|
expect(manifest.stages['00b-discovery']?.status).toBe('passed');
|
||||||
expect(manifest.stages['05-coding']?.gateResults?.map((g) => g.outcome)).toEqual([
|
|
||||||
'passed',
|
|
||||||
'passed',
|
|
||||||
'passed',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('respects CLI class override', async () => {
|
it('respects CLI class override', async () => {
|
||||||
@@ -229,7 +215,7 @@ describe('runPipeline', () => {
|
|||||||
const executor = createMockExecutor();
|
const executor = createMockExecutor();
|
||||||
await runPipeline(briefPath, tmpDir, {
|
await runPipeline(briefPath, tmpDir, {
|
||||||
executor,
|
executor,
|
||||||
stages: ['00-intake', '05-coding', '08-test'],
|
stages: ['00-intake', '00b-discovery', '02-planning-1'],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(executor.submittedTasks[0]!.dependsOn).toBeUndefined();
|
expect(executor.submittedTasks[0]!.dependsOn).toBeUndefined();
|
||||||
@@ -238,14 +224,14 @@ describe('runPipeline', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles stage failure', async () => {
|
it('handles stage failure', async () => {
|
||||||
const executor = createMockExecutor({ failStage: '05-coding' });
|
const executor = createMockExecutor({ failStage: '00b-discovery' });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
runPipeline(briefPath, tmpDir, {
|
runPipeline(briefPath, tmpDir, {
|
||||||
executor,
|
executor,
|
||||||
stages: ['00-intake', '05-coding'],
|
stages: ['00-intake', '00b-discovery'],
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow('Stage 05-coding failed');
|
).rejects.toThrow('Stage 00b-discovery failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('marks manifest as failed on stage failure', async () => {
|
it('marks manifest as failed on stage failure', async () => {
|
||||||
@@ -284,143 +270,30 @@ describe('resumePipeline', () => {
|
|||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resumes from first incomplete stage and fails closed at the next provider gate', async () => {
|
it('resumes from first incomplete stage', async () => {
|
||||||
// Simulate a run whose authority stages were approved out-of-band
|
// First run fails on discovery
|
||||||
// (recorded as passed) and whose coding stage failed mechanically.
|
const executor1 = createMockExecutor({ failStage: '00b-discovery' });
|
||||||
const runId = '20260101-000000';
|
let runDir: string;
|
||||||
const runDir = path.join(tmpDir, '.forge', 'runs', runId);
|
|
||||||
fs.mkdirSync(runDir, { recursive: true });
|
|
||||||
const passed = { status: 'passed' as const, startedAt: '2026-01-01T00:00:00Z' };
|
|
||||||
saveManifest(runDir, {
|
|
||||||
runId,
|
|
||||||
brief: briefPath,
|
|
||||||
codebase: tmpDir,
|
|
||||||
briefClass: 'hotfix',
|
|
||||||
classSource: 'frontmatter',
|
|
||||||
forceBoard: false,
|
|
||||||
mode: 'normal',
|
|
||||||
createdAt: '2026-01-01T00:00:00Z',
|
|
||||||
updatedAt: '2026-01-01T00:00:00Z',
|
|
||||||
currentStage: '05-coding',
|
|
||||||
status: 'failed',
|
|
||||||
stages: {
|
|
||||||
'00-intake': passed,
|
|
||||||
'00b-discovery': passed,
|
|
||||||
'02-planning-1': passed,
|
|
||||||
'03-planning-2': passed,
|
|
||||||
'04-planning-3': passed,
|
|
||||||
'05-coding': { status: 'failed', reason: 'gate failed' },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Resume re-runs 05-coding (the first non-passed stage), then fails
|
try {
|
||||||
// closed at 06-review because no reviewer provider is wired.
|
await runPipeline(briefPath, tmpDir, {
|
||||||
const executor = createMockExecutor();
|
executor: executor1,
|
||||||
await expect(resumePipeline(runDir, executor)).rejects.toMatchObject({
|
stages: ['00-intake', '00b-discovery', '02-planning-1'],
|
||||||
name: 'ForgeCapabilityError',
|
});
|
||||||
code: 'FORGE_NO_REVIEWER',
|
} catch {
|
||||||
});
|
// expected
|
||||||
|
|
||||||
const manifest = loadManifest(runDir);
|
|
||||||
expect(manifest.stages['05-coding']?.status).toBe('passed');
|
|
||||||
expect(manifest.stages['06-review']?.status).toBe('blocked');
|
|
||||||
expect(manifest.status).toBe('failed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resumes to completion as simulated under explicit simulate', async () => {
|
|
||||||
const runId = '20260101-000003';
|
|
||||||
const runDir = path.join(tmpDir, '.forge', 'runs', runId);
|
|
||||||
fs.mkdirSync(runDir, { recursive: true });
|
|
||||||
const passed = { status: 'passed' as const, startedAt: '2026-01-01T00:00:00Z' };
|
|
||||||
saveManifest(runDir, {
|
|
||||||
runId,
|
|
||||||
brief: briefPath,
|
|
||||||
codebase: tmpDir,
|
|
||||||
briefClass: 'hotfix',
|
|
||||||
classSource: 'frontmatter',
|
|
||||||
forceBoard: false,
|
|
||||||
mode: 'normal',
|
|
||||||
createdAt: '2026-01-01T00:00:00Z',
|
|
||||||
updatedAt: '2026-01-01T00:00:00Z',
|
|
||||||
currentStage: '05-coding',
|
|
||||||
status: 'failed',
|
|
||||||
stages: {
|
|
||||||
'00-intake': passed,
|
|
||||||
'00b-discovery': passed,
|
|
||||||
'02-planning-1': passed,
|
|
||||||
'03-planning-2': passed,
|
|
||||||
'04-planning-3': passed,
|
|
||||||
'05-coding': { status: 'failed', reason: 'gate failed' },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await resumePipeline(runDir, undefined, { simulate: true });
|
|
||||||
|
|
||||||
expect(result.manifest.status).toBe('simulated');
|
|
||||||
expect(result.manifest.mode).toBe('simulated');
|
|
||||||
expect(result.stages[0]).toBe('05-coding');
|
|
||||||
for (const stage of result.stages) {
|
|
||||||
expect(result.manifest.stages[stage]?.status).toBe('simulated');
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed on resume when the next stage needs authority sign-off', async () => {
|
const runsDir = path.join(tmpDir, '.forge', 'runs');
|
||||||
const runId = '20260101-000001';
|
runDir = path.join(runsDir, fs.readdirSync(runsDir)[0]!);
|
||||||
const runDir = path.join(tmpDir, '.forge', 'runs', runId);
|
|
||||||
fs.mkdirSync(runDir, { recursive: true });
|
|
||||||
saveManifest(runDir, {
|
|
||||||
runId,
|
|
||||||
brief: briefPath,
|
|
||||||
codebase: tmpDir,
|
|
||||||
briefClass: 'hotfix',
|
|
||||||
classSource: 'frontmatter',
|
|
||||||
forceBoard: false,
|
|
||||||
mode: 'normal',
|
|
||||||
createdAt: '2026-01-01T00:00:00Z',
|
|
||||||
updatedAt: '2026-01-01T00:00:00Z',
|
|
||||||
currentStage: '00-intake',
|
|
||||||
status: 'in_progress',
|
|
||||||
stages: {
|
|
||||||
'00-intake': { status: 'passed' },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const executor = createMockExecutor();
|
// Resume should pick up from 00b-discovery
|
||||||
await expect(resumePipeline(runDir, executor)).rejects.toMatchObject({
|
const executor2 = createMockExecutor();
|
||||||
name: 'ForgeCapabilityError',
|
const result = await resumePipeline(runDir, executor2);
|
||||||
code: 'FORGE_AUTHORITY_REQUIRED',
|
|
||||||
});
|
|
||||||
|
|
||||||
const manifest = loadManifest(runDir);
|
expect(result.manifest.status).toBe('completed');
|
||||||
expect(manifest.stages['00b-discovery']?.status).toBe('waiting-for-authority');
|
// Should have re-run from 00b-discovery onward
|
||||||
expect(manifest.status).toBe('waiting-for-authority');
|
expect(result.stages[0]).toBe('00b-discovery');
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed on resume without an executor or --simulate', async () => {
|
|
||||||
const runId = '20260101-000002';
|
|
||||||
const runDir = path.join(tmpDir, '.forge', 'runs', runId);
|
|
||||||
fs.mkdirSync(runDir, { recursive: true });
|
|
||||||
saveManifest(runDir, {
|
|
||||||
runId,
|
|
||||||
brief: briefPath,
|
|
||||||
codebase: tmpDir,
|
|
||||||
briefClass: 'hotfix',
|
|
||||||
classSource: 'frontmatter',
|
|
||||||
forceBoard: false,
|
|
||||||
mode: 'normal',
|
|
||||||
createdAt: '2026-01-01T00:00:00Z',
|
|
||||||
updatedAt: '2026-01-01T00:00:00Z',
|
|
||||||
currentStage: '00-intake',
|
|
||||||
status: 'in_progress',
|
|
||||||
stages: {
|
|
||||||
'00-intake': { status: 'passed' },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(resumePipeline(runDir)).rejects.toMatchObject({
|
|
||||||
name: 'ForgeCapabilityError',
|
|
||||||
code: 'FORGE_NO_EXECUTOR',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -95,14 +95,7 @@ export function generateBoardTasks(
|
|||||||
briefPath,
|
briefPath,
|
||||||
resultPath: resultRelPath,
|
resultPath: resultRelPath,
|
||||||
timeoutSeconds: 120,
|
timeoutSeconds: 120,
|
||||||
qualityGates: [
|
qualityGates: ['true'],
|
||||||
{
|
|
||||||
kind: 'authority',
|
|
||||||
capability: 'board-approval',
|
|
||||||
reason:
|
|
||||||
'persona evaluation is judged by board synthesis (authority review); no mechanical gate exists',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
metadata: {
|
metadata: {
|
||||||
personaName: persona.name,
|
personaName: persona.name,
|
||||||
personaSlug: persona.slug,
|
personaSlug: persona.slug,
|
||||||
@@ -128,13 +121,7 @@ export function generateBoardTasks(
|
|||||||
timeoutSeconds: 120,
|
timeoutSeconds: 120,
|
||||||
dependsOn: personaTaskIds,
|
dependsOn: personaTaskIds,
|
||||||
dependsOnPolicy: 'all_terminal',
|
dependsOnPolicy: 'all_terminal',
|
||||||
qualityGates: [
|
qualityGates: ['true'],
|
||||||
{
|
|
||||||
kind: 'authority',
|
|
||||||
capability: 'board-approval',
|
|
||||||
reason: 'board synthesis is an authority decision; no mechanical gate exists',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
metadata: {
|
metadata: {
|
||||||
resultOutputPath: synthesisResult,
|
resultOutputPath: synthesisResult,
|
||||||
inputResultPaths: personaResultPaths,
|
inputResultPaths: personaResultPaths,
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { registerForgeCommand } from './cli.js';
|
import { registerForgeCommand } from './cli.js';
|
||||||
import { loadManifest } from './pipeline-runner.js';
|
|
||||||
|
|
||||||
describe('registerForgeCommand', () => {
|
describe('registerForgeCommand', () => {
|
||||||
it('registers a "forge" command on the parent program', () => {
|
it('registers a "forge" command on the parent program', () => {
|
||||||
@@ -59,94 +55,3 @@ describe('registerForgeCommand', () => {
|
|||||||
}).not.toThrow();
|
}).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('forge run fail-closed behavior (SDLC-D-035)', () => {
|
|
||||||
let tmpDir: string;
|
|
||||||
let briefPath: string;
|
|
||||||
let errSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
let prevExitCode: string | number | null | undefined;
|
|
||||||
|
|
||||||
const parse = (args: string[]) => {
|
|
||||||
const program = new Command();
|
|
||||||
registerForgeCommand(program);
|
|
||||||
return program.parseAsync(['forge', ...args], { from: 'user' });
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-cli-failclosed-'));
|
|
||||||
briefPath = path.join(tmpDir, 'brief.md');
|
|
||||||
fs.writeFileSync(briefPath, '# Fix bug\n\nA bugfix for lint cleanup.');
|
|
||||||
errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
||||||
prevExitCode = process.exitCode;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
errSpy.mockRestore();
|
|
||||||
logSpy.mockRestore();
|
|
||||||
process.exitCode = prevExitCode;
|
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exits nonzero with a typed FORGE_NO_EXECUTOR error when no executor is wired and --simulate is absent', async () => {
|
|
||||||
await parse(['run', '--brief', briefPath, '--codebase', tmpDir]);
|
|
||||||
|
|
||||||
expect(process.exitCode).toBe(1);
|
|
||||||
const errText = errSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
||||||
expect(errText).toContain('FORGE_NO_EXECUTOR');
|
|
||||||
// It must never run the pipeline with a stub and report success.
|
|
||||||
expect(fs.existsSync(path.join(tmpDir, '.forge', 'runs'))).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('completes with typed simulated results and exit 0 under explicit --simulate', async () => {
|
|
||||||
await parse(['run', '--brief', briefPath, '--codebase', tmpDir, '--simulate']);
|
|
||||||
|
|
||||||
expect(process.exitCode).toBeUndefined();
|
|
||||||
|
|
||||||
// Loud simulated-mode summary.
|
|
||||||
const logText = logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
||||||
expect(logText).toContain('SIMULATED');
|
|
||||||
|
|
||||||
// Manifest records the mode and simulated per-result statuses.
|
|
||||||
const runsDir = path.join(tmpDir, '.forge', 'runs');
|
|
||||||
const runIds = fs.readdirSync(runsDir);
|
|
||||||
expect(runIds).toHaveLength(1);
|
|
||||||
const manifest = loadManifest(path.join(runsDir, runIds[0]!));
|
|
||||||
expect(manifest.mode).toBe('simulated');
|
|
||||||
expect(manifest.status).toBe('simulated');
|
|
||||||
for (const stageStatus of Object.values(manifest.stages)) {
|
|
||||||
expect(stageStatus?.status).toBe('simulated');
|
|
||||||
for (const gateResult of stageStatus?.gateResults ?? []) {
|
|
||||||
expect(gateResult.outcome).toBe('simulated');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resume exits nonzero with a typed FORGE_NO_EXECUTOR error without --simulate', async () => {
|
|
||||||
const runDir = path.join(tmpDir, '.forge', 'runs', '20260101-000000');
|
|
||||||
fs.mkdirSync(runDir, { recursive: true });
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(runDir, 'manifest.json'),
|
|
||||||
JSON.stringify({
|
|
||||||
runId: '20260101-000000',
|
|
||||||
brief: briefPath,
|
|
||||||
codebase: tmpDir,
|
|
||||||
briefClass: 'hotfix',
|
|
||||||
classSource: 'frontmatter',
|
|
||||||
forceBoard: false,
|
|
||||||
createdAt: '2026-01-01T00:00:00Z',
|
|
||||||
updatedAt: '2026-01-01T00:00:00Z',
|
|
||||||
currentStage: '00-intake',
|
|
||||||
status: 'in_progress',
|
|
||||||
stages: { '00-intake': { status: 'passed' } },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await parse(['resume', '20260101-000000', '--project', tmpDir]);
|
|
||||||
|
|
||||||
expect(process.exitCode).toBe(1);
|
|
||||||
const errText = errSpy.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
||||||
expect(errText).toContain('FORGE_NO_EXECUTOR');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
+48
-122
@@ -5,47 +5,37 @@ import type { Command } from 'commander';
|
|||||||
|
|
||||||
import { classifyBrief } from './brief-classifier.js';
|
import { classifyBrief } from './brief-classifier.js';
|
||||||
import { STAGE_LABELS, STAGE_SEQUENCE } from './constants.js';
|
import { STAGE_LABELS, STAGE_SEQUENCE } from './constants.js';
|
||||||
import { ForgeCapabilityError } from './errors.js';
|
|
||||||
import { getEffectivePersonas, loadBoardPersonas } from './persona-loader.js';
|
import { getEffectivePersonas, loadBoardPersonas } from './persona-loader.js';
|
||||||
import { generateRunId, getPipelineStatus, loadManifest, runPipeline } from './pipeline-runner.js';
|
import { generateRunId, getPipelineStatus, loadManifest, runPipeline } from './pipeline-runner.js';
|
||||||
import { createSimulatedExecutor } from './simulated-executor.js';
|
import type { PipelineOptions, RunManifest, TaskExecutor } from './types.js';
|
||||||
import type { PipelineOptions, RunManifest, RunMode } from './types.js';
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stub executor — used when no real executor is wired at CLI invocation time.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const stubExecutor: TaskExecutor = {
|
||||||
|
async submitTask(task) {
|
||||||
|
console.log(` [forge] stage submitted: ${task.id} (${task.title})`);
|
||||||
|
},
|
||||||
|
async waitForCompletion(taskId, _timeoutMs) {
|
||||||
|
console.log(` [forge] stage complete: ${taskId}`);
|
||||||
|
return {
|
||||||
|
task_id: taskId,
|
||||||
|
status: 'completed' as const,
|
||||||
|
completed_at: new Date().toISOString(),
|
||||||
|
exit_code: 0,
|
||||||
|
gate_results: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async getTaskStatus(_taskId) {
|
||||||
|
return 'completed' as const;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Resolve a run's effective mode, defaulting legacy manifests to normal. */
|
|
||||||
function runModeOf(manifest: RunManifest): RunMode {
|
|
||||||
return manifest.mode ?? 'normal';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Print a loud banner so a simulated run can never be misread as verified. */
|
|
||||||
function printSimulatedBanner(): void {
|
|
||||||
console.log('');
|
|
||||||
console.log('[forge] ===============================================================');
|
|
||||||
console.log('[forge] MODE: SIMULATED — no stage or gate was really executed.');
|
|
||||||
console.log('[forge] All results are synthetic and MUST NOT be read as verified');
|
|
||||||
console.log('[forge] success. Wire a real executor/providers and re-run to verify.');
|
|
||||||
console.log('[forge] ===============================================================');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Print a typed error line for fail-closed capability errors. */
|
|
||||||
function printCapabilityError(err: ForgeCapabilityError): void {
|
|
||||||
console.error(`[forge] error ${err.code}: ${err.message}`);
|
|
||||||
console.error(`[forge] missing capability: ${err.capability}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Handle a pipeline error uniformly: typed capability errors get their code. */
|
|
||||||
function handlePipelineError(err: unknown): void {
|
|
||||||
if (err instanceof ForgeCapabilityError) {
|
|
||||||
printCapabilityError(err);
|
|
||||||
} else {
|
|
||||||
console.error(`[forge] pipeline failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
}
|
|
||||||
process.exitCode = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(startedAt?: string, completedAt?: string): string {
|
function formatDuration(startedAt?: string, completedAt?: string): string {
|
||||||
if (!startedAt || !completedAt) return '-';
|
if (!startedAt || !completedAt) return '-';
|
||||||
const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime();
|
const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime();
|
||||||
@@ -54,24 +44,19 @@ function formatDuration(startedAt?: string, completedAt?: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function printManifestTable(manifest: RunManifest): void {
|
function printManifestTable(manifest: RunManifest): void {
|
||||||
const mode = runModeOf(manifest);
|
|
||||||
console.log(`\nRun ID : ${manifest.runId}`);
|
console.log(`\nRun ID : ${manifest.runId}`);
|
||||||
console.log(`Status : ${manifest.status}`);
|
console.log(`Status : ${manifest.status}`);
|
||||||
console.log(`Mode : ${mode}`);
|
|
||||||
if (mode === 'simulated') {
|
|
||||||
console.log('WARNING: SIMULATED RUN — results are synthetic, not verified success.');
|
|
||||||
}
|
|
||||||
console.log(`Brief : ${manifest.brief}`);
|
console.log(`Brief : ${manifest.brief}`);
|
||||||
console.log(`Class : ${manifest.briefClass} (${manifest.classSource})`);
|
console.log(`Class : ${manifest.briefClass} (${manifest.classSource})`);
|
||||||
console.log(`Updated: ${manifest.updatedAt}`);
|
console.log(`Updated: ${manifest.updatedAt}`);
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('Stage'.padEnd(22) + 'Status'.padEnd(24) + 'Duration');
|
console.log('Stage'.padEnd(22) + 'Status'.padEnd(14) + 'Duration');
|
||||||
console.log('-'.repeat(60));
|
console.log('-'.repeat(50));
|
||||||
for (const stage of STAGE_SEQUENCE) {
|
for (const stage of STAGE_SEQUENCE) {
|
||||||
const s = manifest.stages[stage];
|
const s = manifest.stages[stage];
|
||||||
if (!s) continue;
|
if (!s) continue;
|
||||||
const label = (STAGE_LABELS[stage] ?? stage).padEnd(22);
|
const label = (STAGE_LABELS[stage] ?? stage).padEnd(22);
|
||||||
const status = s.status.padEnd(24);
|
const status = s.status.padEnd(14);
|
||||||
const dur = formatDuration(s.startedAt, s.completedAt);
|
const dur = formatDuration(s.startedAt, s.completedAt);
|
||||||
console.log(`${label}${status}${dur}`);
|
console.log(`${label}${status}${dur}`);
|
||||||
}
|
}
|
||||||
@@ -105,58 +90,23 @@ function listRecentRuns(projectRoot?: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('\nRecent runs:');
|
console.log('\nRecent runs:');
|
||||||
console.log('Run ID'.padEnd(22) + 'Status'.padEnd(24) + 'Mode'.padEnd(12) + 'Brief');
|
console.log('Run ID'.padEnd(22) + 'Status'.padEnd(14) + 'Brief');
|
||||||
console.log('-'.repeat(80));
|
console.log('-'.repeat(70));
|
||||||
|
|
||||||
for (const runId of entries) {
|
for (const runId of entries) {
|
||||||
const runDir = path.join(runsDir, runId);
|
const runDir = path.join(runsDir, runId);
|
||||||
try {
|
try {
|
||||||
const manifest = loadManifest(runDir);
|
const manifest = loadManifest(runDir);
|
||||||
const status = manifest.status.padEnd(24);
|
const status = manifest.status.padEnd(14);
|
||||||
const mode = runModeOf(manifest).padEnd(12);
|
|
||||||
const brief = path.basename(manifest.brief);
|
const brief = path.basename(manifest.brief);
|
||||||
console.log(`${runId.padEnd(22)}${status}${mode}${brief}`);
|
console.log(`${runId.padEnd(22)}${status}${brief}`);
|
||||||
} catch {
|
} catch {
|
||||||
console.log(`${runId.padEnd(22)}${'(unreadable)'.padEnd(24)}`);
|
console.log(`${runId.padEnd(22)}${'(unreadable)'.padEnd(14)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log('');
|
console.log('');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply the exit-code policy for a finished pipeline run (SDLC-D-035):
|
|
||||||
*
|
|
||||||
* - exit 0 only for a verified `completed` normal run, or for an overall
|
|
||||||
* `simulated` run when the caller explicitly passed --simulate;
|
|
||||||
* - anything else exits nonzero so it can never be read as success.
|
|
||||||
*/
|
|
||||||
function applyRunExitPolicy(result: { manifest: RunManifest; runDir: string }, simulate: boolean) {
|
|
||||||
const { manifest } = result;
|
|
||||||
|
|
||||||
if (runModeOf(manifest) === 'simulated') {
|
|
||||||
if (!simulate || manifest.status !== 'simulated') {
|
|
||||||
console.error(
|
|
||||||
'[forge] error FORGE_MODE_MISMATCH: run reports simulated results without an explicit, ' +
|
|
||||||
'consistent --simulate request; refusing to report success.',
|
|
||||||
);
|
|
||||||
process.exitCode = 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
printSimulatedBanner();
|
|
||||||
console.log(`[forge] run directory: ${result.runDir}`);
|
|
||||||
return; // exit 0 — the caller explicitly opted into simulation
|
|
||||||
}
|
|
||||||
|
|
||||||
if (manifest.status !== 'completed') {
|
|
||||||
console.error(`[forge] run did not complete: terminal status '${manifest.status}'`);
|
|
||||||
process.exitCode = 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[forge] pipeline complete (mode: normal): ${manifest.runId}`);
|
|
||||||
console.log(`[forge] run directory: ${result.runDir}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Register function
|
// Register function
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -179,11 +129,6 @@ export function registerForgeCommand(parent: Command): void {
|
|||||||
.option('--config <path>', 'Path to forge config file (.forge/config.yaml)')
|
.option('--config <path>', 'Path to forge config file (.forge/config.yaml)')
|
||||||
.option('--codebase <path>', 'Codebase root to pass to the pipeline', process.cwd())
|
.option('--codebase <path>', 'Codebase root to pass to the pipeline', process.cwd())
|
||||||
.option('--dry-run', 'Print planned stages without executing', false)
|
.option('--dry-run', 'Print planned stages without executing', false)
|
||||||
.option(
|
|
||||||
'--simulate',
|
|
||||||
'Simulate execution without real providers (every result is typed simulated, never verified)',
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
.action(
|
.action(
|
||||||
async (opts: {
|
async (opts: {
|
||||||
brief: string;
|
brief: string;
|
||||||
@@ -192,7 +137,6 @@ export function registerForgeCommand(parent: Command): void {
|
|||||||
config?: string;
|
config?: string;
|
||||||
codebase: string;
|
codebase: string;
|
||||||
dryRun: boolean;
|
dryRun: boolean;
|
||||||
simulate: boolean;
|
|
||||||
}) => {
|
}) => {
|
||||||
const briefPath = path.resolve(opts.brief);
|
const briefPath = path.resolve(opts.brief);
|
||||||
|
|
||||||
@@ -205,22 +149,14 @@ export function registerForgeCommand(parent: Command): void {
|
|||||||
const briefContent = fs.readFileSync(briefPath, 'utf-8');
|
const briefContent = fs.readFileSync(briefPath, 'utf-8');
|
||||||
const briefClass = classifyBrief(briefContent);
|
const briefClass = classifyBrief(briefContent);
|
||||||
const projectRoot = opts.codebase;
|
const projectRoot = opts.codebase;
|
||||||
// A real executor is never wired at CLI invocation time today, so the
|
|
||||||
// only executor we may construct is the explicitly-requested simulated
|
|
||||||
// one. Normal mode fails closed with FORGE_NO_EXECUTOR.
|
|
||||||
const executor = opts.simulate ? createSimulatedExecutor() : undefined;
|
|
||||||
|
|
||||||
if (opts.resume) {
|
if (opts.resume) {
|
||||||
const runId = opts.runId ?? generateRunId();
|
const runId = opts.runId ?? generateRunId();
|
||||||
const runDir = resolveRunDir(runId, projectRoot);
|
const runDir = resolveRunDir(runId, projectRoot);
|
||||||
console.log(`[forge] resuming run: ${runId}`);
|
console.log(`[forge] resuming run: ${runId}`);
|
||||||
try {
|
const { resumePipeline } = await import('./pipeline-runner.js');
|
||||||
const { resumePipeline } = await import('./pipeline-runner.js');
|
const result = await resumePipeline(runDir, stubExecutor);
|
||||||
const result = await resumePipeline(runDir, executor, { simulate: opts.simulate });
|
console.log(`[forge] pipeline complete: ${result.runId}`);
|
||||||
applyRunExitPolicy(result, opts.simulate);
|
|
||||||
} catch (err) {
|
|
||||||
handlePipelineError(err);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,8 +164,7 @@ export function registerForgeCommand(parent: Command): void {
|
|||||||
briefClass,
|
briefClass,
|
||||||
codebase: projectRoot,
|
codebase: projectRoot,
|
||||||
dryRun: opts.dryRun,
|
dryRun: opts.dryRun,
|
||||||
executor,
|
executor: stubExecutor,
|
||||||
simulate: opts.simulate,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (opts.dryRun) {
|
if (opts.dryRun) {
|
||||||
@@ -245,15 +180,16 @@ export function registerForgeCommand(parent: Command): void {
|
|||||||
|
|
||||||
console.log(`[forge] starting pipeline for brief: ${briefPath}`);
|
console.log(`[forge] starting pipeline for brief: ${briefPath}`);
|
||||||
console.log(`[forge] classified as: ${briefClass}`);
|
console.log(`[forge] classified as: ${briefClass}`);
|
||||||
if (opts.simulate) {
|
|
||||||
console.log('[forge] mode: SIMULATED (explicit --simulate)');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runPipeline(briefPath, projectRoot, pipelineOptions);
|
const result = await runPipeline(briefPath, projectRoot, pipelineOptions);
|
||||||
applyRunExitPolicy(result, opts.simulate);
|
console.log(`[forge] pipeline complete: ${result.runId}`);
|
||||||
|
console.log(`[forge] run directory: ${result.runDir}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handlePipelineError(err);
|
console.error(
|
||||||
|
`[forge] pipeline failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -288,12 +224,7 @@ export function registerForgeCommand(parent: Command): void {
|
|||||||
.command('resume <runId>')
|
.command('resume <runId>')
|
||||||
.description('Resume a stopped or failed pipeline run')
|
.description('Resume a stopped or failed pipeline run')
|
||||||
.option('--project <path>', 'Project root (defaults to cwd)', process.cwd())
|
.option('--project <path>', 'Project root (defaults to cwd)', process.cwd())
|
||||||
.option(
|
.action(async (runId: string, opts: { project: string }) => {
|
||||||
'--simulate',
|
|
||||||
'Simulate execution without real providers (every result is typed simulated, never verified)',
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
.action(async (runId: string, opts: { project: string; simulate: boolean }) => {
|
|
||||||
const runDir = resolveRunDir(runId, opts.project);
|
const runDir = resolveRunDir(runId, opts.project);
|
||||||
|
|
||||||
if (!fs.existsSync(runDir)) {
|
if (!fs.existsSync(runDir)) {
|
||||||
@@ -303,20 +234,15 @@ export function registerForgeCommand(parent: Command): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[forge] resuming run: ${runId}`);
|
console.log(`[forge] resuming run: ${runId}`);
|
||||||
if (opts.simulate) {
|
|
||||||
console.log('[forge] mode: SIMULATED (explicit --simulate)');
|
|
||||||
}
|
|
||||||
|
|
||||||
// No real executor is wired at CLI invocation time; only the explicitly
|
|
||||||
// requested simulated executor may be constructed (fail closed otherwise).
|
|
||||||
const executor = opts.simulate ? createSimulatedExecutor() : undefined;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { resumePipeline } = await import('./pipeline-runner.js');
|
const { resumePipeline } = await import('./pipeline-runner.js');
|
||||||
const result = await resumePipeline(runDir, executor, { simulate: opts.simulate });
|
const result = await resumePipeline(runDir, stubExecutor);
|
||||||
applyRunExitPolicy(result, opts.simulate);
|
console.log(`[forge] pipeline complete: ${result.runId}`);
|
||||||
|
console.log(`[forge] run directory: ${result.runDir}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handlePipelineError(err);
|
console.error(`[forge] resume failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
process.exitCode = 1;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,16 +9,7 @@ export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.
|
|||||||
/** Pipeline asset directory (stages, agents, rails, gates, templates). */
|
/** Pipeline asset directory (stages, agents, rails, gates, templates). */
|
||||||
export const PIPELINE_DIR = path.join(PACKAGE_ROOT, 'pipeline');
|
export const PIPELINE_DIR = path.join(PACKAGE_ROOT, 'pipeline');
|
||||||
|
|
||||||
/** Stage specifications — defines every pipeline stage.
|
/** Stage specifications — defines every pipeline stage. */
|
||||||
*\n * Gate semantics (SDLC-D-035): every gate is one of
|
|
||||||
* - a real command string / GateEntry a mechanical runner can execute,
|
|
||||||
* - an `authority` gate (human/board sign-off; produces waiting-for-authority),
|
|
||||||
* - a `provider` gate (requires a wired provider such as a reviewer or CI pipeline).
|
|
||||||
*
|
|
||||||
* Vacuous gates (`true`, echo'd synthetic approvals, placeholder ci-pipeline
|
|
||||||
* commands) are forbidden: a stage whose gate has no real implementation
|
|
||||||
* fails closed instead of passing.
|
|
||||||
*/
|
|
||||||
export const STAGE_SPECS: Record<string, StageSpec> = {
|
export const STAGE_SPECS: Record<string, StageSpec> = {
|
||||||
'00-intake': {
|
'00-intake': {
|
||||||
number: '00',
|
number: '00',
|
||||||
@@ -36,13 +27,7 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
|
|||||||
type: 'research',
|
type: 'research',
|
||||||
gate: 'discovery-complete',
|
gate: 'discovery-complete',
|
||||||
promptFile: '00b-discovery.md',
|
promptFile: '00b-discovery.md',
|
||||||
qualityGates: [
|
qualityGates: ['true'],
|
||||||
{
|
|
||||||
kind: 'authority',
|
|
||||||
capability: 'discovery-complete',
|
|
||||||
reason: 'discovery completion is attested by an authority; no mechanical check exists',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
'01-board': {
|
'01-board': {
|
||||||
number: '01',
|
number: '01',
|
||||||
@@ -51,13 +36,7 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
|
|||||||
type: 'review',
|
type: 'review',
|
||||||
gate: 'board-approval',
|
gate: 'board-approval',
|
||||||
promptFile: '01-board.md',
|
promptFile: '01-board.md',
|
||||||
qualityGates: [
|
qualityGates: [{ type: 'ci-pipeline', command: 'board-approval (via board-tasks)' }],
|
||||||
{
|
|
||||||
kind: 'authority',
|
|
||||||
capability: 'board-approval',
|
|
||||||
reason: 'board approval is a board/human decision; no mechanical gate exists',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
'01b-brief-analyzer': {
|
'01b-brief-analyzer': {
|
||||||
number: '01b',
|
number: '01b',
|
||||||
@@ -66,13 +45,7 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
|
|||||||
type: 'research',
|
type: 'research',
|
||||||
gate: 'brief-analysis-complete',
|
gate: 'brief-analysis-complete',
|
||||||
promptFile: '01-board.md',
|
promptFile: '01-board.md',
|
||||||
qualityGates: [
|
qualityGates: ['true'],
|
||||||
{
|
|
||||||
kind: 'authority',
|
|
||||||
capability: 'brief-analysis-complete',
|
|
||||||
reason: 'brief analysis completion is attested by an authority; no mechanical check exists',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
'02-planning-1': {
|
'02-planning-1': {
|
||||||
number: '02',
|
number: '02',
|
||||||
@@ -81,13 +54,7 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
|
|||||||
type: 'research',
|
type: 'research',
|
||||||
gate: 'architecture-approval',
|
gate: 'architecture-approval',
|
||||||
promptFile: '02-planning-1-architecture.md',
|
promptFile: '02-planning-1-architecture.md',
|
||||||
qualityGates: [
|
qualityGates: ['true'],
|
||||||
{
|
|
||||||
kind: 'authority',
|
|
||||||
capability: 'architecture-approval',
|
|
||||||
reason: 'ADR approval requires authority sign-off; no mechanical check exists',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
'03-planning-2': {
|
'03-planning-2': {
|
||||||
number: '03',
|
number: '03',
|
||||||
@@ -96,14 +63,7 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
|
|||||||
type: 'research',
|
type: 'research',
|
||||||
gate: 'implementation-approval',
|
gate: 'implementation-approval',
|
||||||
promptFile: '03-planning-2-implementation.md',
|
promptFile: '03-planning-2-implementation.md',
|
||||||
qualityGates: [
|
qualityGates: ['true'],
|
||||||
{
|
|
||||||
kind: 'authority',
|
|
||||||
capability: 'implementation-approval',
|
|
||||||
reason:
|
|
||||||
'implementation spec approval requires authority sign-off; no mechanical check exists',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
'04-planning-3': {
|
'04-planning-3': {
|
||||||
number: '04',
|
number: '04',
|
||||||
@@ -112,14 +72,7 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
|
|||||||
type: 'research',
|
type: 'research',
|
||||||
gate: 'decomposition-approval',
|
gate: 'decomposition-approval',
|
||||||
promptFile: '04-planning-3-decomposition.md',
|
promptFile: '04-planning-3-decomposition.md',
|
||||||
qualityGates: [
|
qualityGates: ['true'],
|
||||||
{
|
|
||||||
kind: 'authority',
|
|
||||||
capability: 'decomposition-approval',
|
|
||||||
reason:
|
|
||||||
'task decomposition approval requires authority sign-off; no mechanical check exists',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
'05-coding': {
|
'05-coding': {
|
||||||
number: '05',
|
number: '05',
|
||||||
@@ -139,10 +92,9 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
|
|||||||
promptFile: '06-review.md',
|
promptFile: '06-review.md',
|
||||||
qualityGates: [
|
qualityGates: [
|
||||||
{
|
{
|
||||||
kind: 'provider',
|
type: 'ai-review',
|
||||||
capability: 'reviewer',
|
command:
|
||||||
reason:
|
'echo \'{"summary":"review-pass","verdict":"approve","findings":[],"stats":{"blockers":0,"should_fix":0,"suggestions":0}}\'',
|
||||||
'review verdicts require a wired reviewer provider; synthetic approvals are not permitted',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -153,13 +105,7 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
|
|||||||
type: 'coding',
|
type: 'coding',
|
||||||
gate: 're-review',
|
gate: 're-review',
|
||||||
promptFile: '07-remediate.md',
|
promptFile: '07-remediate.md',
|
||||||
qualityGates: [
|
qualityGates: ['true'],
|
||||||
{
|
|
||||||
kind: 'authority',
|
|
||||||
capability: 're-review',
|
|
||||||
reason: 'remediation re-review is an approval-based gate; no mechanical check exists',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
'08-test': {
|
'08-test': {
|
||||||
number: '08',
|
number: '08',
|
||||||
@@ -177,13 +123,7 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
|
|||||||
type: 'deploy',
|
type: 'deploy',
|
||||||
gate: 'deploy-verification',
|
gate: 'deploy-verification',
|
||||||
promptFile: '09-deploy.md',
|
promptFile: '09-deploy.md',
|
||||||
qualityGates: [
|
qualityGates: [{ type: 'ci-pipeline', command: 'deploy-verification' }],
|
||||||
{
|
|
||||||
kind: 'provider',
|
|
||||||
capability: 'ci-pipeline',
|
|
||||||
reason: 'deploy verification requires a wired CI pipeline provider',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
/**
|
|
||||||
* Typed fail-closed capability errors (SDLC-D-035).
|
|
||||||
*
|
|
||||||
* A Forge run must fail closed when a required capability (executor, reviewer
|
|
||||||
* provider, CI pipeline, authority sign-off) is missing. These typed errors
|
|
||||||
* name the missing capability so callers can distinguish "not wired" from
|
|
||||||
* ordinary execution failures.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Closed set of typed Forge capability error codes. */
|
|
||||||
export const FORGE_ERROR_CODES = [
|
|
||||||
'FORGE_NO_EXECUTOR',
|
|
||||||
'FORGE_NO_REVIEWER',
|
|
||||||
'FORGE_NO_CI_PIPELINE',
|
|
||||||
'FORGE_NO_PROVIDER',
|
|
||||||
'FORGE_AUTHORITY_REQUIRED',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type ForgeErrorCode = (typeof FORGE_ERROR_CODES)[number];
|
|
||||||
|
|
||||||
/** Raised when a required capability is missing and the pipeline must fail closed. */
|
|
||||||
export class ForgeCapabilityError extends Error {
|
|
||||||
/** Typed error code from the closed FORGE_ERROR_CODES set. */
|
|
||||||
readonly code: ForgeErrorCode;
|
|
||||||
/** The missing capability, e.g. `task-executor`, `reviewer`, `board-approval`. */
|
|
||||||
readonly capability: string;
|
|
||||||
|
|
||||||
constructor(code: ForgeErrorCode, capability: string, message: string) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'ForgeCapabilityError';
|
|
||||||
this.code = code;
|
|
||||||
this.capability = capability;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Map a provider gate capability to its typed error code. */
|
|
||||||
export function providerErrorCode(capability: string): ForgeErrorCode {
|
|
||||||
switch (capability) {
|
|
||||||
case 'reviewer':
|
|
||||||
return 'FORGE_NO_REVIEWER';
|
|
||||||
case 'ci-pipeline':
|
|
||||||
return 'FORGE_NO_CI_PIPELINE';
|
|
||||||
default:
|
|
||||||
return 'FORGE_NO_PROVIDER';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,13 +5,6 @@ export type {
|
|||||||
StageSpec,
|
StageSpec,
|
||||||
BriefClass,
|
BriefClass,
|
||||||
ClassSource,
|
ClassSource,
|
||||||
ForgeOutcome,
|
|
||||||
AuthorityGate,
|
|
||||||
ProviderGate,
|
|
||||||
ForgeGate,
|
|
||||||
ForgeGateResult,
|
|
||||||
ForgeTaskResult,
|
|
||||||
RunMode,
|
|
||||||
StageStatus,
|
StageStatus,
|
||||||
RunManifest,
|
RunManifest,
|
||||||
ForgeTaskStatus,
|
ForgeTaskStatus,
|
||||||
@@ -88,24 +81,5 @@ export {
|
|||||||
getPipelineStatus,
|
getPipelineStatus,
|
||||||
} from './pipeline-runner.js';
|
} from './pipeline-runner.js';
|
||||||
|
|
||||||
// Fail-closed errors and typed outcome model (SDLC-D-035)
|
|
||||||
export { FORGE_ERROR_CODES, ForgeCapabilityError, providerErrorCode } from './errors.js';
|
|
||||||
export type { ForgeErrorCode } from './errors.js';
|
|
||||||
export {
|
|
||||||
isSatisfyingOutcome,
|
|
||||||
isCapabilityGate,
|
|
||||||
isCommandGate,
|
|
||||||
gateLabel,
|
|
||||||
uniformGateResults,
|
|
||||||
simulatedGateResults,
|
|
||||||
waitingGateResults,
|
|
||||||
blockedGateResults,
|
|
||||||
evaluateStageGates,
|
|
||||||
} from './outcomes.js';
|
|
||||||
export type { StageEvaluation } from './outcomes.js';
|
|
||||||
|
|
||||||
// Simulated executor (explicit --simulate only)
|
|
||||||
export { createSimulatedExecutor } from './simulated-executor.js';
|
|
||||||
|
|
||||||
// CLI
|
// CLI
|
||||||
export { registerForgeCommand } from './cli.js';
|
export { registerForgeCommand } from './cli.js';
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
import type { GateEntry } from '@mosaicstack/macp';
|
|
||||||
|
|
||||||
import type {
|
|
||||||
AuthorityGate,
|
|
||||||
ForgeGate,
|
|
||||||
ForgeGateResult,
|
|
||||||
ForgeOutcome,
|
|
||||||
ForgeTaskResult,
|
|
||||||
ProviderGate,
|
|
||||||
} from './types.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gate and dependency satisfaction predicate (SDLC-D-035).
|
|
||||||
*
|
|
||||||
* ONLY a verified `passed` outcome satisfies. Every other member of the closed
|
|
||||||
* outcome set — including `simulated` — is non-satisfying, so a simulated or
|
|
||||||
* authority-blocked result can never be read as success-by-verification.
|
|
||||||
*/
|
|
||||||
export function isSatisfyingOutcome(outcome: ForgeOutcome): boolean {
|
|
||||||
return outcome === 'passed';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether a gate is an authority or provider gate (capability-based, command-less). */
|
|
||||||
export function isCapabilityGate(gate: ForgeGate): gate is AuthorityGate | ProviderGate {
|
|
||||||
if (typeof gate !== 'object' || gate === null) return false;
|
|
||||||
const kind = (gate as Record<string, unknown>)['kind'];
|
|
||||||
return kind === 'authority' || kind === 'provider';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether a gate definition carries a real command a mechanical runner can execute. */
|
|
||||||
export function isCommandGate(gate: ForgeGate): gate is string | GateEntry {
|
|
||||||
if (typeof gate === 'string') {
|
|
||||||
return gate.trim().length > 0;
|
|
||||||
}
|
|
||||||
if (isCapabilityGate(gate)) {
|
|
||||||
// Authority and provider gates are satisfied by a capability, not a command.
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return typeof gate.command === 'string' && gate.command.trim().length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Typed label identifying a gate in results and logs. */
|
|
||||||
export function gateLabel(gate: ForgeGate): string {
|
|
||||||
if (typeof gate === 'string') return gate;
|
|
||||||
if (isCapabilityGate(gate)) return `${gate.kind}:${gate.capability}`;
|
|
||||||
return gate.command || gate.type || 'unnamed-gate';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reason string stamped on every simulated gate result. */
|
|
||||||
export const SIMULATED_GATE_REASON =
|
|
||||||
'simulated execution (--simulate): gate was not evaluated by a real implementation';
|
|
||||||
|
|
||||||
/** Build typed gate results with a uniform outcome for a stage's declared gates. */
|
|
||||||
export function uniformGateResults(
|
|
||||||
gates: ForgeGate[],
|
|
||||||
outcome: ForgeOutcome,
|
|
||||||
reason: string,
|
|
||||||
): ForgeGateResult[] {
|
|
||||||
return gates.map((gate) => ({ gate: gateLabel(gate), outcome, reason }));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Typed simulated gate results — used exclusively in `--simulate` runs. */
|
|
||||||
export function simulatedGateResults(gates: ForgeGate[]): ForgeGateResult[] {
|
|
||||||
return uniformGateResults(gates, 'simulated', SIMULATED_GATE_REASON);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Typed waiting-for-authority gate results for approval-based stages. */
|
|
||||||
export function waitingGateResults(gates: ForgeGate[], reason: string): ForgeGateResult[] {
|
|
||||||
return uniformGateResults(gates, 'waiting-for-authority', reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Typed blocked gate results for stages whose provider capability is not wired. */
|
|
||||||
export function blockedGateResults(gates: ForgeGate[], reason: string): ForgeGateResult[] {
|
|
||||||
return uniformGateResults(gates, 'blocked', reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Outcome of evaluating a completed stage in normal mode. */
|
|
||||||
export interface StageEvaluation {
|
|
||||||
outcome: ForgeOutcome;
|
|
||||||
reason: string;
|
|
||||||
gateResults: ForgeGateResult[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Evaluate a stage's declared gates against the executor's typed result.
|
|
||||||
*
|
|
||||||
* Fail-closed mapping:
|
|
||||||
* - a `simulated` task or gate outcome in normal mode maps to `error`
|
|
||||||
* - a missing gate result for a required command gate maps to `blocked`
|
|
||||||
* - a non-passing task outcome propagates as the stage outcome
|
|
||||||
* - only verified `passed` task and gate outcomes yield a `passed` stage
|
|
||||||
*/
|
|
||||||
export function evaluateStageGates(
|
|
||||||
stageName: string,
|
|
||||||
gates: ForgeGate[],
|
|
||||||
result: ForgeTaskResult,
|
|
||||||
): StageEvaluation {
|
|
||||||
const gateResults = result.gate_results ?? [];
|
|
||||||
|
|
||||||
if (result.outcome === 'simulated') {
|
|
||||||
return {
|
|
||||||
outcome: 'error',
|
|
||||||
reason: `executor reported a simulated outcome for stage '${stageName}' in normal mode — refusing to treat simulated results as verified`,
|
|
||||||
gateResults,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isSatisfyingOutcome(result.outcome)) {
|
|
||||||
return {
|
|
||||||
outcome: result.outcome,
|
|
||||||
reason: `task outcome is '${result.outcome}': ${result.reason}`,
|
|
||||||
gateResults,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const gate of gates) {
|
|
||||||
// Authority and provider gates are pre-flighted before execution; they have
|
|
||||||
// no mechanical result to verify here.
|
|
||||||
if (!isCommandGate(gate)) continue;
|
|
||||||
|
|
||||||
const label = gateLabel(gate);
|
|
||||||
const gateResult = gateResults.find((r) => r.gate === label);
|
|
||||||
if (!gateResult) {
|
|
||||||
return {
|
|
||||||
outcome: 'blocked',
|
|
||||||
reason: `no gate result was reported for required gate '${label}' (stage '${stageName}')`,
|
|
||||||
gateResults,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!isSatisfyingOutcome(gateResult.outcome)) {
|
|
||||||
return {
|
|
||||||
outcome: gateResult.outcome === 'simulated' ? 'error' : gateResult.outcome,
|
|
||||||
reason: `gate '${label}' outcome is '${gateResult.outcome}': ${gateResult.reason}`,
|
|
||||||
gateResults,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
outcome: 'passed',
|
|
||||||
reason:
|
|
||||||
gates.length === 0
|
|
||||||
? "stage declares no gates; task outcome 'passed' accepted"
|
|
||||||
: 'all declared gates verified passed',
|
|
||||||
gateResults,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,33 +1,18 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { STAGE_SEQUENCE, STAGE_SPECS } from './constants.js';
|
import { STAGE_SEQUENCE } from './constants.js';
|
||||||
import { determineBriefClass, stagesForClass } from './brief-classifier.js';
|
import { determineBriefClass, stagesForClass } from './brief-classifier.js';
|
||||||
import { ForgeCapabilityError, providerErrorCode } from './errors.js';
|
|
||||||
import {
|
|
||||||
blockedGateResults,
|
|
||||||
evaluateStageGates,
|
|
||||||
isCapabilityGate,
|
|
||||||
simulatedGateResults,
|
|
||||||
waitingGateResults,
|
|
||||||
} from './outcomes.js';
|
|
||||||
import { mapStageToTask } from './stage-adapter.js';
|
import { mapStageToTask } from './stage-adapter.js';
|
||||||
import { createSimulatedExecutor } from './simulated-executor.js';
|
|
||||||
import type {
|
import type {
|
||||||
ForgeTask,
|
ForgeTask,
|
||||||
ForgeTaskResult,
|
|
||||||
PipelineOptions,
|
PipelineOptions,
|
||||||
PipelineResult,
|
PipelineResult,
|
||||||
RunManifest,
|
RunManifest,
|
||||||
RunMode,
|
|
||||||
StageStatus,
|
StageStatus,
|
||||||
TaskExecutor,
|
TaskExecutor,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
/** Reason stamped on stages that complete under explicit simulation. */
|
|
||||||
const SIMULATED_STAGE_REASON =
|
|
||||||
'simulated execution (--simulate): stage was not executed by a real executor';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a timestamp-based run ID.
|
* Generate a timestamp-based run ID.
|
||||||
*/
|
*/
|
||||||
@@ -62,7 +47,6 @@ function createManifest(opts: {
|
|||||||
briefClass: RunManifest['briefClass'];
|
briefClass: RunManifest['briefClass'];
|
||||||
classSource: RunManifest['classSource'];
|
classSource: RunManifest['classSource'];
|
||||||
forceBoard: boolean;
|
forceBoard: boolean;
|
||||||
mode: RunMode;
|
|
||||||
runDir: string;
|
runDir: string;
|
||||||
}): RunManifest {
|
}): RunManifest {
|
||||||
const ts = nowISO();
|
const ts = nowISO();
|
||||||
@@ -73,7 +57,6 @@ function createManifest(opts: {
|
|||||||
briefClass: opts.briefClass,
|
briefClass: opts.briefClass,
|
||||||
classSource: opts.classSource,
|
classSource: opts.classSource,
|
||||||
forceBoard: opts.forceBoard,
|
forceBoard: opts.forceBoard,
|
||||||
mode: opts.mode,
|
|
||||||
createdAt: ts,
|
createdAt: ts,
|
||||||
updatedAt: ts,
|
updatedAt: ts,
|
||||||
currentStage: '',
|
currentStage: '',
|
||||||
@@ -125,199 +108,20 @@ export function selectStages(stages?: string[], skipTo?: string): string[] {
|
|||||||
return selected.slice(skipIndex);
|
return selected.slice(skipIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fail closed when the required executor capability is missing (SDLC-D-035).
|
|
||||||
*/
|
|
||||||
function requireExecutor(executor: TaskExecutor | undefined, simulate: boolean): TaskExecutor {
|
|
||||||
if (executor) return executor;
|
|
||||||
if (simulate) return createSimulatedExecutor({ log: false });
|
|
||||||
throw new ForgeCapabilityError(
|
|
||||||
'FORGE_NO_EXECUTOR',
|
|
||||||
'task-executor',
|
|
||||||
'no task executor is wired; refusing to run the pipeline with a stub executor (fail closed). ' +
|
|
||||||
'Pass --simulate to opt into explicitly simulated execution.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pre-flight a stage's gates in normal mode (fail closed, SDLC-D-035).
|
|
||||||
*
|
|
||||||
* - authority gates: record a typed `waiting-for-authority` stage result and
|
|
||||||
* raise FORGE_AUTHORITY_REQUIRED — approval-based gates never pass vacuously.
|
|
||||||
* - provider gates: record a typed `blocked` stage result and raise the typed
|
|
||||||
* capability error for the missing provider.
|
|
||||||
*
|
|
||||||
* Returns the stage status to record when the pre-flight blocks, or undefined
|
|
||||||
* when the stage may proceed.
|
|
||||||
*/
|
|
||||||
function preflightStageGates(
|
|
||||||
stageName: string,
|
|
||||||
manifest: RunManifest,
|
|
||||||
): { status: StageStatus; error: ForgeCapabilityError } | undefined {
|
|
||||||
const spec = STAGE_SPECS[stageName];
|
|
||||||
if (!spec) throw new Error(`Unknown Forge stage: ${stageName}`);
|
|
||||||
|
|
||||||
for (const gate of spec.qualityGates) {
|
|
||||||
if (!isCapabilityGate(gate)) continue;
|
|
||||||
|
|
||||||
const startedAt = manifest.stages[stageName]?.startedAt;
|
|
||||||
const completedAt = nowISO();
|
|
||||||
|
|
||||||
if (gate.kind === 'authority') {
|
|
||||||
const reason = `gate '${gate.capability}' requires authority sign-off; no mechanical implementation exists (${gate.reason})`;
|
|
||||||
return {
|
|
||||||
status: {
|
|
||||||
status: 'waiting-for-authority',
|
|
||||||
reason,
|
|
||||||
startedAt,
|
|
||||||
completedAt,
|
|
||||||
gateResults: waitingGateResults(spec.qualityGates, reason),
|
|
||||||
},
|
|
||||||
error: new ForgeCapabilityError(
|
|
||||||
'FORGE_AUTHORITY_REQUIRED',
|
|
||||||
gate.capability,
|
|
||||||
`stage '${stageName}' is blocked on authority gate '${gate.capability}': ${gate.reason}. ` +
|
|
||||||
'The pipeline fails closed instead of passing vacuously. Record the approval out-of-band ' +
|
|
||||||
'or run with --simulate for explicitly simulated execution.',
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const reason = `gate '${gate.capability}' requires provider '${gate.capability}' and none is wired (${gate.reason})`;
|
|
||||||
return {
|
|
||||||
status: {
|
|
||||||
status: 'blocked',
|
|
||||||
reason,
|
|
||||||
startedAt,
|
|
||||||
completedAt,
|
|
||||||
gateResults: blockedGateResults(spec.qualityGates, reason),
|
|
||||||
},
|
|
||||||
error: new ForgeCapabilityError(
|
|
||||||
providerErrorCode(gate.capability),
|
|
||||||
gate.capability,
|
|
||||||
`stage '${stageName}' requires provider '${gate.capability}' which is not wired: ${gate.reason}. ` +
|
|
||||||
'The pipeline fails closed instead of passing vacuously.',
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the given stage tasks sequentially, updating the manifest.
|
|
||||||
*
|
|
||||||
* Normal mode requires a real executor and evaluates every declared command
|
|
||||||
* gate through the typed outcome model; any non-verified result fails closed.
|
|
||||||
* Simulate mode types every stage and gate result as `simulated`.
|
|
||||||
*/
|
|
||||||
async function executeStages(opts: {
|
|
||||||
manifest: RunManifest;
|
|
||||||
runDir: string;
|
|
||||||
tasks: ForgeTask[];
|
|
||||||
stageNames: string[];
|
|
||||||
executor: TaskExecutor;
|
|
||||||
simulate: boolean;
|
|
||||||
}): Promise<void> {
|
|
||||||
const { manifest, runDir, tasks, stageNames, executor, simulate } = opts;
|
|
||||||
|
|
||||||
for (let i = 0; i < tasks.length; i++) {
|
|
||||||
const task = tasks[i]!;
|
|
||||||
const stageName = stageNames[i]!;
|
|
||||||
const spec = STAGE_SPECS[stageName];
|
|
||||||
if (!spec) throw new Error(`Unknown Forge stage: ${stageName}`);
|
|
||||||
|
|
||||||
// Update manifest: stage in progress
|
|
||||||
manifest.currentStage = stageName;
|
|
||||||
manifest.stages[stageName] = {
|
|
||||||
status: 'in_progress',
|
|
||||||
startedAt: nowISO(),
|
|
||||||
};
|
|
||||||
saveManifest(runDir, manifest);
|
|
||||||
|
|
||||||
// Fail-closed pre-flight (normal mode only): authority/provider gates have
|
|
||||||
// no mechanical implementation and must never pass vacuously.
|
|
||||||
if (!simulate) {
|
|
||||||
const blocked = preflightStageGates(stageName, manifest);
|
|
||||||
if (blocked) {
|
|
||||||
manifest.stages[stageName] = blocked.status;
|
|
||||||
manifest.status =
|
|
||||||
blocked.status.status === 'waiting-for-authority' ? 'waiting-for-authority' : 'failed';
|
|
||||||
saveManifest(runDir, manifest);
|
|
||||||
throw blocked.error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let result: ForgeTaskResult;
|
|
||||||
try {
|
|
||||||
await executor.submitTask(task);
|
|
||||||
result = await executor.waitForCompletion(task.id, task.timeoutSeconds * 1000);
|
|
||||||
} catch (error) {
|
|
||||||
// Process errors (including timeouts) map to the fail-closed `error` outcome.
|
|
||||||
const reason = error instanceof Error ? error.message : String(error);
|
|
||||||
manifest.stages[stageName] = {
|
|
||||||
status: 'error',
|
|
||||||
reason: `executor error: ${reason}`,
|
|
||||||
startedAt: manifest.stages[stageName]?.startedAt,
|
|
||||||
completedAt: nowISO(),
|
|
||||||
gateResults: [],
|
|
||||||
};
|
|
||||||
manifest.status = 'failed';
|
|
||||||
saveManifest(runDir, manifest);
|
|
||||||
throw error instanceof Error ? error : new Error(reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (simulate) {
|
|
||||||
manifest.stages[stageName] = {
|
|
||||||
status: 'simulated',
|
|
||||||
reason: SIMULATED_STAGE_REASON,
|
|
||||||
startedAt: manifest.stages[stageName]?.startedAt,
|
|
||||||
completedAt: nowISO(),
|
|
||||||
gateResults: simulatedGateResults(spec.qualityGates),
|
|
||||||
};
|
|
||||||
saveManifest(runDir, manifest);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const evaluation = evaluateStageGates(stageName, spec.qualityGates, result);
|
|
||||||
manifest.stages[stageName] = {
|
|
||||||
status: evaluation.outcome,
|
|
||||||
reason: evaluation.reason,
|
|
||||||
startedAt: manifest.stages[stageName]?.startedAt,
|
|
||||||
completedAt: nowISO(),
|
|
||||||
gateResults: evaluation.gateResults,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (evaluation.outcome !== 'passed') {
|
|
||||||
manifest.status =
|
|
||||||
evaluation.outcome === 'waiting-for-authority' ? 'waiting-for-authority' : 'failed';
|
|
||||||
saveManifest(runDir, manifest);
|
|
||||||
throw new Error(`Stage ${stageName} ${evaluation.outcome}: ${evaluation.reason}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
saveManifest(runDir, manifest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run the Forge pipeline.
|
* Run the Forge pipeline.
|
||||||
*
|
*
|
||||||
* 1. Fail closed unless a real executor is wired or simulation is explicit
|
* 1. Classify the brief
|
||||||
* 2. Classify the brief
|
* 2. Generate a run ID and create run directory
|
||||||
* 3. Generate a run ID and create run directory
|
* 3. Map stages to tasks and submit to TaskExecutor
|
||||||
* 4. Map stages to tasks and submit to TaskExecutor
|
* 4. Track manifest with stage statuses
|
||||||
* 5. Track manifest with typed stage outcomes
|
* 5. Return pipeline result
|
||||||
* 6. Return pipeline result
|
|
||||||
*/
|
*/
|
||||||
export async function runPipeline(
|
export async function runPipeline(
|
||||||
briefPath: string,
|
briefPath: string,
|
||||||
projectRoot: string,
|
projectRoot: string,
|
||||||
options: PipelineOptions,
|
options: PipelineOptions,
|
||||||
): Promise<PipelineResult> {
|
): Promise<PipelineResult> {
|
||||||
const simulate = options.simulate ?? false;
|
|
||||||
const executor = requireExecutor(options.executor, simulate);
|
|
||||||
const mode: RunMode = simulate ? 'simulated' : 'normal';
|
|
||||||
|
|
||||||
const resolvedRoot = path.resolve(projectRoot);
|
const resolvedRoot = path.resolve(projectRoot);
|
||||||
const resolvedBrief = path.resolve(briefPath);
|
const resolvedBrief = path.resolve(briefPath);
|
||||||
const briefContent = fs.readFileSync(resolvedBrief, 'utf-8');
|
const briefContent = fs.readFileSync(resolvedBrief, 'utf-8');
|
||||||
@@ -342,7 +146,6 @@ export async function runPipeline(
|
|||||||
briefClass,
|
briefClass,
|
||||||
classSource,
|
classSource,
|
||||||
forceBoard: options.forceBoard ?? false,
|
forceBoard: options.forceBoard ?? false,
|
||||||
mode,
|
|
||||||
runDir,
|
runDir,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -369,10 +172,54 @@ export async function runPipeline(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute stages
|
// Execute stages
|
||||||
await executeStages({ manifest, runDir, tasks, stageNames: selectedStages, executor, simulate });
|
const { executor } = options;
|
||||||
|
for (let i = 0; i < tasks.length; i++) {
|
||||||
|
const task = tasks[i]!;
|
||||||
|
const stageName = selectedStages[i]!;
|
||||||
|
|
||||||
// All stages reached a terminal state for this mode
|
// Update manifest: stage in progress
|
||||||
manifest.status = simulate ? 'simulated' : 'completed';
|
manifest.currentStage = stageName;
|
||||||
|
manifest.stages[stageName] = {
|
||||||
|
status: 'in_progress',
|
||||||
|
startedAt: nowISO(),
|
||||||
|
};
|
||||||
|
saveManifest(runDir, manifest);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await executor.submitTask(task);
|
||||||
|
const result = await executor.waitForCompletion(task.id, task.timeoutSeconds * 1000);
|
||||||
|
|
||||||
|
// Update manifest: stage completed or failed
|
||||||
|
const stageStatus: StageStatus = {
|
||||||
|
status: result.status === 'completed' ? 'passed' : 'failed',
|
||||||
|
startedAt: manifest.stages[stageName]!.startedAt,
|
||||||
|
completedAt: nowISO(),
|
||||||
|
};
|
||||||
|
manifest.stages[stageName] = stageStatus;
|
||||||
|
|
||||||
|
if (result.status !== 'completed') {
|
||||||
|
manifest.status = 'failed';
|
||||||
|
saveManifest(runDir, manifest);
|
||||||
|
throw new Error(`Stage ${stageName} failed with status: ${result.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveManifest(runDir, manifest);
|
||||||
|
} catch (error) {
|
||||||
|
if (!manifest.stages[stageName]?.completedAt) {
|
||||||
|
manifest.stages[stageName] = {
|
||||||
|
status: 'failed',
|
||||||
|
startedAt: manifest.stages[stageName]?.startedAt,
|
||||||
|
completedAt: nowISO(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
manifest.status = 'failed';
|
||||||
|
saveManifest(runDir, manifest);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All stages passed
|
||||||
|
manifest.status = 'completed';
|
||||||
saveManifest(runDir, manifest);
|
saveManifest(runDir, manifest);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -387,30 +234,22 @@ export async function runPipeline(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resume a pipeline from the last non-passed stage.
|
* Resume a pipeline from the last incomplete stage.
|
||||||
*/
|
*/
|
||||||
export async function resumePipeline(
|
export async function resumePipeline(
|
||||||
runDir: string,
|
runDir: string,
|
||||||
executor?: TaskExecutor,
|
executor: TaskExecutor,
|
||||||
options?: { simulate?: boolean },
|
|
||||||
): Promise<PipelineResult> {
|
): Promise<PipelineResult> {
|
||||||
const simulate = options?.simulate ?? false;
|
|
||||||
const wiredExecutor = requireExecutor(executor, simulate);
|
|
||||||
const mode: RunMode = simulate ? 'simulated' : 'normal';
|
|
||||||
|
|
||||||
const manifest = loadManifest(runDir);
|
const manifest = loadManifest(runDir);
|
||||||
const resolvedRoot = path.dirname(path.dirname(path.dirname(runDir))); // .forge/runs/{id} → project root
|
const resolvedRoot = path.dirname(path.dirname(path.dirname(runDir))); // .forge/runs/{id} → project root
|
||||||
|
|
||||||
const briefContent = fs.readFileSync(manifest.brief, 'utf-8');
|
const briefContent = fs.readFileSync(manifest.brief, 'utf-8');
|
||||||
const allStages = stagesForClass(manifest.briefClass, manifest.forceBoard);
|
const allStages = stagesForClass(manifest.briefClass, manifest.forceBoard);
|
||||||
|
|
||||||
manifest.mode = mode;
|
// Find first non-passed stage
|
||||||
|
|
||||||
// Find first non-satisfying stage (only a verified `passed` counts as done;
|
|
||||||
// simulated and waiting-for-authority stages are re-run).
|
|
||||||
const resumeFrom = allStages.find((s) => manifest.stages[s]?.status !== 'passed');
|
const resumeFrom = allStages.find((s) => manifest.stages[s]?.status !== 'passed');
|
||||||
if (!resumeFrom) {
|
if (!resumeFrom) {
|
||||||
manifest.status = mode === 'simulated' ? 'simulated' : 'completed';
|
manifest.status = 'completed';
|
||||||
saveManifest(runDir, manifest);
|
saveManifest(runDir, manifest);
|
||||||
return {
|
return {
|
||||||
runId: manifest.runId,
|
runId: manifest.runId,
|
||||||
@@ -445,16 +284,49 @@ export async function resumePipeline(
|
|||||||
tasks.push(task);
|
tasks.push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
await executeStages({
|
for (let i = 0; i < tasks.length; i++) {
|
||||||
manifest,
|
const task = tasks[i]!;
|
||||||
runDir,
|
const stageName = remainingStages[i]!;
|
||||||
tasks,
|
|
||||||
stageNames: remainingStages,
|
|
||||||
executor: wiredExecutor,
|
|
||||||
simulate,
|
|
||||||
});
|
|
||||||
|
|
||||||
manifest.status = simulate ? 'simulated' : 'completed';
|
manifest.currentStage = stageName;
|
||||||
|
manifest.stages[stageName] = {
|
||||||
|
status: 'in_progress',
|
||||||
|
startedAt: nowISO(),
|
||||||
|
};
|
||||||
|
saveManifest(runDir, manifest);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await executor.submitTask(task);
|
||||||
|
const result = await executor.waitForCompletion(task.id, task.timeoutSeconds * 1000);
|
||||||
|
|
||||||
|
manifest.stages[stageName] = {
|
||||||
|
status: result.status === 'completed' ? 'passed' : 'failed',
|
||||||
|
startedAt: manifest.stages[stageName]!.startedAt,
|
||||||
|
completedAt: nowISO(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (result.status !== 'completed') {
|
||||||
|
manifest.status = 'failed';
|
||||||
|
saveManifest(runDir, manifest);
|
||||||
|
throw new Error(`Stage ${stageName} failed with status: ${result.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveManifest(runDir, manifest);
|
||||||
|
} catch (error) {
|
||||||
|
if (!manifest.stages[stageName]?.completedAt) {
|
||||||
|
manifest.stages[stageName] = {
|
||||||
|
status: 'failed',
|
||||||
|
startedAt: manifest.stages[stageName]?.startedAt,
|
||||||
|
completedAt: nowISO(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
manifest.status = 'failed';
|
||||||
|
saveManifest(runDir, manifest);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest.status = 'completed';
|
||||||
saveManifest(runDir, manifest);
|
saveManifest(runDir, manifest);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import type { ForgeTask, ForgeTaskResult, TaskExecutor } from './types.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simulated executor — used ONLY when the caller explicitly passes --simulate.
|
|
||||||
*
|
|
||||||
* It submits no real work and returns typed `simulated` results so a simulated
|
|
||||||
* run can never be confused with a verified one. In normal mode (no --simulate)
|
|
||||||
* the CLI refuses to run at all with FORGE_NO_EXECUTOR instead of wiring this
|
|
||||||
* stub (fail closed, SDLC-D-035).
|
|
||||||
*/
|
|
||||||
export function createSimulatedExecutor(options?: { log?: boolean }): TaskExecutor {
|
|
||||||
const log = options?.log ?? true;
|
|
||||||
return {
|
|
||||||
async submitTask(task: ForgeTask) {
|
|
||||||
if (log) console.log(` [forge:simulated] stage submitted: ${task.id} (${task.title})`);
|
|
||||||
},
|
|
||||||
async waitForCompletion(taskId: string): Promise<ForgeTaskResult> {
|
|
||||||
if (log) console.log(` [forge:simulated] stage complete: ${taskId}`);
|
|
||||||
return {
|
|
||||||
task_id: taskId,
|
|
||||||
outcome: 'simulated',
|
|
||||||
reason: 'no executor wired; simulated execution requested via --simulate',
|
|
||||||
completed_at: new Date().toISOString(),
|
|
||||||
exit_code: 0,
|
|
||||||
gate_results: [],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async getTaskStatus() {
|
|
||||||
return 'completed' as const;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { GateEntry } from '@mosaicstack/macp';
|
import type { GateEntry, TaskResult } from '@mosaicstack/macp';
|
||||||
|
|
||||||
/** Stage dispatch mode. */
|
/** Stage dispatch mode. */
|
||||||
export type StageDispatch = 'exec' | 'yolo' | 'pi';
|
export type StageDispatch = 'exec' | 'yolo' | 'pi';
|
||||||
@@ -6,58 +6,6 @@ export type StageDispatch = 'exec' | 'yolo' | 'pi';
|
|||||||
/** Stage type — determines agent selection and gate requirements. */
|
/** Stage type — determines agent selection and gate requirements. */
|
||||||
export type StageType = 'research' | 'review' | 'coding' | 'deploy';
|
export type StageType = 'research' | 'review' | 'coding' | 'deploy';
|
||||||
|
|
||||||
/**
|
|
||||||
* Typed outcome for every gate and stage evaluation — closed set (SDLC-D-035).
|
|
||||||
*
|
|
||||||
* Only `passed` means "verified by a real implementation". `simulated` is
|
|
||||||
* produced exclusively in explicit `--simulate` runs and is never satisfying.
|
|
||||||
*/
|
|
||||||
export type ForgeOutcome =
|
|
||||||
| 'passed'
|
|
||||||
| 'failed'
|
|
||||||
| 'blocked'
|
|
||||||
| 'error'
|
|
||||||
| 'waiting-for-authority'
|
|
||||||
| 'simulated'
|
|
||||||
| 'not-applicable';
|
|
||||||
|
|
||||||
/** A gate that requires authority (human/board) sign-off; no mechanical command can satisfy it. */
|
|
||||||
export interface AuthorityGate {
|
|
||||||
kind: 'authority';
|
|
||||||
capability: string;
|
|
||||||
reason: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A gate that requires a wired provider (e.g. an AI reviewer, CI pipeline) to evaluate. */
|
|
||||||
export interface ProviderGate {
|
|
||||||
kind: 'provider';
|
|
||||||
capability: string;
|
|
||||||
reason: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Forge quality gate: a real command, an authority sign-off, or a provider-backed check. */
|
|
||||||
export type ForgeGate = string | GateEntry | AuthorityGate | ProviderGate;
|
|
||||||
|
|
||||||
/** Typed result of evaluating a single quality gate. */
|
|
||||||
export interface ForgeGateResult {
|
|
||||||
gate: string;
|
|
||||||
outcome: ForgeOutcome;
|
|
||||||
reason: string;
|
|
||||||
exitCode?: number;
|
|
||||||
output?: string;
|
|
||||||
timedOut?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Typed result of a task/stage execution returned by a TaskExecutor. */
|
|
||||||
export interface ForgeTaskResult {
|
|
||||||
task_id: string;
|
|
||||||
outcome: ForgeOutcome;
|
|
||||||
reason: string;
|
|
||||||
completed_at: string;
|
|
||||||
exit_code: number;
|
|
||||||
gate_results: ForgeGateResult[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stage specification — defines a single pipeline stage. */
|
/** Stage specification — defines a single pipeline stage. */
|
||||||
export interface StageSpec {
|
export interface StageSpec {
|
||||||
number: string;
|
number: string;
|
||||||
@@ -66,7 +14,7 @@ export interface StageSpec {
|
|||||||
type: StageType;
|
type: StageType;
|
||||||
gate: string;
|
gate: string;
|
||||||
promptFile: string;
|
promptFile: string;
|
||||||
qualityGates: ForgeGate[];
|
qualityGates: (string | GateEntry)[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Brief classification. */
|
/** Brief classification. */
|
||||||
@@ -77,18 +25,11 @@ export type ClassSource = 'cli' | 'frontmatter' | 'auto';
|
|||||||
|
|
||||||
/** Per-stage status within a run manifest. */
|
/** Per-stage status within a run manifest. */
|
||||||
export interface StageStatus {
|
export interface StageStatus {
|
||||||
status: 'pending' | 'in_progress' | ForgeOutcome;
|
status: 'pending' | 'in_progress' | 'passed' | 'failed';
|
||||||
/** Why the stage reached its current (terminal) outcome, when applicable. */
|
|
||||||
reason?: string;
|
|
||||||
startedAt?: string;
|
startedAt?: string;
|
||||||
completedAt?: string;
|
completedAt?: string;
|
||||||
/** Typed per-gate results recorded alongside the stage outcome. */
|
|
||||||
gateResults?: ForgeGateResult[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Execution mode of a run. */
|
|
||||||
export type RunMode = 'normal' | 'simulated';
|
|
||||||
|
|
||||||
/** Run manifest — persisted to disk as manifest.json. */
|
/** Run manifest — persisted to disk as manifest.json. */
|
||||||
export interface RunManifest {
|
export interface RunManifest {
|
||||||
runId: string;
|
runId: string;
|
||||||
@@ -97,23 +38,10 @@ export interface RunManifest {
|
|||||||
briefClass: BriefClass;
|
briefClass: BriefClass;
|
||||||
classSource: ClassSource;
|
classSource: ClassSource;
|
||||||
forceBoard: boolean;
|
forceBoard: boolean;
|
||||||
/**
|
|
||||||
* Execution mode. `simulated` runs stub execution; their results are typed
|
|
||||||
* `simulated` and must never be read as verified success. Optional because
|
|
||||||
* manifests written before this field existed default to `normal`.
|
|
||||||
*/
|
|
||||||
mode?: RunMode;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
currentStage: string;
|
currentStage: string;
|
||||||
status:
|
status: 'in_progress' | 'completed' | 'failed' | 'interrupted' | 'rejected';
|
||||||
| 'in_progress'
|
|
||||||
| 'completed'
|
|
||||||
| 'failed'
|
|
||||||
| 'interrupted'
|
|
||||||
| 'rejected'
|
|
||||||
| 'simulated'
|
|
||||||
| 'waiting-for-authority';
|
|
||||||
stages: Record<string, StageStatus>;
|
stages: Record<string, StageStatus>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +65,7 @@ export interface ForgeTask {
|
|||||||
briefPath: string;
|
briefPath: string;
|
||||||
resultPath: string;
|
resultPath: string;
|
||||||
timeoutSeconds: number;
|
timeoutSeconds: number;
|
||||||
qualityGates: ForgeGate[];
|
qualityGates: (string | GateEntry)[];
|
||||||
worktree?: string;
|
worktree?: string;
|
||||||
command?: string;
|
command?: string;
|
||||||
dependsOn?: string[];
|
dependsOn?: string[];
|
||||||
@@ -148,7 +76,7 @@ export interface ForgeTask {
|
|||||||
/** Abstract task executor — decouples from packages/coord. */
|
/** Abstract task executor — decouples from packages/coord. */
|
||||||
export interface TaskExecutor {
|
export interface TaskExecutor {
|
||||||
submitTask(task: ForgeTask): Promise<void>;
|
submitTask(task: ForgeTask): Promise<void>;
|
||||||
waitForCompletion(taskId: string, timeoutMs: number): Promise<ForgeTaskResult>;
|
waitForCompletion(taskId: string, timeoutMs: number): Promise<TaskResult>;
|
||||||
getTaskStatus(taskId: string): Promise<ForgeTaskStatus>;
|
getTaskStatus(taskId: string): Promise<ForgeTaskStatus>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,16 +122,7 @@ export interface PipelineOptions {
|
|||||||
stages?: string[];
|
stages?: string[];
|
||||||
skipTo?: string;
|
skipTo?: string;
|
||||||
dryRun?: boolean;
|
dryRun?: boolean;
|
||||||
/**
|
executor: TaskExecutor;
|
||||||
* Real task executor. Required in normal mode: the pipeline fails closed
|
|
||||||
* with FORGE_NO_EXECUTOR when it is absent.
|
|
||||||
*/
|
|
||||||
executor?: TaskExecutor;
|
|
||||||
/**
|
|
||||||
* Explicit opt-in to simulated execution. Every stage and gate result is
|
|
||||||
* typed `simulated` and is never satisfying.
|
|
||||||
*/
|
|
||||||
simulate?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pipeline run result. */
|
/** Pipeline run result. */
|
||||||
|
|||||||
@@ -12,33 +12,6 @@ The default tmux socket is `mosaic-fleet` so fleet commands do not touch the
|
|||||||
default tmux server. The roster is the desired-state authority; generated environment files are
|
default tmux server. The roster is the desired-state authority; generated environment files are
|
||||||
rebuildable projections, never a second source of configuration.
|
rebuildable projections, never a second source of configuration.
|
||||||
|
|
||||||
## Brain-home split (fleet state vs framework templates)
|
|
||||||
|
|
||||||
When a mosaic-brain clone is present, fleet **state** resolves from the brain
|
|
||||||
home while framework templates and dispatch state stay in the config home
|
|
||||||
(three-tree model, canon `docs/STRUCTURE-CANON.md` §2):
|
|
||||||
|
|
||||||
| Path | Without brain (legacy) | With brain |
|
|
||||||
| ------------------------------------------------------------------------------- | ------------------------------------- | ------------------------------ |
|
|
||||||
| `fleet/agents/<seat>.env.*` | `~/.config/mosaic/fleet/agents/` | `~/.mosaic/fleet/agents/` |
|
|
||||||
| `fleet/roles.local/` (overrides) | `~/.config/mosaic/fleet/roles.local/` | `~/.mosaic/fleet/roles.local/` |
|
|
||||||
| `fleet/profiles/` (working copies) | `~/.config/mosaic/fleet/profiles/` | `~/.mosaic/fleet/profiles/` |
|
|
||||||
| `fleet/roster.yaml`, `fleet/roles/` (baseline), `fleet/run/`, `fleet/services/` | `~/.config/mosaic/fleet/…` | unchanged (config home) |
|
|
||||||
|
|
||||||
Activation (`packages/mosaic/src/fleet/brain-home.ts`, mirrored in
|
|
||||||
`tools/fleet/start-agent-session.sh`):
|
|
||||||
|
|
||||||
1. `MOSAIC_BRAIN_HOME` env var — explicit, always wins.
|
|
||||||
2. Canonical `~/.mosaic` — adopted only when `MOSAIC_HOME` is the default
|
|
||||||
`~/.config/mosaic` AND `~/.mosaic/fleet/agents` exists. Custom
|
|
||||||
`--mosaic-home` values (tests, sandboxes, canaries) never adopt, keeping
|
|
||||||
them hermetic.
|
|
||||||
3. Otherwise the config home (legacy single-tree behavior).
|
|
||||||
|
|
||||||
Seat env dirs under a brain are subject to the same privacy boundary (0700
|
|
||||||
dirs, 0600 files); `.env.generated` files are structure-valuable and tracked
|
|
||||||
in the brain repo, hand-maintained `.env`/`.env.local` stay ignored and private.
|
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
- `examples/minimal.yaml` starts one local canary slot.
|
- `examples/minimal.yaml` starts one local canary slot.
|
||||||
|
|||||||
@@ -51,8 +51,12 @@ See `docs/fleet/reference/generated-env-boundary.md` for the full contract.
|
|||||||
## Manual canary sequence
|
## Manual canary sequence
|
||||||
|
|
||||||
Use the roster and the supported installer; do not pre-create the agent environment directory or
|
Use the roster and the supported installer; do not pre-create the agent environment directory or
|
||||||
edit a generated projection. `mosaic fleet install` validates the roster, installs the units and
|
edit a generated projection. Before it writes any holder identity, tool, projection, or unit file,
|
||||||
helpers, and writes private roster-derived projections before any service is started.
|
`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.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create a site-owned canary roster. Inspect an existing roster before using --force.
|
# Create a site-owned canary roster. Inspect an existing roster before using --force.
|
||||||
|
|||||||
@@ -255,68 +255,6 @@ fleet_declared_transport() {
|
|||||||
printf '%s\n' "${declared:-tmux}"
|
printf '%s\n' "${declared:-tmux}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Brain-home fleet-state resolution (#1298; canon STRUCTURE-CANON §2).
|
|
||||||
#
|
|
||||||
# Seat launch envs, roles.local overrides, and profile working copies resolve
|
|
||||||
# from the brain home when one is active; roster, baseline roles, run/, and
|
|
||||||
# services stay under MOSAIC_HOME. This check surfaces which tree fleet state
|
|
||||||
# resolves from and the drift a launch would otherwise hit at runtime:
|
|
||||||
#
|
|
||||||
# - a stale MOSAIC_BRAIN_HOME pointing at a directory with no fleet/agents is a
|
|
||||||
# misconfiguration the resolver honors (explicit wins) — warn, don't pass;
|
|
||||||
# - a symlinked brain or agents dir defeats the managed-directory boundary;
|
|
||||||
# - a group/world-readable agents dir violates the 0700 projection boundary;
|
|
||||||
# - env files left in the config-home tree while a brain is active are split
|
|
||||||
# state — the write path rejects NEW split writes, but nothing would ever
|
|
||||||
# tell the operator the old files are stranded.
|
|
||||||
resolve_brain_home() {
|
|
||||||
local explicit="${MOSAIC_BRAIN_HOME:-}"
|
|
||||||
if [[ -n "$(printf '%s' "$explicit" | tr -d '[:space:]')" ]]; then
|
|
||||||
printf '%s' "$explicit"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
if [[ "$(cd "$MOSAIC_HOME" 2>/dev/null && pwd -P)" == "$HOME/.config/mosaic" \
|
|
||||||
&& -d "$HOME/.mosaic/fleet/agents" ]]; then
|
|
||||||
printf '%s' "$HOME/.mosaic"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
printf '%s' "$MOSAIC_HOME"
|
|
||||||
}
|
|
||||||
|
|
||||||
check_brain_home() {
|
|
||||||
local brain agents mode
|
|
||||||
brain="$(resolve_brain_home)"
|
|
||||||
|
|
||||||
if [[ "$brain" == "$MOSAIC_HOME" ]]; then
|
|
||||||
pass "Fleet state home: $MOSAIC_HOME (legacy single-tree; no brain adopted)"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
agents="$brain/fleet/agents"
|
|
||||||
if [[ ! -d "$agents" ]]; then
|
|
||||||
warn "Brain home '$brain' has no fleet/agents — seat envs will not resolve from it. Point MOSAIC_BRAIN_HOME at a brain carrying fleet/agents, or unset it."
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
if [[ -L "$brain" || -L "$agents" ]]; then
|
|
||||||
warn "Brain fleet-state path resolves through a symlink ($brain) — the managed-directory boundary requires regular directories."
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
mode="$(stat -c '%a' -- "$agents" 2>/dev/null)" || mode=""
|
|
||||||
if [[ -n "$mode" ]] && (( (8#$mode & 8#077) != 0 )); then
|
|
||||||
warn "Brain agents dir '$agents' is group/world-accessible (mode $mode) — the projection boundary requires 0700."
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -d "$MOSAIC_HOME/fleet/agents" ]] \
|
|
||||||
&& ls "$MOSAIC_HOME/fleet/agents/"*.env* >/dev/null 2>&1; then
|
|
||||||
warn "Fleet env files exist in BOTH trees — brain '$brain' is active but '$MOSAIC_HOME/fleet/agents' still carries env files (split state). Migrate them (mosaic fleet regen) and remove the config-home copies."
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
pass "Fleet state home: $brain (brain active); roster + templates: $MOSAIC_HOME"
|
|
||||||
}
|
|
||||||
|
|
||||||
check_fleet_transport() {
|
check_fleet_transport() {
|
||||||
local transport
|
local transport
|
||||||
transport="$(fleet_declared_transport)"
|
transport="$(fleet_declared_transport)"
|
||||||
@@ -335,8 +273,6 @@ check_fleet_transport() {
|
|||||||
|
|
||||||
check_fleet_transport
|
check_fleet_transport
|
||||||
|
|
||||||
check_brain_home
|
|
||||||
|
|
||||||
# Legacy migration surfaces should no longer contain symlink trees.
|
# Legacy migration surfaces should no longer contain symlink trees.
|
||||||
legacy_paths=(
|
legacy_paths=(
|
||||||
"$HOME/.claude/agent-guides"
|
"$HOME/.claude/agent-guides"
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Covers the brain-home fleet-state check in `mosaic-doctor` (#1298 follow-up).
|
|
||||||
#
|
|
||||||
# The functions are extracted from the shipped script rather than copied here
|
|
||||||
# (same discipline as test-fleet-transport-check.sh): a test that carries its
|
|
||||||
# own copy of the logic keeps passing after the shipped copy changes.
|
|
||||||
# Extraction is by exact function header and a closing brace in column one.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR=$(cd -- "$(dirname "$0")" && pwd)
|
|
||||||
DOCTOR="$SCRIPT_DIR/mosaic-doctor"
|
|
||||||
|
|
||||||
fail() {
|
|
||||||
echo "FAIL: $*" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
[ -f "$DOCTOR" ] || fail "missing mosaic-doctor at $DOCTOR"
|
|
||||||
|
|
||||||
extract_function() {
|
|
||||||
local name="$1"
|
|
||||||
local extracted
|
|
||||||
extracted=$(sed -n "/^${name}() {/,/^}/p" "$DOCTOR")
|
|
||||||
[ -n "$extracted" ] || fail "could not extract ${name}() from mosaic-doctor — script reshaped?"
|
|
||||||
printf '%s\n' "$extracted"
|
|
||||||
}
|
|
||||||
|
|
||||||
for fn in resolve_brain_home check_brain_home; do
|
|
||||||
extract_function "$fn" >/dev/null
|
|
||||||
done
|
|
||||||
|
|
||||||
warn_count=0
|
|
||||||
warn() { warn_count=$((warn_count + 1)); echo "[WARN] $*"; }
|
|
||||||
pass() { echo "[OK] $*"; return 0; }
|
|
||||||
|
|
||||||
eval "$(extract_function resolve_brain_home)"
|
|
||||||
eval "$(extract_function check_brain_home)"
|
|
||||||
|
|
||||||
ROOT=$(mktemp -d)
|
|
||||||
trap 'rm -rf "$ROOT"' EXIT
|
|
||||||
|
|
||||||
run_case() {
|
|
||||||
# label, expect (ok|warn), then env assignments as arguments.
|
|
||||||
# The check runs under `env` in a subshell, so its warn() also prints a
|
|
||||||
# sentinel the parent counts — a subshell counter would never be visible.
|
|
||||||
local label="$1" expect="$2"
|
|
||||||
shift 2
|
|
||||||
local out warns
|
|
||||||
out=$(env "$@" bash -c "warn() { echo \"[WARN] \$*\"; }; pass() { echo \"[OK] \$*\"; return 0; }; $(extract_function resolve_brain_home); $(extract_function check_brain_home); check_brain_home" 2>&1)
|
|
||||||
warns=$(printf '%s\n' "$out" | grep -c '^\[WARN\]' || true)
|
|
||||||
if [[ "$expect" == ok && "$warns" -eq 0 ]]; then
|
|
||||||
echo "ok - $label"
|
|
||||||
elif [[ "$expect" == warn && "$warns" -gt 0 ]]; then
|
|
||||||
echo "ok - $label (warned)"
|
|
||||||
else
|
|
||||||
echo "output: $out" >&2
|
|
||||||
fail "$label: expected $expect (warns=$warns)"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── legacy: no brain, custom home never adopts ─────────────────────────────
|
|
||||||
mkdir -p "$ROOT/legacy-mosaic/fleet/agents"
|
|
||||||
run_case "custom home without brain stays legacy" ok \
|
|
||||||
MOSAIC_HOME="$ROOT/legacy-mosaic" HOME="$ROOT"
|
|
||||||
|
|
||||||
# ── healthy brain at the default config home ───────────────────────────────
|
|
||||||
mkdir -p "$ROOT/home/.config/mosaic" "$ROOT/home/.mosaic/fleet/agents"
|
|
||||||
chmod 700 "$ROOT/home/.mosaic/fleet/agents"
|
|
||||||
run_case "default home adopts healthy brain" ok \
|
|
||||||
MOSAIC_HOME="$ROOT/home/.config/mosaic" HOME="$ROOT/home"
|
|
||||||
|
|
||||||
# ── explicit MOSAIC_BRAIN_HOME to a brain without fleet/agents → warn ──────
|
|
||||||
mkdir -p "$ROOT/brain-noagents/fleet" "$ROOT/config"
|
|
||||||
run_case "explicit brain without agents warns" warn \
|
|
||||||
MOSAIC_HOME="$ROOT/config" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-noagents"
|
|
||||||
|
|
||||||
# ── explicit MOSAIC_BRAIN_HOME to a healthy brain → ok ─────────────────────
|
|
||||||
mkdir -p "$ROOT/brain-ok/fleet/agents" "$ROOT/config2"
|
|
||||||
chmod 700 "$ROOT/brain-ok/fleet/agents"
|
|
||||||
run_case "explicit healthy brain passes" ok \
|
|
||||||
MOSAIC_HOME="$ROOT/config2" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-ok"
|
|
||||||
|
|
||||||
# ── group-readable agents dir → warn (0700 boundary) ───────────────────────
|
|
||||||
mkdir -p "$ROOT/brain-loose/fleet/agents" "$ROOT/config3"
|
|
||||||
chmod 750 "$ROOT/brain-loose/fleet/agents"
|
|
||||||
run_case "group-readable brain agents warns" warn \
|
|
||||||
MOSAIC_HOME="$ROOT/config3" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-loose"
|
|
||||||
|
|
||||||
# ── symlinked agents dir → warn (managed-directory boundary) ───────────────
|
|
||||||
mkdir -p "$ROOT/brain-link/real-agents" "$ROOT/brain-link/fleet" "$ROOT/config4"
|
|
||||||
ln -s "$ROOT/brain-link/real-agents" "$ROOT/brain-link/fleet/agents"
|
|
||||||
run_case "symlinked brain agents warns" warn \
|
|
||||||
MOSAIC_HOME="$ROOT/config4" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-link"
|
|
||||||
|
|
||||||
# ── split state: envs in BOTH trees → warn ─────────────────────────────────
|
|
||||||
mkdir -p "$ROOT/brain-split/fleet/agents" "$ROOT/config5/fleet/agents"
|
|
||||||
chmod 700 "$ROOT/brain-split/fleet/agents" "$ROOT/config5/fleet/agents"
|
|
||||||
touch "$ROOT/config5/fleet/agents/coder0.env.generated"
|
|
||||||
run_case "env files in both trees warns (split state)" warn \
|
|
||||||
MOSAIC_HOME="$ROOT/config5" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-split"
|
|
||||||
|
|
||||||
# ── config-home agents dir WITHOUT env files alongside a brain → ok ────────
|
|
||||||
mkdir -p "$ROOT/brain-clean/fleet/agents" "$ROOT/config6/fleet/agents"
|
|
||||||
chmod 700 "$ROOT/brain-clean/fleet/agents" "$ROOT/config6/fleet/agents"
|
|
||||||
run_case "empty config-home agents dir alongside brain passes" ok \
|
|
||||||
MOSAIC_HOME="$ROOT/config6" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-clean"
|
|
||||||
|
|
||||||
echo "ok - mosaic-doctor brain-home check"
|
|
||||||
+199
@@ -0,0 +1,199 @@
|
|||||||
|
#!/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
|
||||||
@@ -80,26 +80,6 @@ safe_path "$MOSAIC_HOME" || fail_env unsafe-path MOSAIC_HOME "$MOSAIC_HOME"
|
|||||||
|
|
||||||
FLEET_DIR="$MOSAIC_HOME/fleet"
|
FLEET_DIR="$MOSAIC_HOME/fleet"
|
||||||
AGENT_ENV_DIR="$FLEET_DIR/agents"
|
AGENT_ENV_DIR="$FLEET_DIR/agents"
|
||||||
|
|
||||||
# Brain-home split (canon docs/STRUCTURE-CANON.md §2): seat launch envs live
|
|
||||||
# under the brain home's fleet/agents when a brain is active; roster, roles
|
|
||||||
# baseline, and runtime state (fleet/run) stay under MOSAIC_HOME.
|
|
||||||
# Resolution mirrors packages/mosaic/src/fleet/brain-home.ts:
|
|
||||||
# 1. MOSAIC_BRAIN_HOME env (explicit, always wins)
|
|
||||||
# 2. ~/.mosaic — adopted only when MOSAIC_HOME is the default config home AND
|
|
||||||
# ~/.mosaic/fleet/agents exists
|
|
||||||
# 3. MOSAIC_HOME (legacy single-tree)
|
|
||||||
BRAIN_HOME="${MOSAIC_BRAIN_HOME:-}"
|
|
||||||
if [ -z "$BRAIN_HOME" ]; then
|
|
||||||
BRAIN_HOME="$MOSAIC_HOME"
|
|
||||||
if [ "$(cd "$MOSAIC_HOME" 2>/dev/null && pwd -P)" = "$HOME/.config/mosaic" ] \
|
|
||||||
&& [ -d "$HOME/.mosaic/fleet/agents" ]; then
|
|
||||||
BRAIN_HOME="$HOME/.mosaic"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
if [ "$BRAIN_HOME" != "$MOSAIC_HOME" ]; then
|
|
||||||
AGENT_ENV_DIR="$BRAIN_HOME/fleet/agents"
|
|
||||||
fi
|
|
||||||
assert_managed_directory "$MOSAIC_HOME"
|
assert_managed_directory "$MOSAIC_HOME"
|
||||||
assert_managed_directory "$FLEET_DIR"
|
assert_managed_directory "$FLEET_DIR"
|
||||||
assert_private_directory "$AGENT_ENV_DIR"
|
assert_private_directory "$AGENT_ENV_DIR"
|
||||||
@@ -278,46 +258,22 @@ if _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Systemd passes HOME as %h, and the installed service fixes MOSAIC_HOME under
|
# Systemd passes HOME as %h, and the installed service fixes MOSAIC_HOME under
|
||||||
# that home. Derive the pane home from the canonical path when available so an
|
# that home. The provisioning preflight executes this same helper under the
|
||||||
# inherited pane/session HOME cannot become runtime authority.
|
# unit's clean launcher environment, so operator PATH cannot produce a false
|
||||||
PANE_HOME=$HOME
|
# green result for a binary the pane will never see.
|
||||||
case "$MOSAIC_HOME" in
|
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||||
*/.config/mosaic) PANE_HOME=${MOSAIC_HOME%/.config/mosaic} ;;
|
# shellcheck source=pane-runtime-path.sh
|
||||||
esac
|
. "$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")
|
||||||
|
|
||||||
_build_runtime_bin_prefix() {
|
# #1241/#1256. The pane runs `mosaic yolo <runtime>` under PANE_PATH with a
|
||||||
local candidates=()
|
# cleared environment. Resolve both names and validate any shebang interpreter
|
||||||
if [ -n "$MOSAIC_RUNTIME_BIN" ]; then candidates+=("$MOSAIC_RUNTIME_BIN"); fi
|
# here, before an effect, where the failure remains attributable. Name
|
||||||
if command -v npm >/dev/null 2>&1; then
|
# resolution alone is insufficient: an `#!/usr/bin/env node` script resolves
|
||||||
local npm_prefix
|
# even when the pane cannot execute it because Node is absent.
|
||||||
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() {
|
_resolve_in_pane_path() {
|
||||||
PATH="$PANE_PATH" command -v -- "$1" 2>/dev/null
|
mosaic_fleet_resolve_in_pane_path "$PANE_PATH" "$1"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Exit 69 (EX_UNAVAILABLE): the seat cannot be provided. Distinguished from the
|
# Exit 69 (EX_UNAVAILABLE): the seat cannot be provided. Distinguished from the
|
||||||
@@ -332,8 +288,15 @@ fail_launch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for required_binary in mosaic "$MOSAIC_AGENT_RUNTIME"; do
|
for required_binary in mosaic "$MOSAIC_AGENT_RUNTIME"; do
|
||||||
_resolve_in_pane_path "$required_binary" >/dev/null ||
|
resolved_binary=$(_resolve_in_pane_path "$required_binary") ||
|
||||||
fail_launch missing-binary "'${required_binary}' is not on the pane PATH (${PANE_PATH})"
|
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
|
done
|
||||||
|
|
||||||
_ensure_claude_workdir_trusted() {
|
_ensure_claude_workdir_trusted() {
|
||||||
|
|||||||
@@ -167,54 +167,6 @@ if echo "$valid_args" | grep -qF 'bash -c'; then
|
|||||||
fail "launcher constructed a shell command payload"
|
fail "launcher constructed a shell command payload"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Brain-home split (canon §2) ─────────────────────────────────────────
|
|
||||||
# When MOSAIC_HOME is the default config home under $HOME and the host carries
|
|
||||||
# $HOME/.mosaic/fleet/agents, seat envs resolve from the brain tree; the config
|
|
||||||
# home still owns fleet/run (holder-owner) and remains a managed boundary.
|
|
||||||
: > "$TMUX_CALLS"
|
|
||||||
HOME_BRAIN="$ROOT/brain-home"
|
|
||||||
CONFIG_HOME="$HOME_BRAIN/.config/mosaic"
|
|
||||||
BRAIN="$HOME_BRAIN/.mosaic"
|
|
||||||
mkdir -p "$CONFIG_HOME/fleet/run" "$BRAIN/fleet/agents" "$HOME_BRAIN/work"
|
|
||||||
chmod 700 "$CONFIG_HOME" "$CONFIG_HOME/fleet" "$CONFIG_HOME/fleet/run" \
|
|
||||||
"$BRAIN/fleet/agents" "$HOME_BRAIN/work"
|
|
||||||
printf '123e4567-e89b-12d3-a456-426614174000\n' > "$CONFIG_HOME/fleet/run/holder-owner"
|
|
||||||
chmod 600 "$CONFIG_HOME/fleet/run/holder-owner"
|
|
||||||
cat > "$BRAIN/fleet/agents/coder-brain.env.generated" <<EOF
|
|
||||||
MOSAIC_AGENT_NAME=coder-brain
|
|
||||||
MOSAIC_AGENT_CLASS=code
|
|
||||||
MOSAIC_AGENT_RUNTIME=pi
|
|
||||||
MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol
|
|
||||||
MOSAIC_AGENT_REASONING=high
|
|
||||||
MOSAIC_AGENT_TOOL_POLICY=code
|
|
||||||
MOSAIC_AGENT_WORKDIR=$HOME_BRAIN/work
|
|
||||||
MOSAIC_TMUX_SOCKET=mosaic-test
|
|
||||||
EOF
|
|
||||||
chmod 600 "$BRAIN/fleet/agents/coder-brain.env.generated"
|
|
||||||
install_pane_binaries "$HOME_BRAIN"
|
|
||||||
HOME="$HOME_BRAIN" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
|
||||||
MOSAIC_TEST_PANE_PID=$$ MOSAIC_TEST_HOME="$HOME_BRAIN" \
|
|
||||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
|
||||||
MOSAIC_HOME="$CONFIG_HOME" "$START" coder-brain
|
|
||||||
brain_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
|
||||||
echo "$brain_args" | grep -qF new-session || fail "brain-home generated projection did not reach tmux"
|
|
||||||
echo "$brain_args" | grep -qF 'coder-brain' || fail "brain-home agent env was not the launch source"
|
|
||||||
[ -f "$BRAIN/fleet/agents/coder-brain.env.generated" ] || fail "brain generated env vanished"
|
|
||||||
|
|
||||||
# Negative control: the SAME default-config-home shape but without
|
|
||||||
# ~/.mosaic/fleet/agents — the config-home env tree is used directly (legacy).
|
|
||||||
: > "$TMUX_CALLS"
|
|
||||||
HOME_NOBRAIN="$ROOT/brainless-home"
|
|
||||||
CONFIG_HOME_NOBRAIN="$HOME_NOBRAIN/.config/mosaic"
|
|
||||||
write_generated "$CONFIG_HOME_NOBRAIN" "coder-legacy"
|
|
||||||
install_pane_binaries "$HOME_NOBRAIN"
|
|
||||||
HOME="$HOME_NOBRAIN" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
|
||||||
MOSAIC_TEST_PANE_PID=$$ MOSAIC_TEST_HOME="$HOME_NOBRAIN" \
|
|
||||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
|
||||||
MOSAIC_HOME="$CONFIG_HOME_NOBRAIN" "$START" coder-legacy
|
|
||||||
legacy_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
|
||||||
echo "$legacy_args" | grep -qF new-session || fail "legacy single-tree launch regressed"
|
|
||||||
|
|
||||||
# The pane must start through an absolute clean-environment boundary. Its
|
# The pane must start through an absolute clean-environment boundary. Its
|
||||||
# runtime command remains an argv vector, but no holder/session environment
|
# runtime command remains an argv vector, but no holder/session environment
|
||||||
# control variable can pass through the pane command.
|
# control variable can pass through the pane command.
|
||||||
@@ -532,6 +484,27 @@ assert_missing_pane_binary_rejected() {
|
|||||||
assert_missing_pane_binary_rejected mosaic
|
assert_missing_pane_binary_rejected mosaic
|
||||||
assert_missing_pane_binary_rejected pi
|
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
|
# #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
|
# 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
|
# WARNING about the heartbeat sidecar followed by exit 0 — three layers above it
|
||||||
|
|||||||
@@ -39,20 +39,3 @@ packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires rea
|
|||||||
# recorded judgement. These lines ARE that judgement, signed.)
|
# 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/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
|
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",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
"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/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 && bash framework/tools/_scripts/test-brain-home-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/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"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import { fleetAgentEnvDir, fleetRolesLocalDir } from '../fleet/brain-home.js';
|
|
||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
import {
|
import {
|
||||||
executeFleetAgentMutation,
|
executeFleetAgentMutation,
|
||||||
@@ -150,9 +149,9 @@ async function executeCommand(
|
|||||||
request,
|
request,
|
||||||
mosaicHome,
|
mosaicHome,
|
||||||
rosterPath,
|
rosterPath,
|
||||||
agentEnvDir: fleetAgentEnvDir(mosaicHome),
|
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
|
||||||
rolesDir: join(mosaicHome, 'fleet', 'roles'),
|
rolesDir: join(mosaicHome, 'fleet', 'roles'),
|
||||||
overrideDir: fleetRolesLocalDir(mosaicHome),
|
overrideDir: join(mosaicHome, 'fleet', 'roles.local'),
|
||||||
dryRun: forceDryRun || opts.dryRun === true,
|
dryRun: forceDryRun || opts.dryRun === true,
|
||||||
...(deps.projectionApplier === undefined ? {} : { projectionApplier: deps.projectionApplier }),
|
...(deps.projectionApplier === undefined ? {} : { projectionApplier: deps.projectionApplier }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { fleetAgentEnvDir, fleetRolesLocalDir } from '../fleet/brain-home.js';
|
|
||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
import {
|
import {
|
||||||
parseV1MigrationObservations,
|
parseV1MigrationObservations,
|
||||||
@@ -121,11 +120,11 @@ export function registerFleetMigrationCommand(
|
|||||||
observations,
|
observations,
|
||||||
personaDirs: {
|
personaDirs: {
|
||||||
rolesDir: deps.rolesDir ?? join(mosaicHome, 'fleet', 'roles'),
|
rolesDir: deps.rolesDir ?? join(mosaicHome, 'fleet', 'roles'),
|
||||||
overrideDir: deps.overrideDir ?? fleetRolesLocalDir(mosaicHome),
|
overrideDir: deps.overrideDir ?? join(mosaicHome, 'fleet', 'roles.local'),
|
||||||
},
|
},
|
||||||
environment: {
|
environment: {
|
||||||
mosaicHome,
|
mosaicHome,
|
||||||
agentEnvDir: fleetAgentEnvDir(mosaicHome),
|
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
printJson(preview);
|
printJson(preview);
|
||||||
|
|||||||
@@ -30,21 +30,19 @@ import { lstat, readFile, readdir, stat } from 'node:fs/promises';
|
|||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { basename, isAbsolute, join, sep } from 'node:path';
|
import { basename, isAbsolute, join, sep } from 'node:path';
|
||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
import { fleetRolesLocalDir } from '../fleet/brain-home.js';
|
|
||||||
|
|
||||||
function defaultMosaicHome(): string {
|
function defaultMosaicHome(): string {
|
||||||
return process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
return process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Baseline persona role contracts (reseeded on update; config home — framework). */
|
/** Baseline persona role contracts (reseeded on update). */
|
||||||
export function defaultRolesDir(mosaicHome = defaultMosaicHome()): string {
|
export function defaultRolesDir(mosaicHome = defaultMosaicHome()): string {
|
||||||
return join(mosaicHome, 'fleet', 'roles');
|
return join(mosaicHome, 'fleet', 'roles');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** PRESERVE-protected override layer (survives update; wins on merge).
|
/** PRESERVE-protected override layer (survives update; wins on merge). */
|
||||||
* Brain home (`~/.mosaic/fleet/roles.local`) when a brain is active. */
|
|
||||||
export function defaultOverrideDir(mosaicHome = defaultMosaicHome()): string {
|
export function defaultOverrideDir(mosaicHome = defaultMosaicHome()): string {
|
||||||
return fleetRolesLocalDir(mosaicHome);
|
return join(mosaicHome, 'fleet', 'roles.local');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import { homedir } from 'node:os';
|
|||||||
import { basename, join } from 'node:path';
|
import { basename, join } from 'node:path';
|
||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
import YAML from 'yaml';
|
import YAML from 'yaml';
|
||||||
import { fleetProfilesDir } from '../fleet/brain-home.js';
|
|
||||||
import {
|
import {
|
||||||
defaultOverrideDir,
|
defaultOverrideDir,
|
||||||
extractClassesFromDir,
|
extractClassesFromDir,
|
||||||
@@ -37,10 +36,9 @@ function defaultMosaicHome(): string {
|
|||||||
return process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
return process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Directory holding the seeded profile yaml files — brain home when active
|
/** Directory holding the seeded profile yaml files. */
|
||||||
* (user working copies, committed), else the config home seed. */
|
|
||||||
export function defaultProfilesDir(mosaicHome = defaultMosaicHome()): string {
|
export function defaultProfilesDir(mosaicHome = defaultMosaicHome()): string {
|
||||||
return fleetProfilesDir(mosaicHome);
|
return join(mosaicHome, 'fleet', 'profiles');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Directory holding the persona role contracts. */
|
/** Directory holding the persona role contracts. */
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { type FleetReconcileDeps } from '../fleet/fleet-reconciler.js';
|
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';
|
import { registerFleetCommand, type CommandResult, type FleetCommandDeps } from './fleet.js';
|
||||||
|
|
||||||
const roster = `
|
const roster = `
|
||||||
@@ -65,12 +69,15 @@ function program(
|
|||||||
mosaicHome: string,
|
mosaicHome: string,
|
||||||
runner: FleetCommandDeps['runner'],
|
runner: FleetCommandDeps['runner'],
|
||||||
reconcileOverrides: Partial<FleetReconcileDeps> = {},
|
reconcileOverrides: Partial<FleetReconcileDeps> = {},
|
||||||
|
runtimeProbeRunner: FleetRuntimeProbeRunner = runtimeProbe('present'),
|
||||||
): Command {
|
): Command {
|
||||||
const result = new Command();
|
const result = new Command();
|
||||||
result.exitOverride();
|
result.exitOverride();
|
||||||
registerFleetCommand(result, {
|
registerFleetCommand(result, {
|
||||||
mosaicHome,
|
mosaicHome,
|
||||||
runner,
|
runner,
|
||||||
|
frameworkRoot: resolve(process.cwd(), 'framework'),
|
||||||
|
runtimeProbeRunner,
|
||||||
reconcileDeps: {
|
reconcileDeps: {
|
||||||
homeDirectory: '/home/mosaic',
|
homeDirectory: '/home/mosaic',
|
||||||
readHolderIdentity: async () => '11111111-1111-4111-8111-111111111111',
|
readHolderIdentity: async () => '11111111-1111-4111-8111-111111111111',
|
||||||
@@ -83,6 +90,24 @@ function program(
|
|||||||
return result;
|
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[] {
|
function capture(): string[] {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
|
vi.spyOn(console, 'log').mockImplementation((value: string): void => {
|
||||||
@@ -152,13 +177,64 @@ describe('mosaic fleet reconciler commands', (): void => {
|
|||||||
|
|
||||||
expect(lines.map((line: string): unknown => JSON.parse(line))).toMatchObject([
|
expect(lines.map((line: string): unknown => JSON.parse(line))).toMatchObject([
|
||||||
{ applied: false, lifecycle: 'not-applied' },
|
{ applied: false, lifecycle: 'not-applied' },
|
||||||
{ 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'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
expect(
|
expect(
|
||||||
calls.every((call: string[]): boolean => call[0] !== 'systemctl' || call[2] === 'show'),
|
calls.every((call: string[]): boolean => call[0] !== 'systemctl' || call[2] === 'show'),
|
||||||
).toBe(true);
|
).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)(
|
it.each(['start', 'stop', 'restart'] as const)(
|
||||||
'uses exact roster-owned systemd targeting for %s',
|
'uses exact roster-owned systemd targeting for %s',
|
||||||
async (operation: 'start' | 'stop' | 'restart'): Promise<void> => {
|
async (operation: 'start' | 'stop' | 'restart'): Promise<void> => {
|
||||||
|
|||||||
@@ -8,10 +8,18 @@ import {
|
|||||||
type FleetReconcileCommand,
|
type FleetReconcileCommand,
|
||||||
type FleetReconcileDeps,
|
type FleetReconcileDeps,
|
||||||
} from '../fleet/fleet-reconciler.js';
|
} 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';
|
import { parseRosterV2 } from '../fleet/roster-v2.js';
|
||||||
|
|
||||||
export interface FleetReconcilerCommandDeps {
|
export interface FleetReconcilerCommandDeps {
|
||||||
readonly runner: CommandRunner;
|
readonly runner: CommandRunner;
|
||||||
|
readonly runtimeProbeRunner?: FleetRuntimeProbeRunner;
|
||||||
|
readonly frameworkRoot?: string;
|
||||||
readonly mosaicHome?: string;
|
readonly mosaicHome?: string;
|
||||||
readonly reconcileDeps?: Omit<FleetReconcileDeps, 'runner' | 'mosaicHome'>;
|
readonly reconcileDeps?: Omit<FleetReconcileDeps, 'runner' | 'mosaicHome'>;
|
||||||
}
|
}
|
||||||
@@ -71,6 +79,10 @@ export async function executeReconcilerCommand(
|
|||||||
const mosaicHome = resolveMosaicHome(fleetCommand, deps);
|
const mosaicHome = resolveMosaicHome(fleetCommand, deps);
|
||||||
const rosterPath = resolveRosterPath(fleetCommand, mosaicHome);
|
const rosterPath = resolveRosterPath(fleetCommand, mosaicHome);
|
||||||
const roster = parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml');
|
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 mutating = operation === 'apply' || operation === 'reconcile' || isLifecycle(operation);
|
||||||
const expectedGeneration = mutating
|
const expectedGeneration = mutating
|
||||||
? parseExpectedGeneration(opts.expectedGeneration)
|
? parseExpectedGeneration(opts.expectedGeneration)
|
||||||
@@ -90,8 +102,31 @@ export async function executeReconcilerCommand(
|
|||||||
...(deps.reconcileDeps ?? {}),
|
...(deps.reconcileDeps ?? {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
printJson(result);
|
printJson(operation === 'doctor' ? { ...result, checks: runtimeInspection } : result);
|
||||||
process.exitCode = result.recovery === undefined && result.cleanup === undefined ? 0 : 1;
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLifecycle(operation: FleetReconcileCommand): boolean {
|
function isLifecycle(operation: FleetReconcileCommand): boolean {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { homedir } from 'node:os';
|
|||||||
import { join, relative, resolve } from 'node:path';
|
import { join, relative, resolve } from 'node:path';
|
||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
import type { CommandRunner } from './fleet.js';
|
import type { CommandRunner } from './fleet.js';
|
||||||
import { fleetAgentEnvDir } from '../fleet/brain-home.js';
|
|
||||||
import {
|
import {
|
||||||
applyPreparedGeneratedAgentEnvironmentProjection,
|
applyPreparedGeneratedAgentEnvironmentProjection,
|
||||||
prepareGeneratedAgentEnvironmentProjection,
|
prepareGeneratedAgentEnvironmentProjection,
|
||||||
@@ -154,7 +153,7 @@ export async function executeFleetRegen(
|
|||||||
options: FleetRegenOptions,
|
options: FleetRegenOptions,
|
||||||
): Promise<FleetRegenResult> {
|
): Promise<FleetRegenResult> {
|
||||||
const mosaicHome = defaultMosaicHome(deps);
|
const mosaicHome = defaultMosaicHome(deps);
|
||||||
const agentEnvDir = fleetAgentEnvDir(mosaicHome);
|
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
||||||
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||||
const readRoster = deps.readRoster ?? defaultReadRoster(deps, mosaicHome);
|
const readRoster = deps.readRoster ?? defaultReadRoster(deps, mosaicHome);
|
||||||
const prepare = deps.prepareProjection ?? prepareGeneratedAgentEnvironmentProjection;
|
const prepare = deps.prepareProjection ?? prepareGeneratedAgentEnvironmentProjection;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
|
|||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
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';
|
import { registerFleetCommand, type CommandResult, type CommandRunner } from './fleet.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,6 +70,7 @@ agents:
|
|||||||
let tempHome: string | undefined;
|
let tempHome: string | undefined;
|
||||||
const savedHome = process.env.HOME;
|
const savedHome = process.env.HOME;
|
||||||
const savedMosaicHome = process.env.MOSAIC_HOME;
|
const savedMosaicHome = process.env.MOSAIC_HOME;
|
||||||
|
const savedPath = process.env.PATH;
|
||||||
|
|
||||||
afterEach(async (): Promise<void> => {
|
afterEach(async (): Promise<void> => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
@@ -77,6 +79,8 @@ afterEach(async (): Promise<void> => {
|
|||||||
else process.env.HOME = savedHome;
|
else process.env.HOME = savedHome;
|
||||||
if (savedMosaicHome === undefined) delete process.env.MOSAIC_HOME;
|
if (savedMosaicHome === undefined) delete process.env.MOSAIC_HOME;
|
||||||
else process.env.MOSAIC_HOME = savedMosaicHome;
|
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 });
|
if (tempHome) await rm(tempHome, { recursive: true, force: true });
|
||||||
tempHome = undefined;
|
tempHome = undefined;
|
||||||
});
|
});
|
||||||
@@ -85,7 +89,7 @@ afterEach(async (): Promise<void> => {
|
|||||||
* A HOME with a roster-v2 fleet and nothing else — the greenfield shape, before
|
* A HOME with a roster-v2 fleet and nothing else — the greenfield shape, before
|
||||||
* anything has been installed, applied or started.
|
* anything has been installed, applied or started.
|
||||||
*/
|
*/
|
||||||
async function v2Home(): Promise<string> {
|
async function v2Home(options: { withPaneRuntime?: boolean } = {}): Promise<string> {
|
||||||
tempHome = await mkdtemp(join(tmpdir(), 'mosaic-fleet-v2-dispatch-'));
|
tempHome = await mkdtemp(join(tmpdir(), 'mosaic-fleet-v2-dispatch-'));
|
||||||
process.env.HOME = tempHome;
|
process.env.HOME = tempHome;
|
||||||
delete process.env.MOSAIC_HOME;
|
delete process.env.MOSAIC_HOME;
|
||||||
@@ -97,6 +101,12 @@ async function v2Home(): Promise<string> {
|
|||||||
await writeFile(join(mosaicHome, 'fleet', 'roles', 'code.md'), '`class: code`\n\n# code\n', {
|
await writeFile(join(mosaicHome, 'fleet', 'roles', 'code.md'), '`class: code`\n\n# code\n', {
|
||||||
mode: 0o600,
|
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;
|
return mosaicHome;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,10 +123,17 @@ const greenfieldRunner: CommandRunner = async (command): Promise<CommandResult>
|
|||||||
return { stdout: '', stderr: '', exitCode: 1 };
|
return { stdout: '', stderr: '', exitCode: 1 };
|
||||||
};
|
};
|
||||||
|
|
||||||
function program(runner: CommandRunner = greenfieldRunner): Command {
|
function program(
|
||||||
|
runner: CommandRunner = greenfieldRunner,
|
||||||
|
runtimeProbeRunner?: FleetRuntimeProbeRunner,
|
||||||
|
): Command {
|
||||||
const result = new Command();
|
const result = new Command();
|
||||||
result.exitOverride();
|
result.exitOverride();
|
||||||
registerFleetCommand(result, { runner, frameworkRoot: resolve(process.cwd(), 'framework') });
|
registerFleetCommand(result, {
|
||||||
|
runner,
|
||||||
|
frameworkRoot: resolve(process.cwd(), 'framework'),
|
||||||
|
...(runtimeProbeRunner === undefined ? {} : { runtimeProbeRunner }),
|
||||||
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +183,93 @@ describe('mosaic fleet ps — roster v2', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('mosaic fleet install — 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> => {
|
it('places the tool files and unit templates', async (): Promise<void> => {
|
||||||
const mosaicHome = await v2Home();
|
const mosaicHome = await v2Home();
|
||||||
capture();
|
capture();
|
||||||
@@ -183,9 +287,11 @@ describe('mosaic fleet install — roster v2', (): void => {
|
|||||||
]) {
|
]) {
|
||||||
expect(await exists(join(systemdUserDir, unit))).toBe(true);
|
expect(await exists(join(systemdUserDir, unit))).toBe(true);
|
||||||
}
|
}
|
||||||
const launcher = join(mosaicHome, 'tools', 'fleet', 'start-agent-session.sh');
|
for (const tool of ['start-agent-session.sh', 'pane-runtime-path.sh']) {
|
||||||
expect(await exists(launcher)).toBe(true);
|
const toolPath = join(mosaicHome, 'tools', 'fleet', tool);
|
||||||
expect((await stat(launcher)).mode & 0o777).toBe(0o755);
|
expect(await exists(toolPath)).toBe(true);
|
||||||
|
expect((await stat(toolPath)).mode & 0o777).toBe(0o755);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('writes NO generated env — that file belongs to the reconciler (#791)', async (): Promise<void> => {
|
it('writes NO generated env — that file belongs to the reconciler (#791)', async (): Promise<void> => {
|
||||||
|
|||||||
@@ -1277,6 +1277,10 @@ describe('fleet command construction', () => {
|
|||||||
const home = await tempDir();
|
const home = await tempDir();
|
||||||
process.env.HOME = home;
|
process.env.HOME = home;
|
||||||
delete process.env.MOSAIC_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 mosaicHome = join(home, '.config', 'mosaic');
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
program.exitOverride();
|
program.exitOverride();
|
||||||
@@ -1315,6 +1319,10 @@ describe('fleet command construction', () => {
|
|||||||
const originalHome = process.env.HOME;
|
const originalHome = process.env.HOME;
|
||||||
const home = await tempDir();
|
const home = await tempDir();
|
||||||
process.env.HOME = home;
|
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 mosaicHome = join(home, '.config', 'mosaic');
|
||||||
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
|
||||||
const fleetDir = join(mosaicHome, 'fleet');
|
const fleetDir = join(mosaicHome, 'fleet');
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { homedir, hostname, userInfo } from 'node:os';
|
import { homedir, hostname, userInfo } from 'node:os';
|
||||||
import { dirname, join, resolve } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
import { fleetAgentEnvDir } from '../fleet/brain-home.js';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import * as readline from 'node:readline';
|
import * as readline from 'node:readline';
|
||||||
@@ -61,6 +60,12 @@ import {
|
|||||||
writeAgentEnvironmentProjection,
|
writeAgentEnvironmentProjection,
|
||||||
writeManagedFleetRoster,
|
writeManagedFleetRoster,
|
||||||
} from '../fleet/generated-env-boundary.js';
|
} 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 { registerFleetBacklogCommand } from './fleet-backlog.js';
|
||||||
import { registerFleetPersonaCommand } from './fleet-personas.js';
|
import { registerFleetPersonaCommand } from './fleet-personas.js';
|
||||||
import { registerFleetProfileCommand } from './fleet-profiles.js';
|
import { registerFleetProfileCommand } from './fleet-profiles.js';
|
||||||
@@ -90,6 +95,8 @@ export type SleepFn = (ms: number) => Promise<void>;
|
|||||||
|
|
||||||
export interface FleetCommandDeps {
|
export interface FleetCommandDeps {
|
||||||
runner?: CommandRunner;
|
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`). */
|
/** Injectable interactive runner for commands needing inherited TTY (e.g., `tmux attach`). */
|
||||||
interactiveRunner?: InteractiveRunner;
|
interactiveRunner?: InteractiveRunner;
|
||||||
/**
|
/**
|
||||||
@@ -159,7 +166,7 @@ export function resolveFleetPaths(mosaicHome = defaultMosaicHome()): FleetPaths
|
|||||||
fleetToolsDir: join(mosaicHome, 'tools', 'fleet'),
|
fleetToolsDir: join(mosaicHome, 'tools', 'fleet'),
|
||||||
tmuxToolsDir: join(mosaicHome, 'tools', 'tmux'),
|
tmuxToolsDir: join(mosaicHome, 'tools', 'tmux'),
|
||||||
systemdUserDir: join(homedir(), '.config', 'systemd', 'user'),
|
systemdUserDir: join(homedir(), '.config', 'systemd', 'user'),
|
||||||
agentEnvDir: fleetAgentEnvDir(mosaicHome),
|
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1430,6 +1437,10 @@ export function isSendAccepted(capturedOutput: string): SendVerifyResult {
|
|||||||
|
|
||||||
export function registerFleetCommand(program: Command, deps: FleetCommandDeps = {}): Command {
|
export function registerFleetCommand(program: Command, deps: FleetCommandDeps = {}): Command {
|
||||||
const runner = deps.runner ?? runCommand;
|
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 sleepFn = deps.sleepFn ?? defaultSleep;
|
||||||
const paths = resolveFleetPaths(deps.mosaicHome);
|
const paths = resolveFleetPaths(deps.mosaicHome);
|
||||||
const frameworkRoot = deps.frameworkRoot ?? resolveFrameworkRoot();
|
const frameworkRoot = deps.frameworkRoot ?? resolveFrameworkRoot();
|
||||||
@@ -1528,7 +1539,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
.description('Install local fleet tools and user systemd units')
|
.description('Install local fleet tools and user systemd units')
|
||||||
.option('--no-enable', 'Skip enabling units for boot-survival')
|
.option('--no-enable', 'Skip enabling units for boot-survival')
|
||||||
.action(async (opts: { enable?: boolean }) => {
|
.action(async (opts: { enable?: boolean }) => {
|
||||||
await installFleet(cmd, frameworkRoot);
|
await installFleet(cmd, frameworkRoot, runtimeProbeRunner);
|
||||||
// Unit enablement needs agent names only, so it reads either version.
|
// Unit enablement needs agent names only, so it reads either version.
|
||||||
const roster = await loadRosterReadModel(cmd);
|
const roster = await loadRosterReadModel(cmd);
|
||||||
await enableFleetUnits(runner, roster, opts);
|
await enableFleetUnits(runner, roster, opts);
|
||||||
@@ -1539,7 +1550,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
.description('Install local fleet tools and user systemd units')
|
.description('Install local fleet tools and user systemd units')
|
||||||
.option('--no-enable', 'Skip enabling units for boot-survival')
|
.option('--no-enable', 'Skip enabling units for boot-survival')
|
||||||
.action(async (opts: { enable?: boolean }) => {
|
.action(async (opts: { enable?: boolean }) => {
|
||||||
await installFleet(cmd, frameworkRoot);
|
await installFleet(cmd, frameworkRoot, runtimeProbeRunner);
|
||||||
// Unit enablement needs agent names only, so it reads either version.
|
// Unit enablement needs agent names only, so it reads either version.
|
||||||
const roster = await loadRosterReadModel(cmd);
|
const roster = await loadRosterReadModel(cmd);
|
||||||
await enableFleetUnits(runner, roster, opts);
|
await enableFleetUnits(runner, roster, opts);
|
||||||
@@ -2085,6 +2096,8 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
});
|
});
|
||||||
registerFleetReconcilerCommands(cmd, {
|
registerFleetReconcilerCommands(cmd, {
|
||||||
runner,
|
runner,
|
||||||
|
runtimeProbeRunner,
|
||||||
|
frameworkRoot,
|
||||||
mosaicHome: deps.mosaicHome,
|
mosaicHome: deps.mosaicHome,
|
||||||
reconcileDeps: deps.reconcileDeps,
|
reconcileDeps: deps.reconcileDeps,
|
||||||
});
|
});
|
||||||
@@ -2350,18 +2363,68 @@ export function registerFleetAgentCommands(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installFleet(cmd: Command, frameworkRoot: string): Promise<void> {
|
async function installFleet(
|
||||||
|
cmd: Command,
|
||||||
|
frameworkRoot: string,
|
||||||
|
runtimeProbeRunner: FleetRuntimeProbeRunner,
|
||||||
|
): Promise<void> {
|
||||||
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
|
const activePaths = resolveFleetPaths(cmd.opts<{ mosaicHome: string }>().mosaicHome);
|
||||||
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
|
assertDefaultMosaicHomeForSystemd(activePaths.mosaicHome);
|
||||||
// Read model first: every file this function places is roster-independent, and
|
// Read and preflight before the first mkdir/copy/chmod/write. A successful
|
||||||
// the v1 parser would reject a v2 roster before any of them were written.
|
// install must mean every roster runtime is executable in the eventual pane,
|
||||||
|
// not merely visible to the operator who invoked this command.
|
||||||
const roster = await loadRosterReadModel(cmd);
|
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 ensureFleetHolderIdentity(activePaths.mosaicHome);
|
||||||
await mkdir(activePaths.fleetToolsDir, { recursive: true });
|
await mkdir(activePaths.fleetToolsDir, { recursive: true });
|
||||||
await mkdir(activePaths.tmuxToolsDir, { recursive: true });
|
await mkdir(activePaths.tmuxToolsDir, { recursive: true });
|
||||||
await mkdir(activePaths.systemdUserDir, { recursive: true });
|
await mkdir(activePaths.systemdUserDir, { recursive: true });
|
||||||
|
|
||||||
const startAgentSessionPath = join(activePaths.fleetToolsDir, 'start-agent-session.sh');
|
const startAgentSessionPath = join(activePaths.fleetToolsDir, 'start-agent-session.sh');
|
||||||
|
const paneRuntimePath = join(activePaths.fleetToolsDir, 'pane-runtime-path.sh');
|
||||||
const startInteractionServicePath = join(
|
const startInteractionServicePath = join(
|
||||||
activePaths.fleetToolsDir,
|
activePaths.fleetToolsDir,
|
||||||
'start-interaction-service.sh',
|
'start-interaction-service.sh',
|
||||||
@@ -2375,6 +2438,7 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
|||||||
const agentSendPath = join(activePaths.tmuxToolsDir, 'agent-send.sh');
|
const agentSendPath = join(activePaths.tmuxToolsDir, 'agent-send.sh');
|
||||||
const executableToolPaths = [
|
const executableToolPaths = [
|
||||||
startAgentSessionPath,
|
startAgentSessionPath,
|
||||||
|
paneRuntimePath,
|
||||||
startInteractionServicePath,
|
startInteractionServicePath,
|
||||||
startTmuxHolderPath,
|
startTmuxHolderPath,
|
||||||
printInteractionPolicyPath,
|
printInteractionPolicyPath,
|
||||||
@@ -2385,6 +2449,7 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
|||||||
join(frameworkRoot, 'tools', 'fleet', 'start-agent-session.sh'),
|
join(frameworkRoot, 'tools', 'fleet', 'start-agent-session.sh'),
|
||||||
startAgentSessionPath,
|
startAgentSessionPath,
|
||||||
);
|
);
|
||||||
|
await copyFile(join(frameworkRoot, 'tools', 'fleet', 'pane-runtime-path.sh'), paneRuntimePath);
|
||||||
await copyFile(
|
await copyFile(
|
||||||
join(frameworkRoot, 'tools', 'fleet', 'start-interaction-service.sh'),
|
join(frameworkRoot, 'tools', 'fleet', 'start-interaction-service.sh'),
|
||||||
startInteractionServicePath,
|
startInteractionServicePath,
|
||||||
@@ -2428,7 +2493,9 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const v1Roster = await loadRosterForCommand(cmd);
|
if (v1Roster === undefined) {
|
||||||
|
throw new Error('Roster version changed while installing fleet files.');
|
||||||
|
}
|
||||||
for (const agent of v1Roster.agents) {
|
for (const agent of v1Roster.agents) {
|
||||||
await writeAgentEnvironmentProjection({
|
await writeAgentEnvironmentProjection({
|
||||||
mosaicHome: activePaths.mosaicHome,
|
mosaicHome: activePaths.mosaicHome,
|
||||||
|
|||||||
@@ -349,90 +349,3 @@ describe('registerRuntimeLaunchers — claudex (EXPERIMENTAL overlay)', () => {
|
|||||||
expect(mockExit).not.toHaveBeenCalled();
|
expect(mockExit).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Seat harness homes (MOSAIC-D-002, brain-home split) ────────────────────
|
|
||||||
|
|
||||||
import { activeSeatDir, seatPersonaOverlay } from './launch.js';
|
|
||||||
|
|
||||||
describe('activeSeatDir — per-agent harness home resolution', () => {
|
|
||||||
let root: string;
|
|
||||||
const savedAgentName = process.env['MOSAIC_AGENT_NAME'];
|
|
||||||
const savedBrainHome = process.env['MOSAIC_BRAIN_HOME'];
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
root = mkdtempSync(join(tmpdir(), 'mosaic-seat-home-'));
|
|
||||||
delete process.env['MOSAIC_BRAIN_HOME'];
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
rmSync(root, { recursive: true, force: true });
|
|
||||||
if (savedAgentName === undefined) {
|
|
||||||
delete process.env['MOSAIC_AGENT_NAME'];
|
|
||||||
} else {
|
|
||||||
process.env['MOSAIC_AGENT_NAME'] = savedAgentName;
|
|
||||||
}
|
|
||||||
if (savedBrainHome !== undefined) {
|
|
||||||
process.env['MOSAIC_BRAIN_HOME'] = savedBrainHome;
|
|
||||||
} else {
|
|
||||||
delete process.env['MOSAIC_BRAIN_HOME'];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves the seat dir when MOSAIC_BRAIN_HOME carries the seat', () => {
|
|
||||||
const seat = join(root, 'brain', 'fleet', 'agents', 'coder0');
|
|
||||||
mkdirSync(seat, { recursive: true });
|
|
||||||
process.env['MOSAIC_AGENT_NAME'] = 'coder0';
|
|
||||||
process.env['MOSAIC_BRAIN_HOME'] = join(root, 'brain');
|
|
||||||
|
|
||||||
expect(activeSeatDir(join(root, 'config', 'mosaic'))).toBe(seat);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns undefined without an agent name (bare launches stay shared)', () => {
|
|
||||||
delete process.env['MOSAIC_AGENT_NAME'];
|
|
||||||
expect(activeSeatDir(join(root, 'config', 'mosaic'))).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns undefined when the seat dir does not exist in the brain', () => {
|
|
||||||
process.env['MOSAIC_AGENT_NAME'] = 'ghost';
|
|
||||||
process.env['MOSAIC_BRAIN_HOME'] = join(root, 'brain');
|
|
||||||
mkdirSync(join(root, 'brain', 'fleet', 'agents'), { recursive: true });
|
|
||||||
|
|
||||||
expect(activeSeatDir(join(root, 'config', 'mosaic'))).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(['../escape', 'a/b', '.hidden-start', '', 'spaced name'])(
|
|
||||||
'rejects unsafe agent name %j (path traversal cannot leave the seat store)',
|
|
||||||
(name: string) => {
|
|
||||||
process.env['MOSAIC_AGENT_NAME'] = name;
|
|
||||||
process.env['MOSAIC_BRAIN_HOME'] = join(root, 'brain');
|
|
||||||
|
|
||||||
expect(activeSeatDir(join(root, 'config', 'mosaic'))).toBeUndefined();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it('seatPersonaOverlay renders the seat SOUL.md as an overlay block', () => {
|
|
||||||
const seat = join(root, 'brain', 'fleet', 'agents', 'coder0');
|
|
||||||
mkdirSync(seat, { recursive: true });
|
|
||||||
writeFileSync(join(seat, 'SOUL.md'), '# coder0 — code seat persona\n\nShips tested code.\n');
|
|
||||||
process.env['MOSAIC_AGENT_NAME'] = 'coder0';
|
|
||||||
process.env['MOSAIC_BRAIN_HOME'] = join(root, 'brain');
|
|
||||||
|
|
||||||
const overlay = seatPersonaOverlay(join(root, 'config', 'mosaic'));
|
|
||||||
expect(overlay).toContain('## Seat Persona');
|
|
||||||
expect(overlay).toContain('coder0 — code seat persona');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('seatPersonaOverlay is empty when the seat carries no SOUL.md', () => {
|
|
||||||
const seat = join(root, 'brain', 'fleet', 'agents', 'coder0');
|
|
||||||
mkdirSync(seat, { recursive: true });
|
|
||||||
process.env['MOSAIC_AGENT_NAME'] = 'coder0';
|
|
||||||
process.env['MOSAIC_BRAIN_HOME'] = join(root, 'brain');
|
|
||||||
|
|
||||||
expect(seatPersonaOverlay(join(root, 'config', 'mosaic'))).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('seatPersonaOverlay is empty when no agent name is set', () => {
|
|
||||||
delete process.env['MOSAIC_AGENT_NAME'];
|
|
||||||
expect(seatPersonaOverlay(join(root, 'config', 'mosaic'))).toBe('');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
import { createHash, randomBytes } from 'node:crypto';
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import { homedir, hostname } from 'node:os';
|
import { homedir, hostname } from 'node:os';
|
||||||
import { join, dirname, resolve } from 'node:path';
|
import { join, dirname } from 'node:path';
|
||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
import {
|
import {
|
||||||
buildResolvedFleetCommsBlock,
|
buildResolvedFleetCommsBlock,
|
||||||
@@ -29,7 +29,6 @@ import {
|
|||||||
import { readRegularFileSecure } from '../fleet/secure-file.js';
|
import { readRegularFileSecure } from '../fleet/secure-file.js';
|
||||||
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
|
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
|
||||||
import { canonicalizeRoleClass } from './fleet-personas.js';
|
import { canonicalizeRoleClass } from './fleet-personas.js';
|
||||||
import { resolveBrainHome } from '../fleet/brain-home.js';
|
|
||||||
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
|
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
|
||||||
import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js';
|
import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js';
|
||||||
|
|
||||||
@@ -65,46 +64,9 @@ const HARNESS_HOME_ENV: Record<RuntimeName, string> = {
|
|||||||
opencode: 'XDG_CONFIG_HOME',
|
opencode: 'XDG_CONFIG_HOME',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/.<runtime>.
|
/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/.<runtime> */
|
||||||
* With an active brain seat (MOSAIC_AGENT_NAME + seat dir in the brain home)
|
function harnessHome(runtime: RuntimeName): string {
|
||||||
* the home is per-agent instead: <brainHome>/fleet/agents/<seat>/.<runtime> —
|
return join(MOSAIC_HOME, `.${runtime}`);
|
||||||
* per-agent sessions, settings, and auth inside the seat dir (canon §2,
|
|
||||||
* MOSAIC-D-002). Seat runtime dirs are dot-named so the brain's ignore policy
|
|
||||||
* (per-seat .pi/.claude/.codex dirs) keeps credential material untracked. */
|
|
||||||
const SEAT_AGENT_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
||||||
|
|
||||||
export function activeSeatDir(mosaicHome: string = MOSAIC_HOME): string | undefined {
|
|
||||||
const agent = process.env['MOSAIC_AGENT_NAME']?.trim();
|
|
||||||
if (
|
|
||||||
agent === undefined ||
|
|
||||||
agent === '' ||
|
|
||||||
!SEAT_AGENT_NAME_RE.test(agent) ||
|
|
||||||
agent.includes('..')
|
|
||||||
) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const brain = resolveBrainHome(mosaicHome);
|
|
||||||
if (resolve(brain) === resolve(mosaicHome)) return undefined; // no brain
|
|
||||||
const seat = join(brain, 'fleet', 'agents', agent);
|
|
||||||
return existsSync(seat) ? seat : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function harnessHome(runtime: RuntimeName, mosaicHome: string = MOSAIC_HOME): string {
|
|
||||||
const seat = activeSeatDir(mosaicHome);
|
|
||||||
if (seat !== undefined) return join(seat, `.${runtime}`);
|
|
||||||
return join(mosaicHome, `.${runtime}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Seat persona block: with an active brain seat, <seat>/SOUL.md layers
|
|
||||||
* persona on the root generic base (canon invariant; MOSAIC-D-002). The base
|
|
||||||
* SOUL stays load-on-demand — only the seat delta is injected by value.
|
|
||||||
* Empty string when no seat is active or the seat carries no SOUL.md. */
|
|
||||||
export function seatPersonaOverlay(mosaicHome: string = MOSAIC_HOME): string {
|
|
||||||
const seatDir = activeSeatDir(mosaicHome);
|
|
||||||
if (seatDir === undefined) return '';
|
|
||||||
const seatSoul = readOptional(join(seatDir, 'SOUL.md'));
|
|
||||||
if (!seatSoul.trim()) return '';
|
|
||||||
return '## Seat Persona\n\n' + seatSoul.trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -220,8 +182,6 @@ function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): v
|
|||||||
cli_version: CLI_VERSION,
|
cli_version: CLI_VERSION,
|
||||||
config_home: harnessHome(runtime),
|
config_home: harnessHome(runtime),
|
||||||
config_home_isolated: true,
|
config_home_isolated: true,
|
||||||
config_home_kind: activeSeatDir() !== undefined ? 'seat' : 'runtime-shared',
|
|
||||||
agent_name: process.env['MOSAIC_AGENT_NAME']?.trim() || null,
|
|
||||||
config_home_env: HARNESS_HOME_ENV[runtime] ?? null,
|
config_home_env: HARNESS_HOME_ENV[runtime] ?? null,
|
||||||
argv: redactArgv(cliArgs),
|
argv: redactArgv(cliArgs),
|
||||||
normative_fragments: normativeFragmentDigests(runtime),
|
normative_fragments: normativeFragmentDigests(runtime),
|
||||||
@@ -609,11 +569,6 @@ For required push/merge/issue-close/release actions, execute without routine con
|
|||||||
if (soulLocal.trim()) {
|
if (soulLocal.trim()) {
|
||||||
overlayBlocks.push('## Persona Overlay (SOUL.local.md)\n\n' + soulLocal.trim());
|
overlayBlocks.push('## Persona Overlay (SOUL.local.md)\n\n' + soulLocal.trim());
|
||||||
}
|
}
|
||||||
// Seat persona (MOSAIC-D-002): per-seat SOUL.md layers on the generic base.
|
|
||||||
const seatPersona = seatPersonaOverlay(mosaicHome);
|
|
||||||
if (seatPersona !== '') {
|
|
||||||
overlayBlocks.push(seatPersona);
|
|
||||||
}
|
|
||||||
const standardsLocal = readOptional(join(mosaicHome, 'STANDARDS.local.md'));
|
const standardsLocal = readOptional(join(mosaicHome, 'STANDARDS.local.md'));
|
||||||
if (standardsLocal.trim()) {
|
if (standardsLocal.trim()) {
|
||||||
overlayBlocks.push('## Standards Overlay (STANDARDS.local.md)\n\n' + standardsLocal.trim());
|
overlayBlocks.push('## Standards Overlay (STANDARDS.local.md)\n\n' + standardsLocal.trim());
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
|
||||||
import { homedir, tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
||||||
import {
|
|
||||||
brainHomeIsActive,
|
|
||||||
fleetAgentEnvDir,
|
|
||||||
fleetProfilesDir,
|
|
||||||
fleetRolesLocalDir,
|
|
||||||
fleetStateDir,
|
|
||||||
resolveBrainHome,
|
|
||||||
type BrainHomeOptions,
|
|
||||||
} from './brain-home.js';
|
|
||||||
|
|
||||||
describe('fleet brain-home resolution', (): void => {
|
|
||||||
let cleanup: string | undefined;
|
|
||||||
|
|
||||||
const savedBrainEnv = process.env['MOSAIC_BRAIN_HOME'];
|
|
||||||
|
|
||||||
beforeEach((): void => {
|
|
||||||
delete process.env['MOSAIC_BRAIN_HOME'];
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async (): Promise<void> => {
|
|
||||||
if (savedBrainEnv === undefined) {
|
|
||||||
delete process.env['MOSAIC_BRAIN_HOME'];
|
|
||||||
} else {
|
|
||||||
process.env['MOSAIC_BRAIN_HOME'] = savedBrainEnv;
|
|
||||||
}
|
|
||||||
if (cleanup !== undefined) {
|
|
||||||
await rm(cleanup, { recursive: true, force: true });
|
|
||||||
cleanup = undefined;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function makeTmp(): Promise<string> {
|
|
||||||
const root = await mkdtemp(join(tmpdir(), 'mosaic-brain-home-'));
|
|
||||||
cleanup = root;
|
|
||||||
return root;
|
|
||||||
}
|
|
||||||
|
|
||||||
it('MOSAIC_BRAIN_HOME env wins over every other signal', (): void => {
|
|
||||||
process.env['MOSAIC_BRAIN_HOME'] = '/explicit/brain';
|
|
||||||
expect(resolveBrainHome('/any/mosaic-home')).toBe('/explicit/brain');
|
|
||||||
expect(fleetAgentEnvDir('/any/mosaic-home')).toBe('/explicit/brain/fleet/agents');
|
|
||||||
expect(brainHomeIsActive('/any/mosaic-home')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('injected envBrainHome wins identically (test seam)', (): void => {
|
|
||||||
const opts: BrainHomeOptions = { envBrainHome: '/injected/brain' };
|
|
||||||
expect(resolveBrainHome('/any/mosaic-home', opts)).toBe('/injected/brain');
|
|
||||||
expect(fleetAgentEnvDir('/any/mosaic-home', opts)).toBe('/injected/brain/fleet/agents');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a non-default mosaicHome never adopts the canonical brain (hermetic legacy)', (): void => {
|
|
||||||
const mosaicHome = '/tmp/not-the-default-config-home';
|
|
||||||
expect(resolveBrainHome(mosaicHome)).toBe(mosaicHome);
|
|
||||||
expect(brainHomeIsActive(mosaicHome)).toBe(false);
|
|
||||||
expect(fleetAgentEnvDir(mosaicHome)).toBe(join(mosaicHome, 'fleet', 'agents'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('the default config home adopts the brain when it carries fleet/agents', async (): Promise<void> => {
|
|
||||||
const root = await makeTmp();
|
|
||||||
const brain = join(root, 'brain');
|
|
||||||
await mkdir(join(brain, 'fleet', 'agents'), { recursive: true });
|
|
||||||
const configHome = join(root, 'config', 'mosaic');
|
|
||||||
const opts: BrainHomeOptions = { homes: { brain, configDefault: configHome } };
|
|
||||||
|
|
||||||
expect(resolveBrainHome(configHome, opts)).toBe(brain);
|
|
||||||
expect(fleetAgentEnvDir(configHome, opts)).toBe(join(brain, 'fleet', 'agents'));
|
|
||||||
expect(fleetRolesLocalDir(configHome, opts)).toBe(join(brain, 'fleet', 'roles.local'));
|
|
||||||
expect(fleetProfilesDir(configHome, opts)).toBe(join(brain, 'fleet', 'profiles'));
|
|
||||||
expect(fleetStateDir(configHome, opts)).toBe(join(brain, 'fleet'));
|
|
||||||
expect(brainHomeIsActive(configHome, opts)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('the default config home stays legacy when no brain exists', async (): Promise<void> => {
|
|
||||||
const root = await makeTmp();
|
|
||||||
const configHome = join(root, 'config', 'mosaic');
|
|
||||||
const opts: BrainHomeOptions = {
|
|
||||||
homes: { brain: join(root, 'brain'), configDefault: configHome },
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(resolveBrainHome(configHome, opts)).toBe(configHome);
|
|
||||||
expect(brainHomeIsActive(configHome, opts)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('an empty MOSAIC_BRAIN_HOME is ignored, not treated as set', (): void => {
|
|
||||||
process.env['MOSAIC_BRAIN_HOME'] = ' ';
|
|
||||||
expect(resolveBrainHome('/tmp/legacy-home')).toBe('/tmp/legacy-home');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('adoption requires fleet/agents specifically, not any brain content', async (): Promise<void> => {
|
|
||||||
const root = await makeTmp();
|
|
||||||
const brain = join(root, 'brain');
|
|
||||||
await mkdir(join(brain, 'fleet'), { recursive: true }); // fleet without agents
|
|
||||||
const configHome = join(root, 'config', 'mosaic');
|
|
||||||
const opts: BrainHomeOptions = { homes: { brain, configDefault: configHome } };
|
|
||||||
|
|
||||||
expect(resolveBrainHome(configHome, opts)).toBe(configHome);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('real-home control: a host brain is adopted only through the default home', (): void => {
|
|
||||||
// Control on the un-injected path: this host carries ~/.mosaic/fleet/agents,
|
|
||||||
// so the default config home resolves to the brain or legacy — both valid
|
|
||||||
// canonical endpoints — while a non-default home never adopts.
|
|
||||||
const defaultHome = join(homedir(), '.config', 'mosaic');
|
|
||||||
const resolved = resolveBrainHome(defaultHome);
|
|
||||||
expect([defaultHome, join(homedir(), '.mosaic')]).toContain(resolved);
|
|
||||||
expect(resolveBrainHome(join(homedir(), 'elsewhere', 'mosaic'))).toBe(
|
|
||||||
join(homedir(), 'elsewhere', 'mosaic'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { existsSync } from 'node:fs';
|
|
||||||
import { homedir } from 'node:os';
|
|
||||||
import { join, resolve } from 'node:path';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Overridable resolution inputs (tests inject tmp homes; production reads
|
|
||||||
* the environment and the real home directory).
|
|
||||||
*/
|
|
||||||
export interface BrainHomeOptions {
|
|
||||||
/** Explicit brain home; defaults to `MOSAIC_BRAIN_HOME`. */
|
|
||||||
readonly envBrainHome?: string;
|
|
||||||
/**
|
|
||||||
* Canonical homes used for adoption. Defaults derive from the real
|
|
||||||
* `homedir()`: `{ brain: ~/.mosaic, configDefault: ~/.config/mosaic }`.
|
|
||||||
*/
|
|
||||||
readonly homes?: { readonly brain: string; readonly configDefault: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Brain-home resolution — the three-tree fleet split (stack canon
|
|
||||||
* `docs/STRUCTURE-CANON.md` §2, first carried by the USC estate brain):
|
|
||||||
*
|
|
||||||
* config home (~/.config/mosaic) framework templates + dispatch state:
|
|
||||||
* fleet/roles (baseline), fleet/roster.yaml,
|
|
||||||
* fleet/run (heartbeats), fleet/services
|
|
||||||
* brain home (~/.mosaic) user-owned fleet state, committed:
|
|
||||||
* fleet/agents/<seat>.env.*, fleet/roles.local,
|
|
||||||
* fleet/profiles working copies
|
|
||||||
*
|
|
||||||
* Resolution order:
|
|
||||||
* 1. `MOSAIC_BRAIN_HOME` env (explicit, always wins)
|
|
||||||
* 2. canonical `~/.mosaic` — adopted ONLY when mosaicHome is the real
|
|
||||||
* default config home AND `~/.mosaic/fleet/agents` exists. Custom
|
|
||||||
* `--mosaic-home` values (tests, sandboxes, canaries) never trigger
|
|
||||||
* adoption, keeping them hermetic and deterministic.
|
|
||||||
* 3. mosaicHome itself (legacy single-tree behavior).
|
|
||||||
*/
|
|
||||||
export function resolveBrainHome(mosaicHome: string, options: BrainHomeOptions = {}): string {
|
|
||||||
const explicit = options.envBrainHome ?? process.env['MOSAIC_BRAIN_HOME'];
|
|
||||||
if (explicit !== undefined && explicit.trim() !== '') {
|
|
||||||
return explicit;
|
|
||||||
}
|
|
||||||
const homes = options.homes ?? {
|
|
||||||
brain: join(homedir(), '.mosaic'),
|
|
||||||
configDefault: join(homedir(), '.config', 'mosaic'),
|
|
||||||
};
|
|
||||||
if (resolve(mosaicHome) !== resolve(homes.configDefault)) {
|
|
||||||
return mosaicHome;
|
|
||||||
}
|
|
||||||
return existsSync(join(homes.brain, 'fleet', 'agents')) ? homes.brain : mosaicHome;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when fleet state resolves somewhere other than the config home. */
|
|
||||||
export function brainHomeIsActive(mosaicHome: string, options: BrainHomeOptions = {}): boolean {
|
|
||||||
return resolve(resolveBrainHome(mosaicHome, options)) !== resolve(mosaicHome);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fleet state root (brain home when active, else the config home). */
|
|
||||||
export function fleetStateDir(mosaicHome: string, options: BrainHomeOptions = {}): string {
|
|
||||||
return join(resolveBrainHome(mosaicHome, options), 'fleet');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Seat launch envs — `<brainHome>/fleet/agents` when a brain is active. */
|
|
||||||
export function fleetAgentEnvDir(mosaicHome: string, options: BrainHomeOptions = {}): string {
|
|
||||||
return join(fleetStateDir(mosaicHome, options), 'agents');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** PRESERVE-protected persona override layer — `<brainHome>/fleet/roles.local`. */
|
|
||||||
export function fleetRolesLocalDir(mosaicHome: string, options: BrainHomeOptions = {}): string {
|
|
||||||
return join(fleetStateDir(mosaicHome, options), 'roles.local');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** System-type profiles (user working copies) — `<brainHome>/fleet/profiles`. */
|
|
||||||
export function fleetProfilesDir(mosaicHome: string, options: BrainHomeOptions = {}): string {
|
|
||||||
return join(fleetStateDir(mosaicHome, options), 'profiles');
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import { lstat, open, readFile, unlink, type FileHandle } from 'node:fs/promises
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { fleetAgentEnvDir } from './brain-home.js';
|
|
||||||
import {
|
import {
|
||||||
applyPreparedAgentEnvironmentProjection,
|
applyPreparedAgentEnvironmentProjection,
|
||||||
prepareAgentEnvironmentProjection,
|
prepareAgentEnvironmentProjection,
|
||||||
@@ -618,7 +617,7 @@ function defaultPrepareProjections(
|
|||||||
(agent: FleetRosterV2Agent): Promise<PreparedAgentEnvironmentProjection> =>
|
(agent: FleetRosterV2Agent): Promise<PreparedAgentEnvironmentProjection> =>
|
||||||
prepareAgentEnvironmentProjection({
|
prepareAgentEnvironmentProjection({
|
||||||
mosaicHome,
|
mosaicHome,
|
||||||
agentEnvDir: fleetAgentEnvDir(mosaicHome),
|
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
|
||||||
agentName: agent.name,
|
agentName: agent.name,
|
||||||
generated: projectRosterV2AgentGeneratedEnv(roster, agent),
|
generated: projectRosterV2AgentGeneratedEnv(roster, agent),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -176,52 +176,6 @@ describe('generated fleet agent environment boundary', (): void => {
|
|||||||
expect((await stat(result.generatedPath)).mode & 0o777).toBe(0o600);
|
expect((await stat(result.generatedPath)).mode & 0o777).toBe(0o600);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('brain home: accepts and writes projections under MOSAIC_BRAIN_HOME/fleet/agents', async (): Promise<void> => {
|
|
||||||
const savedBrainHome = process.env['MOSAIC_BRAIN_HOME'];
|
|
||||||
try {
|
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
|
||||||
const mosaicHome = join(cleanup, 'config-home');
|
|
||||||
const brainHome = join(cleanup, 'brain');
|
|
||||||
const agentEnvDir = join(brainHome, 'fleet', 'agents');
|
|
||||||
process.env['MOSAIC_BRAIN_HOME'] = brainHome;
|
|
||||||
|
|
||||||
const result = await writeAgentEnvironmentProjection({
|
|
||||||
mosaicHome,
|
|
||||||
agentEnvDir,
|
|
||||||
agentName: 'coder0',
|
|
||||||
generated: generatedValues,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Projection landed in the brain tree, not under the config home.
|
|
||||||
expect(result.generatedPath).toBe(join(agentEnvDir, 'coder0.env.generated'));
|
|
||||||
expect((await stat(join(brainHome, 'fleet'))).mode & 0o777).toBe(0o700);
|
|
||||||
expect((await stat(agentEnvDir)).mode & 0o777).toBe(0o700);
|
|
||||||
expect((await stat(result.generatedPath)).mode & 0o777).toBe(0o600);
|
|
||||||
await expect(stat(join(mosaicHome, 'fleet'))).rejects.toThrow();
|
|
||||||
|
|
||||||
// A config-home agentEnvDir is now REJECTED while the brain is active —
|
|
||||||
// the boundary must not silently split state across two trees.
|
|
||||||
let rejected: unknown;
|
|
||||||
try {
|
|
||||||
await writeAgentEnvironmentProjection({
|
|
||||||
mosaicHome,
|
|
||||||
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
|
|
||||||
agentName: 'coder1',
|
|
||||||
generated: { ...generatedValues, MOSAIC_AGENT_NAME: 'coder1' },
|
|
||||||
});
|
|
||||||
} catch (caught: unknown) {
|
|
||||||
rejected = caught;
|
|
||||||
}
|
|
||||||
expect(rejected).toBeInstanceOf(AgentEnvBoundaryError);
|
|
||||||
} finally {
|
|
||||||
if (savedBrainHome === undefined) {
|
|
||||||
delete process.env['MOSAIC_BRAIN_HOME'];
|
|
||||||
} else {
|
|
||||||
process.env['MOSAIC_BRAIN_HOME'] = savedBrainHome;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('regenerates desired keys, relocates safe legacy local data, and quarantines forbidden legacy input', async (): Promise<void> => {
|
it('regenerates desired keys, relocates safe legacy local data, and quarantines forbidden legacy input', async (): Promise<void> => {
|
||||||
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
|
||||||
const mosaicHome = join(cleanup, 'mosaic');
|
const mosaicHome = join(cleanup, 'mosaic');
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { createHash, randomUUID } from 'node:crypto';
|
|||||||
import { chmod, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
import { chmod, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { dirname, join, resolve } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
import { fleetAgentEnvDir, resolveBrainHome } from './brain-home.js';
|
|
||||||
import { compareCodePoints } from './deterministic-order.js';
|
import { compareCodePoints } from './deterministic-order.js';
|
||||||
|
|
||||||
export type AgentEnvironmentKind = 'generated' | 'local';
|
export type AgentEnvironmentKind = 'generated' | 'local';
|
||||||
@@ -26,6 +25,12 @@ export interface AgentGeneratedProjectionDeletionOptions {
|
|||||||
readonly agentName: string;
|
readonly agentName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AgentLocalEnvironmentReadOptions {
|
||||||
|
readonly mosaicHome: string;
|
||||||
|
readonly agentEnvDir: string;
|
||||||
|
readonly agentName: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentEnvironmentProjectionResult {
|
export interface AgentEnvironmentProjectionResult {
|
||||||
readonly generatedPath: string;
|
readonly generatedPath: string;
|
||||||
readonly localPath: string;
|
readonly localPath: string;
|
||||||
@@ -146,6 +151,23 @@ export function parseAgentEnvironment(
|
|||||||
return Object.freeze(values);
|
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. */
|
/** Renders the roster-derived generated projection in a stable, complete key order. */
|
||||||
export function renderGeneratedAgentEnvironment(values: Readonly<Record<string, string>>): string {
|
export function renderGeneratedAgentEnvironment(values: Readonly<Record<string, string>>): string {
|
||||||
const normalized = normalizeGeneratedValues(values);
|
const normalized = normalizeGeneratedValues(values);
|
||||||
@@ -529,15 +551,12 @@ async function validatePrivateProjectionDirectory(
|
|||||||
mosaicHome: string,
|
mosaicHome: string,
|
||||||
agentEnvDir: string,
|
agentEnvDir: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Brain-home split (canon §2): seat envs live under the brain home's
|
const fleetDir = join(mosaicHome, 'fleet');
|
||||||
// fleet/agents when a brain is active; roster + templates stay config-home.
|
const expectedAgentEnvDir = join(fleetDir, 'agents');
|
||||||
const expectedAgentEnvDir = fleetAgentEnvDir(mosaicHome);
|
|
||||||
if (resolve(agentEnvDir) !== resolve(expectedAgentEnvDir)) {
|
if (resolve(agentEnvDir) !== resolve(expectedAgentEnvDir)) {
|
||||||
throw new AgentEnvBoundaryError('unsafe-directory', '(directory)', agentEnvDir);
|
throw new AgentEnvBoundaryError('unsafe-directory', '(directory)', agentEnvDir);
|
||||||
}
|
}
|
||||||
const stateHome = resolveBrainHome(mosaicHome);
|
await assertManagedDirectoryIfPresent(mosaicHome, false);
|
||||||
const fleetDir = join(stateHome, 'fleet');
|
|
||||||
await assertManagedDirectoryIfPresent(stateHome, false);
|
|
||||||
await assertManagedDirectoryIfPresent(fleetDir, false);
|
await assertManagedDirectoryIfPresent(fleetDir, false);
|
||||||
await assertManagedDirectoryIfPresent(agentEnvDir, true);
|
await assertManagedDirectoryIfPresent(agentEnvDir, true);
|
||||||
}
|
}
|
||||||
@@ -547,9 +566,8 @@ async function ensurePrivateProjectionDirectory(
|
|||||||
agentEnvDir: string,
|
agentEnvDir: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await validatePrivateProjectionDirectory(mosaicHome, agentEnvDir);
|
await validatePrivateProjectionDirectory(mosaicHome, agentEnvDir);
|
||||||
const stateHome = resolveBrainHome(mosaicHome);
|
const fleetDir = join(mosaicHome, 'fleet');
|
||||||
const fleetDir = join(stateHome, 'fleet');
|
await ensureManagedDirectory(mosaicHome, false);
|
||||||
await ensureManagedDirectory(stateHome, false);
|
|
||||||
await ensureManagedDirectory(fleetDir, false);
|
await ensureManagedDirectory(fleetDir, false);
|
||||||
await ensureManagedDirectory(agentEnvDir, true);
|
await ensureManagedDirectory(agentEnvDir, true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const RUNTIME_DEFS: Record<
|
|||||||
label: 'Claude Code',
|
label: 'Claude Code',
|
||||||
command: 'claude',
|
command: 'claude',
|
||||||
versionFlag: '--version',
|
versionFlag: '--version',
|
||||||
installHint: 'npm install -g @anthropic-ai/claude-code',
|
installHint: 'curl -fsSL https://claude.ai/install.sh | bash',
|
||||||
},
|
},
|
||||||
codex: {
|
codex: {
|
||||||
label: 'Codex',
|
label: 'Codex',
|
||||||
@@ -31,13 +31,13 @@ const RUNTIME_DEFS: Record<
|
|||||||
label: 'OpenCode',
|
label: 'OpenCode',
|
||||||
command: 'opencode',
|
command: 'opencode',
|
||||||
versionFlag: 'version',
|
versionFlag: 'version',
|
||||||
installHint: 'See https://opencode.ai for install instructions',
|
installHint: 'npm install -g opencode-ai',
|
||||||
},
|
},
|
||||||
pi: {
|
pi: {
|
||||||
label: 'Pi',
|
label: 'Pi',
|
||||||
command: 'pi',
|
command: 'pi',
|
||||||
versionFlag: '--version',
|
versionFlag: '--version',
|
||||||
installHint: 'curl -fsSL https://pi.dev/install.sh | sh',
|
installHint: 'npm install -g @earendil-works/pi-coding-agent',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// verify-release.mjs — the ONE canonical terminal verification command
|
|
||||||
// (SDLC-D-034, `pnpm verify:release`).
|
|
||||||
//
|
|
||||||
// Publication (.woodpecker/publish.yml `verify` step) is bound to terminal
|
|
||||||
// verification of the exact commit through this command, which is composed
|
|
||||||
// from the SAME commands the PR CI pipeline (.woodpecker/ci.yml) runs — CI and
|
|
||||||
// publish share one semantic checklist:
|
|
||||||
//
|
|
||||||
// stage | mirrors ci.yml step | commands
|
|
||||||
// --------------|---------------------|------------------------------------------
|
|
||||||
// sanitization | sanitization | verify-sanitized.sh, check-resident-
|
|
||||||
// | | budget.sh (--self-test + run),
|
|
||||||
// | | check-test-enumeration.sh
|
|
||||||
// upgrade-guard | upgrade-guard | test-upgrade-manifest-guard.sh,
|
|
||||||
// | | test-upgrade-rollback.sh,
|
|
||||||
// | | test-upgrade-durable-snapshot.sh,
|
|
||||||
// | | test-install-migration.sh
|
|
||||||
// typecheck | typecheck | pnpm typecheck (runs the checkout
|
|
||||||
// | | preflight, then turbo typecheck)
|
|
||||||
// lint | lint | pnpm lint
|
|
||||||
// format | format | pnpm format:check
|
|
||||||
// test | test | pnpm test
|
|
||||||
// build | publish.yml build | pnpm build
|
|
||||||
//
|
|
||||||
// Caller-provided prerequisites (kept at the pipeline level — see the comments
|
|
||||||
// in .woodpecker/ci.yml): `bash` + `rsync` for the guard stages, `openssl` and
|
|
||||||
// the pinned @earendil-works/pi-coding-agent for the test stage, and — on the
|
|
||||||
// postgres path only — the ci-postgres service plus
|
|
||||||
// `pnpm --filter @mosaicstack/db run db:migrate` before the test stage.
|
|
||||||
//
|
|
||||||
// This command works with DATABASE_URL set (CI postgres path) or unset (local
|
|
||||||
// PGlite path); it never sets, exports, or requires a database itself.
|
|
||||||
//
|
|
||||||
// scripts/verify-release.test.mjs enforces that this stage table keeps
|
|
||||||
// matching .woodpecker/ci.yml step-for-step, so the two surfaces cannot drift
|
|
||||||
// apart silently.
|
|
||||||
|
|
||||||
import { spawnSync } from 'node:child_process';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
export const STAGES = [
|
|
||||||
{
|
|
||||||
name: 'sanitization',
|
|
||||||
// Mirror of the .woodpecker/ci.yml `sanitization` step (minus its
|
|
||||||
// `apk add` environment prep). Kept as direct command strings here: the
|
|
||||||
// #1017 test-enumeration guard audits these paths through the ci.yml
|
|
||||||
// surface, so indirection from ci.yml into this file is not possible.
|
|
||||||
commands: [
|
|
||||||
'bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh',
|
|
||||||
'bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test',
|
|
||||||
'bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh',
|
|
||||||
'bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'upgrade-guard',
|
|
||||||
// Mirror of the .woodpecker/ci.yml `upgrade-guard` step (minus its
|
|
||||||
// `apk add` environment prep).
|
|
||||||
commands: [
|
|
||||||
'bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-manifest-guard.sh',
|
|
||||||
'bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-rollback.sh',
|
|
||||||
'bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh',
|
|
||||||
'bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// `pnpm typecheck` is `pnpm preflight && turbo run typecheck`, so the
|
|
||||||
// checkout preflight (scripts/preflight.mjs) is part of this stage exactly
|
|
||||||
// as it is part of the ci.yml `typecheck` step.
|
|
||||||
name: 'typecheck',
|
|
||||||
commands: ['pnpm typecheck'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'lint',
|
|
||||||
commands: ['pnpm lint'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'format',
|
|
||||||
commands: ['pnpm format:check'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// Requires `openssl` and the pinned `pi` binary on the pipeline path; see
|
|
||||||
// the caller-provided prerequisites above.
|
|
||||||
name: 'test',
|
|
||||||
commands: ['pnpm test'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'build',
|
|
||||||
commands: ['pnpm build'],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function stageByName(name) {
|
|
||||||
return STAGES.find((stage) => stage.name === name);
|
|
||||||
}
|
|
||||||
|
|
||||||
function missingBinaries(bins) {
|
|
||||||
return bins.filter(
|
|
||||||
(bin) => spawnSync('sh', ['-c', `command -v ${bin} >/dev/null 2>&1`]).status !== 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function runCommand(command) {
|
|
||||||
const result = spawnSync(command, { shell: true, stdio: 'inherit' });
|
|
||||||
if (result.error) {
|
|
||||||
console.error(`[verify:release] failed to launch '${command}': ${result.error.message}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (result.status !== 0) {
|
|
||||||
const reason = result.signal ? `terminated by ${result.signal}` : `exited ${result.status}`;
|
|
||||||
console.error(`[verify:release] command '${command}' ${reason}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Runs the complete mandatory verification set (or, with --stage <name>, the
|
|
||||||
// single named stage — used for wiring/smoke-testing, not for gating: only a
|
|
||||||
// run of every stage is a terminal verification). Fails fast: the first
|
|
||||||
// failing command aborts with a non-zero exit code. Returns the exit code.
|
|
||||||
export function verifyRelease({ stages = STAGES } = {}) {
|
|
||||||
const missing = missingBinaries(['bash', 'rsync']);
|
|
||||||
if (missing.length > 0) {
|
|
||||||
console.error(
|
|
||||||
`[verify:release] FATAL: required binaries missing from PATH: ${missing.join(', ')}. ` +
|
|
||||||
'The caller provides them (ci-base bakes bash; pipelines apk add rsync).',
|
|
||||||
);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
for (const stage of stages) {
|
|
||||||
console.log(`\n[verify:release] === stage: ${stage.name} ===`);
|
|
||||||
for (const command of stage.commands) {
|
|
||||||
console.log(`[verify:release] $ ${command}`);
|
|
||||||
if (!runCommand(command)) {
|
|
||||||
console.error(
|
|
||||||
`[verify:release] FATAL: stage '${stage.name}' failed — verification inconclusive`,
|
|
||||||
);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(`\n[verify:release] all ${stages.length} stage(s) passed`);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function main(argv) {
|
|
||||||
const stageFlagIndex = argv.indexOf('--stage');
|
|
||||||
if (stageFlagIndex !== -1) {
|
|
||||||
const name = argv[stageFlagIndex + 1];
|
|
||||||
const stage = stageByName(name);
|
|
||||||
if (!stage) {
|
|
||||||
console.error(
|
|
||||||
`[verify:release] unknown stage '${name ?? ''}' — expected one of: ${STAGES.map((entry) => entry.name).join(', ')}`,
|
|
||||||
);
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
process.exit(verifyRelease({ stages: [stage] }));
|
|
||||||
}
|
|
||||||
process.exit(verifyRelease());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
||||||
main(process.argv.slice(2));
|
|
||||||
}
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import { createRequire } from 'node:module';
|
|
||||||
import path from 'node:path';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import { STAGES } from './verify-release.mjs';
|
|
||||||
|
|
||||||
// SDLC-D-034 checkout invariant: publication in .woodpecker/publish.yml is
|
|
||||||
// bound to exact-commit terminal verification. This suite parses the real
|
|
||||||
// pipeline files and fails red when the gate is bypassed, weakened, or drifts
|
|
||||||
// out of sync with the canonical `pnpm verify:release` command.
|
|
||||||
|
|
||||||
// Reuse the monorepo's existing YAML parser (@mosaicstack/mosaic's direct
|
|
||||||
// dependency) instead of adding a root dependency or vendoring a parser.
|
|
||||||
const mosaicRequire = createRequire(
|
|
||||||
path.resolve(process.cwd(), 'packages', 'mosaic', 'package.json'),
|
|
||||||
);
|
|
||||||
const { parse: parseYaml } = mosaicRequire('yaml');
|
|
||||||
|
|
||||||
const publishYmlPath = path.join(process.cwd(), '.woodpecker', 'publish.yml');
|
|
||||||
const ciYmlPath = path.join(process.cwd(), '.woodpecker', 'ci.yml');
|
|
||||||
|
|
||||||
async function readPublishPipeline() {
|
|
||||||
return parseYaml(await readFile(publishYmlPath, 'utf8'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// A step has an external publication effect when its name starts with
|
|
||||||
// `publish` or when any command pushes an image to a registry.
|
|
||||||
function pushesImage(step) {
|
|
||||||
return (step.commands ?? []).some((command) =>
|
|
||||||
/(^|\s)(\/kaniko\/executor|docker push)\b|--destination/.test(command),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function publishEffectSteps(pipeline) {
|
|
||||||
return Object.entries(pipeline.steps ?? {})
|
|
||||||
.filter(([name, step]) => name.startsWith('publish') || pushesImage(step))
|
|
||||||
.map(([name]) => name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transitive closure of a step's depends_on graph.
|
|
||||||
function dependencyClosure(pipeline, stepName, seen = new Set()) {
|
|
||||||
const dependencies = pipeline.steps?.[stepName]?.depends_on ?? [];
|
|
||||||
for (const dependency of dependencies) {
|
|
||||||
if (seen.has(dependency)) continue;
|
|
||||||
seen.add(dependency);
|
|
||||||
dependencyClosure(pipeline, dependency, seen);
|
|
||||||
}
|
|
||||||
return seen;
|
|
||||||
}
|
|
||||||
|
|
||||||
function verifyCommands(pipeline) {
|
|
||||||
const verify = pipeline.steps?.verify;
|
|
||||||
assert.ok(verify, 'publish pipeline must define a `verify` step');
|
|
||||||
assert.ok(Array.isArray(verify.commands), '`verify` step must have commands');
|
|
||||||
return verify.commands;
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertCommitIdentityAssertion(commands) {
|
|
||||||
const text = commands.join('\n');
|
|
||||||
assert.match(
|
|
||||||
text,
|
|
||||||
/CI_COMMIT_SHA/,
|
|
||||||
'`verify` must compare the provider commit identity (CI_COMMIT_SHA)',
|
|
||||||
);
|
|
||||||
assert.match(text, /git rev-parse HEAD/, '`verify` must compare against git rev-parse HEAD');
|
|
||||||
assert.match(
|
|
||||||
text,
|
|
||||||
/exit 1/,
|
|
||||||
'`verify` must fail closed (exit 1) on identity mismatch or emptiness',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertCanonicalCommand(commands) {
|
|
||||||
assert.ok(
|
|
||||||
commands.some((command) => /^pnpm verify:release\b/.test(command.trim())),
|
|
||||||
'`verify` must run the canonical terminal verification command `pnpm verify:release`',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertPublishGate(pipeline) {
|
|
||||||
assert.ok(pipeline.steps, 'publish pipeline must define steps');
|
|
||||||
|
|
||||||
const commands = verifyCommands(pipeline);
|
|
||||||
assertCommitIdentityAssertion(commands);
|
|
||||||
assertCanonicalCommand(commands);
|
|
||||||
|
|
||||||
const effects = publishEffectSteps(pipeline);
|
|
||||||
assert.ok(effects.length > 0, 'publish pipeline must contain publish effect steps to guard');
|
|
||||||
|
|
||||||
for (const stepName of effects) {
|
|
||||||
const step = pipeline.steps[stepName];
|
|
||||||
assert.ok(
|
|
||||||
Array.isArray(step.depends_on) && step.depends_on.includes('verify'),
|
|
||||||
`publish effect '${stepName}' must depend DIRECTLY on the verify step (SDLC-D-034: transitively through build is not enough)`,
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
dependencyClosure(pipeline, stepName).has('verify'),
|
|
||||||
`publish effect '${stepName}' must depend on a chain that includes verify`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return effects;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('the publish pipeline gates every publish effect behind exact-commit verification', async () => {
|
|
||||||
const pipeline = await readPublishPipeline();
|
|
||||||
const effects = assertPublishGate(pipeline);
|
|
||||||
assert.deepEqual(effects.sort(), [
|
|
||||||
'build-appservice',
|
|
||||||
'build-gateway',
|
|
||||||
'build-web',
|
|
||||||
'publish-next-npm',
|
|
||||||
'publish-npm',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the verify step carries no path/event short-circuit of its own', async () => {
|
|
||||||
const pipeline = await readPublishPipeline();
|
|
||||||
// A `when` filter on `verify` would let a publish effect fire on an event
|
|
||||||
// class that skipped verification — the gate must be unconditional.
|
|
||||||
assert.equal(pipeline.steps.verify.when, undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a publish step that bypasses verify fails the gate checker', () => {
|
|
||||||
// Negative fixture: a plausible publish pipeline where `publish-npm` hangs
|
|
||||||
// off `build` only and `build` never chains to `verify` — the exact bypass
|
|
||||||
// class SDLC-D-034 closes. The checker must go red on it.
|
|
||||||
const bypassingPipeline = `
|
|
||||||
steps:
|
|
||||||
install:
|
|
||||||
image: node:24-alpine
|
|
||||||
commands:
|
|
||||||
- pnpm install --frozen-lockfile
|
|
||||||
verify:
|
|
||||||
image: node:24-alpine
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
if [ -z "$CI_COMMIT_SHA" ] || [ "$CI_COMMIT_SHA" != "$(git rev-parse HEAD)" ]; then
|
|
||||||
echo "identity mismatch" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
- pnpm verify:release
|
|
||||||
depends_on:
|
|
||||||
- install
|
|
||||||
build:
|
|
||||||
image: node:24-alpine
|
|
||||||
commands:
|
|
||||||
- pnpm build
|
|
||||||
depends_on:
|
|
||||||
- install
|
|
||||||
publish-npm:
|
|
||||||
image: node:24-alpine
|
|
||||||
commands:
|
|
||||||
- pnpm publish
|
|
||||||
depends_on:
|
|
||||||
- build
|
|
||||||
`;
|
|
||||||
assert.throws(
|
|
||||||
() => assertPublishGate(parseYaml(bypassingPipeline)),
|
|
||||||
/publish-npm.*DIRECTLY.*verify/s,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a publish step chained to verify only transitively fails the gate checker', () => {
|
|
||||||
// Negative fixture: `build` depends on verify but `publish-npm` does not
|
|
||||||
// carry the direct edge — weaker than SDLC-D-034 requires of the real DAG.
|
|
||||||
const transitiveOnlyPipeline = `
|
|
||||||
steps:
|
|
||||||
install:
|
|
||||||
image: node:24-alpine
|
|
||||||
commands:
|
|
||||||
- pnpm install --frozen-lockfile
|
|
||||||
verify:
|
|
||||||
image: node:24-alpine
|
|
||||||
commands:
|
|
||||||
- |
|
|
||||||
if [ -z "$CI_COMMIT_SHA" ] || [ "$CI_COMMIT_SHA" != "$(git rev-parse HEAD)" ]; then
|
|
||||||
echo "identity mismatch" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
- pnpm verify:release
|
|
||||||
depends_on:
|
|
||||||
- install
|
|
||||||
build:
|
|
||||||
image: node:24-alpine
|
|
||||||
commands:
|
|
||||||
- pnpm build
|
|
||||||
depends_on:
|
|
||||||
- install
|
|
||||||
- verify
|
|
||||||
publish-npm:
|
|
||||||
image: node:24-alpine
|
|
||||||
commands:
|
|
||||||
- pnpm publish
|
|
||||||
depends_on:
|
|
||||||
- build
|
|
||||||
`;
|
|
||||||
assert.throws(
|
|
||||||
() => assertPublishGate(parseYaml(transitiveOnlyPipeline)),
|
|
||||||
/publish-npm.*DIRECTLY.*verify/s,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a verify step without the commit-identity assertion fails the gate checker', () => {
|
|
||||||
const noIdentityPipeline = `
|
|
||||||
steps:
|
|
||||||
verify:
|
|
||||||
image: node:24-alpine
|
|
||||||
commands:
|
|
||||||
- pnpm verify:release
|
|
||||||
publish-npm:
|
|
||||||
image: node:24-alpine
|
|
||||||
commands:
|
|
||||||
- pnpm publish
|
|
||||||
depends_on:
|
|
||||||
- verify
|
|
||||||
`;
|
|
||||||
assert.throws(() => assertPublishGate(parseYaml(noIdentityPipeline)), /CI_COMMIT_SHA/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the canonical verify:release stages mirror the PR CI pipeline one-for-one', async () => {
|
|
||||||
const ci = parseYaml(await readFile(ciYmlPath, 'utf8'));
|
|
||||||
const canonical = Object.fromEntries(STAGES.map((stage) => [stage.name, stage.commands]));
|
|
||||||
|
|
||||||
// The complete mandatory set, in gate order.
|
|
||||||
assert.deepEqual(
|
|
||||||
STAGES.map((stage) => stage.name),
|
|
||||||
['sanitization', 'upgrade-guard', 'typecheck', 'lint', 'format', 'test', 'build'],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Guard stages: ci.yml commands minus its `apk add` environment prep must be
|
|
||||||
// exactly the canonical stage commands (order included).
|
|
||||||
for (const stageName of ['sanitization', 'upgrade-guard']) {
|
|
||||||
assert.deepEqual(
|
|
||||||
ci.steps[stageName].commands.filter((command) => !command.startsWith('apk add')),
|
|
||||||
canonical[stageName],
|
|
||||||
`canonical '${stageName}' stage must match the ci.yml step`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// pnpm stages: ci.yml commands minus `corepack enable` must be exactly the
|
|
||||||
// canonical stage commands.
|
|
||||||
for (const stepName of ['typecheck', 'lint', 'format']) {
|
|
||||||
assert.deepEqual(
|
|
||||||
ci.steps[stepName].commands.filter((command) => command !== 'corepack enable'),
|
|
||||||
canonical[stepName],
|
|
||||||
`canonical '${stepName}' stage must match the ci.yml step`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The test stage is shared, but ci.yml wraps it in pipeline-level
|
|
||||||
// prerequisites the canonical command expects its caller to provide
|
|
||||||
// (SDLC-D-034): the postgres service + readiness wait + db:migrate, openssl,
|
|
||||||
// and the pinned pi runtime. None of those may be dropped silently.
|
|
||||||
for (const command of canonical.test) {
|
|
||||||
assert.ok(
|
|
||||||
ci.steps.test.commands.includes(command),
|
|
||||||
`ci.yml test step must run the canonical test stage command '${command}'`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const fragment of [
|
|
||||||
'pg_isready -h ci-postgres',
|
|
||||||
'pnpm --filter @mosaicstack/db run db:migrate',
|
|
||||||
'npm install -g @earendil-works/[email protected]',
|
|
||||||
]) {
|
|
||||||
assert.ok(
|
|
||||||
ci.steps.test.commands.some((command) => command.includes(fragment)),
|
|
||||||
`ci.yml test step must keep its pipeline-level prerequisite '${fragment}'`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the root package.json exposes verify:release as the canonical command', async () => {
|
|
||||||
const packageJson = JSON.parse(await readFile(path.join(process.cwd(), 'package.json'), 'utf8'));
|
|
||||||
assert.match(packageJson.scripts['verify:release'], /scripts\/verify-release\.mjs/);
|
|
||||||
});
|
|
||||||
@@ -133,13 +133,10 @@ When the full `@mosaicstack/forge` package is available, Forge uses MACP task ex
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run from CLI
|
# Run from CLI
|
||||||
# Fails closed with a typed FORGE_NO_EXECUTOR capability error when no real
|
mosaic forge run path/to/brief.md
|
||||||
# executor is wired — pass --simulate to opt into explicit typed simulation
|
|
||||||
# (every result carries status `simulated`, which satisfies nothing).
|
|
||||||
mosaic forge run path/to/brief.md [--simulate]
|
|
||||||
|
|
||||||
# Resume interrupted run (same fail-closed rule as forge run)
|
# Resume interrupted run
|
||||||
mosaic forge resume .forge/runs/20260401-143022/ [--simulate]
|
mosaic forge resume .forge/runs/20260401-143022/
|
||||||
|
|
||||||
# Check status
|
# Check status
|
||||||
mosaic forge status .forge/runs/20260401-143022/
|
mosaic forge status .forge/runs/20260401-143022/
|
||||||
|
|||||||
Reference in New Issue
Block a user