Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68279d61a1 | ||
|
|
771127d3cd | ||
|
|
ae95e7b853 | ||
|
|
367cb27591 | ||
|
|
18905d69e6 | ||
|
|
ff45f7b5d0 | ||
|
|
64350892e7 | ||
|
|
6e9df3c640 | ||
|
|
f5ba042dfa | ||
|
|
7c7dab3898 | ||
|
|
d92de53399 | ||
|
|
d7e303d3c0 | ||
|
|
726d2ad3a2 | ||
|
|
e4ee1acf24 | ||
|
|
5c5a25e4de | ||
|
|
7669321ea2 | ||
|
|
d8e0aec950 | ||
|
|
49d6136b02 | ||
|
|
a80bae950d | ||
|
|
8199261caa | ||
|
|
57a2f2b40e | ||
|
|
93c1de51e1 | ||
|
|
476db12b92 | ||
|
|
5198c3f198 | ||
|
|
19ac0a02d7 | ||
|
|
6d9387c857 | ||
|
|
14cb9c6a1e | ||
|
|
b5b322f80d | ||
|
|
e4674709be | ||
|
|
c56483eb1b | ||
|
|
5c35a250de | ||
|
|
10a1f82031 | ||
|
|
b61789fe26 | ||
|
|
61a907a12f | ||
|
|
6f5b4c3dc1 | ||
|
|
67f5014cc0 | ||
|
|
463745e314 | ||
|
|
03eda02c20 | ||
|
|
3b4055017e | ||
|
|
07373ede4d | ||
|
|
fb5bb98a32 | ||
|
|
47e90767b7 | ||
|
|
00bc602f93 | ||
|
|
d0c223bdf9 | ||
|
|
cc0d24d5c4 | ||
|
|
40fecd4d38 |
@@ -22,9 +22,9 @@ steps:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
from_secret: REGISTRY_USERNAME
|
||||
REGISTRY_PASS:
|
||||
from_secret: gitea_password
|
||||
from_secret: REGISTRY_PASSWORD
|
||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||
|
||||
+24
-23
@@ -30,6 +30,19 @@ steps:
|
||||
# the baked pnpm store.
|
||||
- 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
|
||||
# personal data or private $HOME defaults. Runs early (no node_modules needed).
|
||||
sanitization:
|
||||
@@ -46,29 +59,8 @@ steps:
|
||||
# [0] of the pnpm chain, so severing that chain would silence it together
|
||||
# with everything it guards; this direct line keeps one instrument running.
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
|
||||
# Tool-index gate: a shipped wrapper that appears in no resident index doc
|
||||
# is undiscoverable from inside a session, and an agent that cannot learn a
|
||||
# wrapper exists reaches for raw curl instead — which is how a Gitea review
|
||||
# got filed PENDING three times. Ships-and-documented is one commit, or red.
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh --self-test
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/check-tools-index.sh
|
||||
# Hermetic regression for issue-close.sh (#1081): mocks tea/curl onto PATH
|
||||
# and sandboxes a throwaway git repo, so it resolves no real credentials and
|
||||
# joins CI directly rather than the exclusions file.
|
||||
- bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh
|
||||
# Hermetic behavioural regression for the PreToolUse wrapper guard: proves
|
||||
# it still blocks the three mistakes AND still lets reads, unwrapped
|
||||
# endpoints and ordinary commands through. Both directions are asserted —
|
||||
# a guard that over-blocks gets routed around, which fails just as hard.
|
||||
- bash packages/mosaic/framework/tools/git/test-wrapper-guard.sh
|
||||
# Hermetic regression for mosaic-worktree.sh at fleet scale: stubs git onto
|
||||
# PATH so `list` faces ~450 KB of porcelain. The defect it pins is invisible
|
||||
# at small size — `git … | awk '…exit'` gives the producer SIGPIPE, which
|
||||
# under `set -euo pipefail` aborts the caller silently with rc=141 and no
|
||||
# output. A repo only reaches that once it has enough worktrees, so the
|
||||
# stub supplies the scale instead of the host's own checkout.
|
||||
- bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh
|
||||
|
||||
# Canonical verify:release stage `upgrade-guard`.
|
||||
# Blocking gate (#791): a framework upgrade must never write or delete an
|
||||
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
||||
# survives a keep-mode reseed byte-identical (with rsync present AND absent —
|
||||
@@ -90,6 +82,8 @@ steps:
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.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:
|
||||
image: *node_image
|
||||
commands:
|
||||
@@ -100,7 +94,8 @@ steps:
|
||||
- sanitization
|
||||
- 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:
|
||||
image: *node_image
|
||||
commands:
|
||||
@@ -117,6 +112,12 @@ steps:
|
||||
depends_on:
|
||||
- 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:
|
||||
image: *node_image
|
||||
environment:
|
||||
|
||||
+66
-6
@@ -1,5 +1,19 @@
|
||||
# Build, publish npm packages, and push Docker 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:
|
||||
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
||||
@@ -48,6 +62,45 @@ steps:
|
||||
# Resolve from the baked pnpm store instead of a cold network fetch.
|
||||
- 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:
|
||||
image: *node_image
|
||||
commands:
|
||||
@@ -55,6 +108,7 @@ steps:
|
||||
- pnpm build
|
||||
depends_on:
|
||||
- install
|
||||
- verify
|
||||
|
||||
publish-npm:
|
||||
image: *node_image
|
||||
@@ -114,6 +168,7 @@ steps:
|
||||
exit 1
|
||||
depends_on:
|
||||
- build
|
||||
- verify
|
||||
|
||||
publish-next-npm:
|
||||
image: *node_image
|
||||
@@ -192,6 +247,7 @@ steps:
|
||||
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
|
||||
depends_on:
|
||||
- build
|
||||
- verify
|
||||
|
||||
# TODO: Uncomment when ready to publish to npmjs.org
|
||||
# publish-npmjs:
|
||||
@@ -205,6 +261,7 @@ steps:
|
||||
# - bash scripts/publish-npmjs.sh
|
||||
# depends_on:
|
||||
# - build
|
||||
# - verify
|
||||
# when:
|
||||
# - event: [tag]
|
||||
|
||||
@@ -213,9 +270,9 @@ steps:
|
||||
when: *image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
from_secret: REGISTRY_USERNAME
|
||||
REGISTRY_PASS:
|
||||
from_secret: gitea_password
|
||||
from_secret: REGISTRY_PASSWORD
|
||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||
@@ -242,15 +299,16 @@ steps:
|
||||
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
|
||||
depends_on:
|
||||
- build
|
||||
- verify
|
||||
|
||||
build-appservice:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *main_image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
from_secret: REGISTRY_USERNAME
|
||||
REGISTRY_PASS:
|
||||
from_secret: gitea_password
|
||||
from_secret: REGISTRY_PASSWORD
|
||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||
@@ -268,15 +326,16 @@ steps:
|
||||
/kaniko/executor --context . --dockerfile docker/appservice.Dockerfile $DESTINATIONS
|
||||
depends_on:
|
||||
- build
|
||||
- verify
|
||||
|
||||
build-web:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *main_image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
from_secret: REGISTRY_USERNAME
|
||||
REGISTRY_PASS:
|
||||
from_secret: gitea_password
|
||||
from_secret: REGISTRY_PASSWORD
|
||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||
@@ -294,3 +353,4 @@ steps:
|
||||
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
|
||||
depends_on:
|
||||
- build
|
||||
- verify
|
||||
|
||||
@@ -74,14 +74,6 @@ The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md
|
||||
|
||||
Pi launches default to a token-lean skill posture: `mosaic pi` passes `--no-skills` so Pi does not preload every global skill description into the system prompt. Use `MOSAIC_PI_SKILL_MODE=all mosaic pi` for the legacy all-skills catalog, or `MOSAIC_PI_SKILL_MODE=discover mosaic pi` to let Pi use its native settings/project skill discovery.
|
||||
|
||||
Mosaic also loads its Pi extensions from `~/.config/mosaic/runtime/pi/`. Inside Pi,
|
||||
`/goal set <statement>` starts a bounded persistent loop that checks every turn and successful
|
||||
compaction, requires two evidence-bearing completion reports, and can be inspected or stopped with
|
||||
`/goal status`, `/goal pause`, `/goal resume`, and `/goal cancel`. Controller-owned goal-state
|
||||
entries redact common credential shapes, but Pi's model/tool-call history is separate, so goals and
|
||||
evidence must never contain secrets or raw sensitive output. Mosaic does not install this extension
|
||||
into `~/.pi/agent/extensions/`.
|
||||
|
||||
### TUI & Gateway
|
||||
|
||||
```bash
|
||||
@@ -146,9 +138,9 @@ mosaic brain tasks
|
||||
mosaic brain conversations
|
||||
|
||||
# Agent forge pipeline
|
||||
mosaic forge run
|
||||
mosaic forge run [--simulate] # fails closed (FORGE_NO_EXECUTOR) with no executor wired; --simulate for typed simulated runs
|
||||
mosaic forge status
|
||||
mosaic forge resume
|
||||
mosaic forge resume [--simulate] # same fail-closed rule as forge run
|
||||
mosaic forge personas
|
||||
|
||||
# Structured logging
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
# T1 report: canonical ungated Claude base and lease overlay
|
||||
|
||||
## Changed
|
||||
|
||||
- Replaced `packages/mosaic/framework/runtime/claude/settings.json` with the canonical ungated base. It retains the model, QA hooks, plugins, command allowlist, permissions, and `mcpServers.sequential-thinking`.
|
||||
- Added `packages/mosaic/framework/runtime/claude/lease-overlay.json`. It contains only `hooks` and the six removed lease hook entries.
|
||||
- Added the byte-identical pre-split source fixture at `packages/mosaic/src/runtime/fixtures/claude-settings.gated.pre-split.json`.
|
||||
- Added `packages/mosaic/src/runtime/claude-settings-base.spec.ts`.
|
||||
|
||||
`framework-manifest.txt` already declares `runtime/**`, so the new overlay is framework-owned and shipped without a manifest change.
|
||||
|
||||
## Lease-hook enumeration
|
||||
|
||||
The actual template has six lease hook entries, matching fred's refined boundary:
|
||||
|
||||
1. `PreToolUse` matcher `.*`: `mutator-gate.py`
|
||||
2. `Stop`: one combined command containing `receipt-observer-client.py` then `promote-complete.py`
|
||||
3. `UserPromptSubmit` matcher `^/mosaic-promote$`: `promote-begin.py`
|
||||
4. `PreCompact`: `revoke-lease.py --reason pre-compact`
|
||||
5. `SessionStart` matcher `compact`: `revoke-lease.py --reason session-start-compact`
|
||||
6. `SessionStart` matcher `resume|clear`: `revoke-lease.py --reason session-start-rollover --bump-generation`
|
||||
|
||||
There is no delta from the refined six-entry enumeration. The Stop entry contains the receipt-observer and promote-complete commands together, rather than as two separate hook objects.
|
||||
|
||||
## Tests and checks
|
||||
|
||||
`pnpm install --frozen-lockfile` was run first because `node_modules` was absent. It completed successfully.
|
||||
|
||||
Red-first run before artifacts existed:
|
||||
|
||||
```text
|
||||
RUN v2.1.9 .../packages/mosaic
|
||||
❯ src/runtime/claude-settings-base.spec.ts (4 tests | 4 failed)
|
||||
× keeps every lease command out of the ungated base
|
||||
→ mutator-gate: expected true to be false
|
||||
× reconstructs the pre-split gated hooks while retaining the canonical MCP correction
|
||||
→ ENOENT: .../lease-overlay.json
|
||||
× ships sequential-thinking in the base
|
||||
→ expected undefined to deeply equal { 'sequential-thinking': ... }
|
||||
× limits the overlay to lease hook entries
|
||||
→ ENOENT: .../lease-overlay.json
|
||||
```
|
||||
|
||||
Final focused acceptance run:
|
||||
|
||||
```text
|
||||
RUN v2.1.9 .../packages/mosaic
|
||||
✓ src/runtime/claude-settings-base.spec.ts (4 tests) 19ms
|
||||
Test Files 1 passed (1)
|
||||
Tests 4 passed (4)
|
||||
```
|
||||
|
||||
`pnpm --filter @mosaicstack/mosaic lint` passed:
|
||||
|
||||
```text
|
||||
> @mosaicstack/[email protected] lint
|
||||
> eslint src
|
||||
```
|
||||
|
||||
`pnpm --filter @mosaicstack/mosaic typecheck` failed on pre-existing workspace resolution and unrelated package errors. The new spec no longer appears in the error list. Initial failures include missing `@mosaicstack/{brain,forge,log,macp,memory,queue,storage,quality-rails,db,config,prdy,types}` declarations, followed by existing `fleet-backlog.ts`, `gateway-doctor.ts`, and TUI implicit-`any` errors. Exit status: 2.
|
||||
|
||||
A focused legacy consumer run confirms an existing assumption that `settings.json` itself is gated:
|
||||
|
||||
```text
|
||||
pnpm --filter @mosaicstack/mosaic exec vitest run src/mutator-gate/mutator-gate.acceptance.spec.ts
|
||||
❯ src/mutator-gate/mutator-gate.acceptance.spec.ts (20 tests | 6 failed)
|
||||
× non-dangerous parser residual is denied by the global all-tools hook without a lease
|
||||
→ expected all-tools mutator-gate command in settings.json
|
||||
× Claude and Pi compaction observer wiring is complete and fail-closed
|
||||
→ expected PreCompact/SessionStart revoke-lease hooks in settings.json
|
||||
```
|
||||
|
||||
The other four failures in that focused run reported `STALE_GENERATION` where the test expected `MUTATOR_UNVERIFIED`, plus one successful-gate assertion. I did not redesign this legacy suite because the task explicitly says to report consumers that assume the base is gated.
|
||||
|
||||
## Consumers found
|
||||
|
||||
Direct `runtime/claude/settings.json` path consumers found by the required repository grep:
|
||||
|
||||
- `packages/mosaic/framework/tools/_scripts/mosaic-link-runtime-assets`: copies the base to `~/.claude/settings.json`.
|
||||
- `packages/mosaic/src/commands/install-ordering-guard.ts` and `.spec.ts`: documentation and behavior assume the source embeds enforcement hooks.
|
||||
- `packages/mosaic/framework/tools/_scripts/test-install-ordering-guard.sh`: comments and assertions expect `mutator-gate.py` and `receipt-observer-client.py` in the base.
|
||||
- `packages/mosaic/src/mutator-gate/mutator-gate.acceptance.spec.ts`: reads the base and asserts mutator, promotion, and compaction lease wiring.
|
||||
- `packages/mosaic/src/lease-broker/promotion_trigger_unittest.py`: reads the base and asserts promotion wiring.
|
||||
- `packages/mosaic/src/lease-broker/recovery_runtime_unittest.py`: reads the base.
|
||||
- `packages/mosaic/src/runtime/update-checker.ts` and `.spec.ts`: references the path in settings wiring/update checks.
|
||||
- Documentation-only references: `docs/compaction-refresh/probes/p6_constrained_recovery.py`, `docs/plans/agent-reflection-loop-PRD.md`, `docs/tasks/544-agent-reflection-loop.md`, and the framework QA documentation/scripts found by grep.
|
||||
|
||||
I did not change these consumers. The install/link and lease acceptance consumers must be taught to select and compose `lease-overlay.json` when a gated promotion seat is requested. That composition behavior is outside T1.
|
||||
|
||||
## Ambiguity handled
|
||||
|
||||
The exact pre-split template fixture has no `mcpServers` key (SHA-256 `44e74ea1e9d424fffa020ee666402662ac856b88bf6ae7f3b8931eed29dc75a4`). The task simultaneously requires a byte-for-byte pre-split fixture, `mcpServers.sequential-thinking` in the base, and `deep-merge(base, overlay) == original`. Those three conditions cannot all hold because a merge cannot remove the required MCP key.
|
||||
|
||||
The acceptance test preserves the exact fixture and asserts that the normalized merge equals the pre-split template plus the required canonical `mcpServers.sequential-thinking` correction. It verifies all original hook content is reconstructed and the base carries the required MCP. Production three-layer merge semantics remain W-F1 work.
|
||||
-102
@@ -1,102 +0,0 @@
|
||||
# REPORT-T2
|
||||
|
||||
Date: 2026-08-13 11:29 CDT
|
||||
Branch: `feat/wf-fleet-t2-launch`
|
||||
Base: `216cd722`
|
||||
Issue: #1209
|
||||
|
||||
## What changed
|
||||
|
||||
- Added `mosaic fleet launch <name> [--dry-run]` in `packages/mosaic/src/commands/fleet-launch-command.ts` and registered it on the existing fleet command.
|
||||
- Added strict schema-one parsing for the user-owned `~/.mosaic/fleet/agents/<name>/profile.json`:
|
||||
- required `schema` and `harness`
|
||||
- default bundle `primary`
|
||||
- optional `model`, `overlay`, `plugins`, `skills`, and string-valued `env`
|
||||
- unknown-key refusal naming the key
|
||||
- dedicated `SCHEMA_TOO_NEW` code and upgrade guidance
|
||||
- Added the three-layer settings composer. Objects merge recursively, scalars use the higher layer, arrays replace, and `null` deletes a key. The selected agent overlay defaults to no overlay when the profile field is absent.
|
||||
- Writes canonical merged settings to `<agent-home>/settings.json` and the future harvest comparison snapshot to `<agent-dir>/settings.generated.json`.
|
||||
- Resolves `primary` to its named bundle, reads an optional account email, and reports forms such as `primary -> fred_example.com ([email protected])`.
|
||||
- Validates credential targets with `lstat`, rejects symlink credential files, resolves and checks containment under the harness auth root, and refuses a real credential file at the seat-link path as first-auth state.
|
||||
- Installs selected plugin and skill entries as seat-local symlinks, prunes stale symlinks, and refuses real objects instead of deleting them.
|
||||
- Builds a declared seat environment with the harness home variable, `MOSAIC_AGENT_NAME`, and profile environment entries. Mechanical values override conflicting profile entries.
|
||||
- Extended `launch.ts` so `harnessHome()` accepts fleet context and remains the home-resolution seam. The fleet launcher uses the existing runtime preflight, prompt, ledger, lease-gated, and process execution path over a minimal ambient environment.
|
||||
- Added deterministic dry-run output containing source layers, merged settings, output and snapshot paths, resolved bundle, symlink plans, declared environment, and harness argv.
|
||||
- Added 17 focused tests, including the required merge, schema, A3, dry-run snapshot, managed-link, command dry-run, execution-seam, and non-zero failure cases.
|
||||
|
||||
## Reconciliation decisions and contradictions
|
||||
|
||||
### Prominent contradiction: roster registries do not contain the frozen launch schema
|
||||
|
||||
The existing code has two other profile/registry concepts:
|
||||
|
||||
- `fleet-profiles.ts` models system-type YAML roster templates. Its `FleetProfile` has no harness bundle, overlay, plugin, skill, or seat environment fields.
|
||||
- roster-v2 models topology and lifecycle. It requires class, provider, reasoning, tool policy, working directory, lifecycle, and launch-yolo fields that schema-one `profile.json` does not contain.
|
||||
|
||||
Deriving a complete roster-v2 member from the frozen per-agent profile is therefore not possible without inventing values. Launch now reads only the per-agent `profile.json` and does not require roster-v2 or the legacy v1 roster. roster-v2 remains the existing lifecycle/topology registry. No second launch registry was introduced.
|
||||
|
||||
The pre-existing `resolveFleetIdentity()` path requires a legacy roster and a secure tmux helper whenever `MOSAIC_AGENT_NAME` is present during contract composition. For profile-backed launch, `launch.ts` excludes roster identity keys only from the contract-build environment, then exports the declared profile seat identity to the harness process. Legacy root runtime launches retain the existing roster-backed behavior. This is the smallest reconciliation that allows profile-only launch without fabricating roster-v2 fields.
|
||||
|
||||
### Historical whole-store plugin link
|
||||
|
||||
The prototype used a whole `plugins` directory symlink, while this task requires selected entry links and pruning. Launch refuses that historical shape with an explicit migration message. It does not delete or silently convert the whole-store link.
|
||||
|
||||
### Existing `FleetProfile` name
|
||||
|
||||
The system-type YAML `FleetProfile` remains unchanged. The new type is named `FleetAgentLaunchProfile` to keep the concepts separate while treating per-agent `profile.json` as the launch SSOT.
|
||||
|
||||
## Ambiguities and bounded choices
|
||||
|
||||
- The design does not freeze the generated snapshot filename. This implementation uses `settings.generated.json` in the agent directory, beside the hidden harness home.
|
||||
- The design explicitly identifies Claude `.credentials.json` and Pi `auth.json`. Codex and OpenCode use `auth.json` in the filename map, matching their harness-home composition shape, but no real credential launch was performed in this task.
|
||||
- Full interactive harvest-back disposition is not implemented. The task asks to store the generated snapshot for the future diff, and this change does that.
|
||||
- A machine descriptor file and content digests were not added. Dry-run and execution consume one resolved in-memory composition, and dry-run prints that composition.
|
||||
- No real harness process or real operator home was used. Every new filesystem test uses a temporary fixture root.
|
||||
|
||||
## Test run
|
||||
|
||||
Dependency install and build:
|
||||
|
||||
```text
|
||||
$ pnpm install --frozen-lockfile
|
||||
Scope: all 28 workspace projects
|
||||
Lockfile is up to date, resolution step is skipped
|
||||
Done in 4.7s using pnpm v10.6.2
|
||||
|
||||
$ pnpm --filter @mosaicstack/mosaic... build
|
||||
Scope: 13 of 28 workspace projects
|
||||
packages/mosaic build: Done
|
||||
```
|
||||
|
||||
Focused and touched integration tests:
|
||||
|
||||
```text
|
||||
$ pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet-launch-command.spec.ts src/commands/launch.spec.ts src/commands/fleet.spec.ts
|
||||
Test Files 3 passed (3)
|
||||
Tests 256 passed (256)
|
||||
```
|
||||
|
||||
Typecheck and lint:
|
||||
|
||||
```text
|
||||
$ pnpm --filter @mosaicstack/mosaic typecheck
|
||||
> tsc --noEmit
|
||||
(exit 0)
|
||||
|
||||
$ pnpm exec eslint packages/mosaic/src/commands/fleet-launch-command.ts packages/mosaic/src/commands/fleet-launch-command.spec.ts packages/mosaic/src/commands/launch.ts packages/mosaic/src/commands/fleet.ts packages/mosaic/src/commands/fleet.spec.ts
|
||||
(exit 0)
|
||||
|
||||
$ pnpm exec prettier --check packages/mosaic/src/commands/fleet-launch-command.ts packages/mosaic/src/commands/fleet-launch-command.spec.ts packages/mosaic/src/commands/launch.ts packages/mosaic/src/commands/fleet.ts packages/mosaic/src/commands/fleet.spec.ts
|
||||
Checking formatting...
|
||||
All matched files use Prettier code style!
|
||||
```
|
||||
|
||||
Package-wide Vitest result:
|
||||
|
||||
```text
|
||||
$ pnpm --filter @mosaicstack/mosaic exec vitest run
|
||||
Test Files 1 failed | 83 passed (84)
|
||||
Tests 4 failed | 1535 passed (1539)
|
||||
```
|
||||
|
||||
All four failures are in `src/mutator-gate/mutator-gate.acceptance.spec.ts`. Three expected `MUTATOR_UNVERIFIED` but received `STALE_GENERATION`; one runtime-gate assertion expected status zero and received status two. An isolated rerun produced the same four failures. I did not confirm whether they predate this branch. The focused launch, fleet, and typecheck runs are green.
|
||||
@@ -1,46 +0,0 @@
|
||||
# T3 report: `mosaic fleet agent new`
|
||||
|
||||
## Changed
|
||||
|
||||
- Added `packages/mosaic/src/fleet/fleet-agent-scaffold.ts`.
|
||||
- Creates user-owned seats at `~/.mosaic/fleet/agents/<name>` (test seam: `fleetDataHome`, environment default: `MOSAIC_DATA_HOME`).
|
||||
- Writes schema-one `profile.json` with default `harness: "claude"`, `bundle: "primary"`, optional `model`, `overlay: "overlay.json"`, and mandatory `env.MOSAIC_AGENT_NAME`.
|
||||
- Writes a positive `SOUL.md` identity and materializes that identity in `.claude/CLAUDE.md` or `.pi/AGENTS.md`.
|
||||
- Writes `overlay.json` as `{}`. Claude homes get `.claude.json` with `hasCompletedOnboarding: true` and `theme: "dark"`. No settings file is composed.
|
||||
- Creates the appropriate credential symlink (`.credentials.json` for Claude, `auth.json` for Pi), allowing an intentional dangling destination and reporting it at the command surface.
|
||||
- Compares every existing object (including link targets as link text), succeeds only byte-identically, and otherwise refuses with the differing paths.
|
||||
- Added `packages/mosaic/src/commands/fleet-agent-scaffold-command.ts` and wired `fleet agent new <name> [--harness claude|pi] [--bundle B] [--model M]` in `packages/mosaic/src/commands/fleet.ts`.
|
||||
- Added `packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts` with temp-root-only coverage: exact Claude/Pi layouts, literal quote/backtick/`$( )` handling, unsafe names and option failures, idempotence, changed-file refusal, and credential-link comparison.
|
||||
|
||||
## Reconciliation
|
||||
|
||||
`fleet-agent-crud-command.ts` currently registers roster-v2 `get/create/update/delete/plan` directly under `mosaic fleet`; it has no `agent new` command or profile schema. T3 adds an `agent` namespace for the profile-owned user-data scaffold and leaves roster-v2 CRUD unchanged.
|
||||
|
||||
No roster projection is created. Current roster-v2 requires fields that cannot be derived from the new profile (`class`, provider, working directory, reasoning, tool policy, lifecycle), while no current `mosaic fleet launch <name>` consumes these profiles. Writing such a roster entry would create the forbidden second registry and invent semantics. The profile is therefore the sole state created here. When the launcher owns profile-to-roster projection, it must derive it there and emit the required actionable unscaffolded-name message.
|
||||
|
||||
## Validation
|
||||
|
||||
```text
|
||||
$ pnpm install --frozen-lockfile
|
||||
Done in 4.1s using pnpm v10.6.2
|
||||
|
||||
$ pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet-agent-scaffold-command.spec.ts
|
||||
✓ src/commands/fleet-agent-scaffold-command.spec.ts (13 tests) 28ms
|
||||
Test Files 1 passed (1)
|
||||
Tests 13 passed (13)
|
||||
|
||||
$ pnpm --filter @mosaicstack/mosaic exec eslint src/fleet/fleet-agent-scaffold.ts src/commands/fleet-agent-scaffold-command.ts src/commands/fleet-agent-scaffold-command.spec.ts src/commands/fleet.ts
|
||||
(exit 0)
|
||||
|
||||
$ pnpm exec prettier --check packages/mosaic/src/fleet/fleet-agent-scaffold.ts packages/mosaic/src/commands/fleet-agent-scaffold-command.ts packages/mosaic/src/commands/fleet-agent-scaffold-command.spec.ts packages/mosaic/src/commands/fleet.ts
|
||||
All matched files use Prettier code style!
|
||||
|
||||
$ git diff --check
|
||||
(exit 0)
|
||||
```
|
||||
|
||||
`pnpm --filter @mosaicstack/mosaic typecheck` remains blocked by pre-existing unresolved workspace package entries (`@mosaicstack/brain`, `@mosaicstack/db`, `@mosaicstack/types`, and others). The typecheck output had no diagnostics naming T3 files. Running the pre-existing CRUD command spec is blocked by the same `@mosaicstack/db` Vite resolution failure through `fleet-backlog.ts`.
|
||||
|
||||
## Skipped ambiguity
|
||||
|
||||
The design asks for a generated harness-home `settings.json` as part of an earlier generic home-template description, but the task explicitly says composed settings are left to launch. T3 creates no `settings.json`; launch composition remains the owner.
|
||||
@@ -190,7 +190,13 @@ beforeEach((ctx) => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!handle) return;
|
||||
// Cleanup only when the fixture actually installed rows. `handle` is set
|
||||
// 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;
|
||||
|
||||
// Delete in dependency order (FK constraints)
|
||||
|
||||
@@ -245,21 +245,9 @@ describe('EnrollmentService.createToken', () => {
|
||||
const after = Date.now();
|
||||
|
||||
const expiresMs = new Date(result.expiresAt).getTime();
|
||||
|
||||
// The property under test is CLAMPING: a 9999s request must come back as 900s.
|
||||
// The gap between clamped and unclamped is 9_099_000 ms, so the tolerance below
|
||||
// only has to exceed CI scheduling jitter — it does not need to be tight to keep
|
||||
// the assertion discriminating. A 5s allowance consumes 0.05% of that margin and
|
||||
// an unclamped result still misses by three orders of magnitude.
|
||||
//
|
||||
// It was 100ms and failed on a loaded agent at 900_106 — 6ms over (#1090). A
|
||||
// wall-clock budget sized to a fast machine is a flake, not a tighter test.
|
||||
const CI_JITTER_MS = 5_000;
|
||||
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + CI_JITTER_MS);
|
||||
// Should be at most 900s from now
|
||||
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + 100);
|
||||
expect(expiresMs - after).toBeGreaterThanOrEqual(0);
|
||||
// Explicitly pin the clamp itself, independent of any timing allowance:
|
||||
// unclamped (9999s) would exceed this by ~9_099_000 ms.
|
||||
expect(expiresMs - before).toBeLessThan(1_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactElement } from 'react';
|
||||
import { formatAge, type FreshnessLabel } from '@/lib/freshness/model';
|
||||
|
||||
/**
|
||||
* Rendering rules for non-current freshness states (RI-5-001).
|
||||
*
|
||||
* - `unavailable` renders an explicit failure panel — never an empty
|
||||
* healthy collection.
|
||||
* - `stale` may render last-known data, but only under a visible label
|
||||
* carrying source identity, snapshot version, and age.
|
||||
* - `partial` renders the verified parts plus an explicit list of what is
|
||||
* missing.
|
||||
*/
|
||||
|
||||
interface RetryableNoticeProps {
|
||||
readonly onRetry?: () => void;
|
||||
readonly retryLabel?: string;
|
||||
}
|
||||
|
||||
function RetryButton({ onRetry, retryLabel }: RetryableNoticeProps): ReactElement | null {
|
||||
if (!onRetry) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="mt-2 rounded-lg border border-surface-border px-3 py-1.5 text-xs transition-colors hover:border-gray-500"
|
||||
>
|
||||
{retryLabel ?? 'Retry'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export interface UnavailableDataNoticeProps extends RetryableNoticeProps {
|
||||
/** What is unavailable, e.g. "Tasks". */
|
||||
readonly title: string;
|
||||
/** Optional underlying failure detail (network message, invalidation reason). */
|
||||
readonly detail?: string | null;
|
||||
}
|
||||
|
||||
/** Explicit `unavailable` state. Never renders as an empty healthy collection. */
|
||||
export function UnavailableDataNotice({
|
||||
title,
|
||||
detail,
|
||||
onRetry,
|
||||
retryLabel,
|
||||
}: UnavailableDataNoticeProps): ReactElement {
|
||||
return (
|
||||
<div role="alert" className="rounded-lg border border-error/40 px-4 py-3 text-sm">
|
||||
<p className="font-medium text-text-primary">{title} are unavailable</p>
|
||||
<p className="mt-1 text-text-muted">
|
||||
This is not an empty result — the data could not be verified from the gateway.
|
||||
{detail ? ` ${detail}` : ''}
|
||||
</p>
|
||||
<RetryButton onRetry={onRetry} retryLabel={retryLabel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface StaleDataNoticeProps extends RetryableNoticeProps {
|
||||
/** Provenance of the last-known snapshot being displayed. */
|
||||
readonly label: FreshnessLabel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Situational-awareness banner for `stale` data: last-known data may render,
|
||||
* but visibly labeled with source identity, snapshot version, and age.
|
||||
*/
|
||||
export function StaleDataNotice({
|
||||
label,
|
||||
onRetry,
|
||||
retryLabel,
|
||||
}: StaleDataNoticeProps): ReactElement {
|
||||
return (
|
||||
<div role="status" className="rounded-lg border border-warning/40 px-4 py-3 text-sm">
|
||||
<p className="font-medium text-warning">Showing last-known data — it may be out of date</p>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
Source {label.source} · snapshot v{label.version} · fetched{' '}
|
||||
{formatAge(label.fetchedAt, Date.now())}. Verdicts derived from this data are unknown and
|
||||
changes are disabled until it is revalidated.
|
||||
</p>
|
||||
<RetryButton onRetry={onRetry} retryLabel={retryLabel ?? 'Revalidate'} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PartialDataNoticeProps extends RetryableNoticeProps {
|
||||
/** Display names of the sections whose collections are unavailable. */
|
||||
readonly missing: readonly string[];
|
||||
}
|
||||
|
||||
/** `partial` surface banner: verified parts render, missing parts are explicit. */
|
||||
export function PartialDataNotice({
|
||||
missing,
|
||||
onRetry,
|
||||
retryLabel,
|
||||
}: PartialDataNoticeProps): ReactElement {
|
||||
return (
|
||||
<div role="status" className="rounded-lg border border-warning/40 px-4 py-3 text-sm">
|
||||
<p className="font-medium text-warning">Some data could not be loaded</p>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
{missing.join(', ')} {missing.length === 1 ? 'is' : 'are'} unavailable — sections below show
|
||||
an explicit unavailable state instead of an empty list. Derived verdicts remain unknown
|
||||
until every collection is revalidated.
|
||||
</p>
|
||||
<RetryButton onRetry={onRetry} retryLabel={retryLabel ?? 'Revalidate'} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Task } from '@/lib/types';
|
||||
import {
|
||||
acceptSnapshot,
|
||||
assertMutable,
|
||||
canMutate,
|
||||
combineFreshness,
|
||||
computeDigest,
|
||||
computeFreshness,
|
||||
DEFAULT_FRESHNESS_POLICY,
|
||||
formatAge,
|
||||
type FreshSnapshot,
|
||||
invalidationReasonLabels,
|
||||
StaleMutationError,
|
||||
UNKNOWN_VERDICT,
|
||||
verdictValue,
|
||||
} from './model';
|
||||
import { validateProjectCollection, validateTaskCollection } from './validators';
|
||||
|
||||
const NOW = 1_800_000_000_000;
|
||||
|
||||
const policy = { ...DEFAULT_FRESHNESS_POLICY, staleAfterMs: 60_000 };
|
||||
|
||||
const taskPayload: Task[] = [
|
||||
{
|
||||
id: 'task-1',
|
||||
title: 'T1',
|
||||
description: null,
|
||||
status: 'not-started',
|
||||
priority: 'high',
|
||||
projectId: 'project-1',
|
||||
missionId: null,
|
||||
assignee: null,
|
||||
tags: null,
|
||||
dueDate: null,
|
||||
metadata: null,
|
||||
createdAt: '2026-08-01T00:00:00.000Z',
|
||||
updatedAt: '2026-08-01T00:00:00.000Z',
|
||||
},
|
||||
];
|
||||
|
||||
function acceptedTaskSnapshot(
|
||||
overrides: Partial<FreshSnapshot<typeof taskPayload>> = {},
|
||||
): FreshSnapshot<typeof taskPayload> {
|
||||
const result = acceptSnapshot({
|
||||
value: taskPayload,
|
||||
validate: validateTaskCollection,
|
||||
previous: null,
|
||||
policy,
|
||||
source: 'gateway:/api/tasks',
|
||||
now: NOW,
|
||||
});
|
||||
if (result.outcome !== 'accepted') {
|
||||
throw new Error(`fixture setup failed: ${result.reason}`);
|
||||
}
|
||||
return { ...result.snapshot, ...overrides };
|
||||
}
|
||||
|
||||
describe('computeFreshness', () => {
|
||||
it('treats a missing snapshot as unavailable, never as an empty healthy collection', () => {
|
||||
expect(computeFreshness({ snapshot: null, policy, now: NOW })).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('returns current for a fresh verified snapshot regardless of data emptiness', () => {
|
||||
const empty = acceptSnapshot({
|
||||
value: [],
|
||||
validate: validateTaskCollection,
|
||||
previous: null,
|
||||
policy,
|
||||
source: 'gateway:/api/tasks',
|
||||
now: NOW,
|
||||
});
|
||||
if (empty.outcome !== 'accepted') throw new Error('expected acceptance');
|
||||
expect(computeFreshness({ snapshot: empty.snapshot, policy, now: NOW })).toBe('current');
|
||||
});
|
||||
|
||||
it('degrades to stale once the snapshot ages past staleAfterMs', () => {
|
||||
const snapshot = acceptedTaskSnapshot();
|
||||
expect(computeFreshness({ snapshot, policy, now: NOW + 60_001 })).toBe('stale');
|
||||
expect(computeFreshness({ snapshot, policy, now: NOW + 59_999 })).toBe('current');
|
||||
});
|
||||
|
||||
it('degrades to stale when the latest revalidation failed', () => {
|
||||
const snapshot = acceptedTaskSnapshot();
|
||||
expect(computeFreshness({ snapshot, policy, now: NOW, degraded: true })).toBe('stale');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mutation guard', () => {
|
||||
it('permits mutations only on current data', () => {
|
||||
expect(canMutate('current')).toBe(true);
|
||||
for (const state of ['stale', 'partial', 'unknown', 'unavailable'] as const) {
|
||||
expect(canMutate(state)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses mutations on non-current data via assertMutable', () => {
|
||||
expect(() => assertMutable('current')).not.toThrow();
|
||||
for (const state of ['stale', 'partial', 'unknown', 'unavailable'] as const) {
|
||||
let thrown: unknown;
|
||||
try {
|
||||
assertMutable(state);
|
||||
} catch (caught) {
|
||||
thrown = caught;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(StaleMutationError);
|
||||
expect(thrown).toBeInstanceOf(Error);
|
||||
if (thrown instanceof StaleMutationError) {
|
||||
expect(thrown.name).toBe('StaleMutationError');
|
||||
expect(thrown.freshness).toBe(state);
|
||||
expect(thrown.message).toContain(state);
|
||||
expect(thrown.message).toContain('revalidat');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('acceptSnapshot', () => {
|
||||
it('accepts a valid payload with provenance', () => {
|
||||
const result = acceptSnapshot({
|
||||
value: taskPayload,
|
||||
validate: validateTaskCollection,
|
||||
previous: null,
|
||||
policy,
|
||||
source: 'gateway:/api/tasks',
|
||||
now: NOW,
|
||||
});
|
||||
expect(result.outcome).toBe('accepted');
|
||||
if (result.outcome !== 'accepted') return;
|
||||
expect(result.snapshot.source).toBe('gateway:/api/tasks');
|
||||
expect(result.snapshot.version).toBe(1);
|
||||
expect(result.snapshot.fetchedAt).toBe(NOW);
|
||||
expect(result.snapshot.data).toEqual(taskPayload);
|
||||
});
|
||||
|
||||
it('invalidates a schema-mismatched payload instead of rendering it', () => {
|
||||
const result = acceptSnapshot({
|
||||
value: { not: 'an array' },
|
||||
validate: validateTaskCollection,
|
||||
previous: acceptedTaskSnapshot(),
|
||||
policy,
|
||||
source: 'gateway:/api/tasks',
|
||||
now: NOW,
|
||||
});
|
||||
expect(result).toEqual({ outcome: 'invalidated', reason: 'schema-mismatch' });
|
||||
expect(invalidationReasonLabels['schema-mismatch']).toContain('schema');
|
||||
});
|
||||
|
||||
it('invalidates cross-workspace payloads', () => {
|
||||
const userOne = acceptSnapshot({
|
||||
value: [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'P1',
|
||||
description: null,
|
||||
status: 'active',
|
||||
userId: 'user-1',
|
||||
metadata: null,
|
||||
createdAt: '2026-08-01T00:00:00.000Z',
|
||||
updatedAt: '2026-08-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
validate: validateProjectCollection,
|
||||
previous: null,
|
||||
policy,
|
||||
source: 'gateway:/api/projects',
|
||||
now: NOW,
|
||||
});
|
||||
if (userOne.outcome !== 'accepted') throw new Error('expected acceptance');
|
||||
|
||||
const switched = acceptSnapshot({
|
||||
value: [
|
||||
{
|
||||
id: 'p9',
|
||||
name: 'P9',
|
||||
description: null,
|
||||
status: 'active',
|
||||
userId: 'user-2',
|
||||
metadata: null,
|
||||
createdAt: '2026-08-01T00:00:00.000Z',
|
||||
updatedAt: '2026-08-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
validate: validateProjectCollection,
|
||||
previous: userOne.snapshot,
|
||||
policy,
|
||||
source: 'gateway:/api/projects',
|
||||
now: NOW,
|
||||
});
|
||||
expect(switched).toEqual({ outcome: 'invalidated', reason: 'cross-workspace' });
|
||||
});
|
||||
|
||||
it('keeps the previous workspace for collections with no intrinsic identity', () => {
|
||||
const userOne = acceptSnapshot({
|
||||
value: [
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'P1',
|
||||
description: null,
|
||||
status: 'active',
|
||||
userId: 'user-1',
|
||||
metadata: null,
|
||||
createdAt: '2026-08-01T00:00:00.000Z',
|
||||
updatedAt: '2026-08-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
validate: validateProjectCollection,
|
||||
previous: null,
|
||||
policy,
|
||||
source: 'gateway:/api/projects',
|
||||
now: NOW,
|
||||
});
|
||||
if (userOne.outcome !== 'accepted') throw new Error('expected acceptance');
|
||||
|
||||
// Empty list after the user deleted every project: no identity to check,
|
||||
// so the verified scope is retained and the empty state stays healthy.
|
||||
const emptied = acceptSnapshot({
|
||||
value: [],
|
||||
validate: validateProjectCollection,
|
||||
previous: userOne.snapshot,
|
||||
policy,
|
||||
source: 'gateway:/api/projects',
|
||||
now: NOW,
|
||||
});
|
||||
expect(emptied.outcome).toBe('accepted');
|
||||
if (emptied.outcome === 'accepted') {
|
||||
expect(emptied.snapshot.data).toEqual([]);
|
||||
expect(emptied.snapshot.workspace).toBe('user-1');
|
||||
}
|
||||
});
|
||||
|
||||
it('invalidates version regressions', () => {
|
||||
const previous = acceptedTaskSnapshot({ version: 7 });
|
||||
const regressed = acceptSnapshot({
|
||||
value: taskPayload,
|
||||
validate: validateTaskCollection,
|
||||
previous,
|
||||
policy,
|
||||
source: 'gateway:/api/tasks',
|
||||
now: NOW,
|
||||
incomingVersion: 3,
|
||||
});
|
||||
expect(regressed).toEqual({ outcome: 'invalidated', reason: 'version-regression' });
|
||||
|
||||
const newerSchema = acceptedTaskSnapshot({ schemaVersion: 4 });
|
||||
const downgradedClient = acceptSnapshot({
|
||||
value: taskPayload,
|
||||
validate: validateTaskCollection,
|
||||
previous: newerSchema,
|
||||
policy: { ...policy, schemaVersion: 2 },
|
||||
source: 'gateway:/api/tasks',
|
||||
now: NOW,
|
||||
});
|
||||
expect(downgradedClient).toEqual({ outcome: 'invalidated', reason: 'version-regression' });
|
||||
});
|
||||
|
||||
it('increments the version monotonically across accepted snapshots', () => {
|
||||
const first = acceptedTaskSnapshot();
|
||||
const second = acceptSnapshot({
|
||||
value: taskPayload,
|
||||
validate: validateTaskCollection,
|
||||
previous: first,
|
||||
policy,
|
||||
source: 'gateway:/api/tasks',
|
||||
now: NOW,
|
||||
});
|
||||
expect(second.outcome).toBe('accepted');
|
||||
if (second.outcome === 'accepted') {
|
||||
expect(second.snapshot.version).toBe(first.version + 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('combineFreshness', () => {
|
||||
it('gates the surface on the primary collection', () => {
|
||||
expect(combineFreshness('unavailable', ['current'])).toBe('unavailable');
|
||||
expect(combineFreshness('unknown', ['current'])).toBe('unknown');
|
||||
expect(combineFreshness('current', [])).toBe('current');
|
||||
});
|
||||
|
||||
it('degrades to partial when a secondary is unavailable', () => {
|
||||
expect(combineFreshness('current', ['current', 'unavailable'])).toBe('partial');
|
||||
});
|
||||
|
||||
it('degrades to unknown while a secondary is still loading', () => {
|
||||
expect(combineFreshness('current', ['unknown'])).toBe('unknown');
|
||||
});
|
||||
|
||||
it('degrades to stale when any collection is stale', () => {
|
||||
expect(combineFreshness('current', ['stale'])).toBe('stale');
|
||||
expect(combineFreshness('stale', ['current'])).toBe('stale');
|
||||
});
|
||||
|
||||
it('propagates partial secondaries', () => {
|
||||
expect(combineFreshness('current', ['partial'])).toBe('partial');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeDigest', () => {
|
||||
it('is stable across key order and changes with data', () => {
|
||||
const a = computeDigest({ x: 1, y: [1, 2] });
|
||||
const b = computeDigest({ y: [1, 2], x: 1 });
|
||||
expect(a).toBe(b);
|
||||
expect(computeDigest({ x: 1, y: [1, 3] })).not.toBe(a);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verdictValue', () => {
|
||||
it('returns the value only for verified inputs', () => {
|
||||
expect(verdictValue(true, '5')).toBe('5');
|
||||
expect(verdictValue(false, '5')).toBe(UNKNOWN_VERDICT);
|
||||
expect(verdictValue(false, '5')).not.toBe('5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAge', () => {
|
||||
it('labels age in human terms', () => {
|
||||
expect(formatAge(NOW, NOW)).toBe('just now');
|
||||
expect(formatAge(NOW, NOW + 15_000)).toBe('under a minute ago');
|
||||
expect(formatAge(NOW, NOW + 120_000)).toBe('2m ago');
|
||||
expect(formatAge(NOW, NOW + 3 * 3_600_000)).toBe('3h ago');
|
||||
expect(formatAge(NOW, NOW + 2 * 86_400_000)).toBe('2d ago');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Typed freshness model for gateway-fetched collections (RI-5-001).
|
||||
*
|
||||
* A failed or stale fetch must never be indistinguishable from an empty
|
||||
* healthy collection. Every fetched surface carries an explicit freshness
|
||||
* state, a verified snapshot identity (source, workspace, version, age), and
|
||||
* a mutation guard that refuses state-changing operations unless the data is
|
||||
* verified current.
|
||||
*/
|
||||
|
||||
/** Freshness states for fetched data. Never inferred from emptiness. */
|
||||
export type FreshnessState = 'current' | 'stale' | 'partial' | 'unknown' | 'unavailable';
|
||||
|
||||
/**
|
||||
* Reasons a snapshot is invalidated. An invalidated snapshot is treated as
|
||||
* unavailable and is never rendered as current.
|
||||
*/
|
||||
export type InvalidationReason =
|
||||
| 'cache-corruption'
|
||||
| 'cross-workspace'
|
||||
| 'schema-mismatch'
|
||||
| 'version-regression';
|
||||
|
||||
/** Human-readable labels for invalidation reasons (UI + error messages). */
|
||||
export const invalidationReasonLabels: Record<InvalidationReason, string> = {
|
||||
'cache-corruption': 'cached snapshot failed integrity checks',
|
||||
'cross-workspace': 'data belongs to a different workspace',
|
||||
'schema-mismatch': 'response did not match the expected schema',
|
||||
'version-regression': 'snapshot version regressed below the accepted version',
|
||||
};
|
||||
|
||||
/** A verified snapshot of fetched data with full provenance. */
|
||||
export interface FreshSnapshot<T> {
|
||||
readonly data: T;
|
||||
/** Source identity of the fetch, e.g. `gateway:/api/tasks`. */
|
||||
readonly source: string;
|
||||
/** Workspace scope the data belongs to. */
|
||||
readonly workspace: string;
|
||||
/** Monotonic snapshot sequence number for this surface. */
|
||||
readonly version: number;
|
||||
/** Schema version of the validator that accepted this snapshot. */
|
||||
readonly schemaVersion: number;
|
||||
/** Epoch ms at which the data was verified. */
|
||||
readonly fetchedAt: number;
|
||||
/** Integrity digest of `data`, used to detect cache corruption. */
|
||||
readonly digest: string;
|
||||
}
|
||||
|
||||
/** Provenance label rendered next to last-known data. */
|
||||
export interface FreshnessLabel {
|
||||
readonly source: string;
|
||||
readonly version: number;
|
||||
readonly fetchedAt: number;
|
||||
}
|
||||
|
||||
/** Policy governing freshness for a surface. */
|
||||
export interface FreshnessPolicy {
|
||||
/** Active workspace scope. Snapshots from other scopes are invalidated. */
|
||||
readonly workspace: string;
|
||||
/** Schema version of the current validator. */
|
||||
readonly schemaVersion: number;
|
||||
/** Age after which a verified snapshot degrades from current to stale. */
|
||||
readonly staleAfterMs: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_FRESHNESS_POLICY: FreshnessPolicy = {
|
||||
workspace: 'default',
|
||||
schemaVersion: 1,
|
||||
staleAfterMs: 60_000,
|
||||
};
|
||||
|
||||
/** Payload returned by a successful schema validation. */
|
||||
export interface FreshPayload<T> {
|
||||
readonly data: T;
|
||||
/**
|
||||
* Workspace identity extracted from the payload itself when the collection
|
||||
* carries one (e.g. a uniform `userId` on projects). `null` when the
|
||||
* collection has no intrinsic workspace identity.
|
||||
*/
|
||||
readonly workspace: string | null;
|
||||
}
|
||||
|
||||
/** Error thrown when a mutation is attempted on non-current data. */
|
||||
export class StaleMutationError extends Error {
|
||||
readonly freshness: FreshnessState;
|
||||
|
||||
constructor(freshness: FreshnessState) {
|
||||
super(`Refused mutation on ${freshness} data: revalidation is required before mutating.`);
|
||||
this.name = 'StaleMutationError';
|
||||
this.freshness = freshness;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable JSON digest used for snapshot integrity checks. */
|
||||
export function computeDigest(value: unknown): string {
|
||||
// FNV-1a 32-bit over the stable JSON serialization. This is an integrity
|
||||
// check against corruption, not a cryptographic guarantee.
|
||||
let hash = 0x811c9dc5;
|
||||
for (const byte of stableStringify(value)) {
|
||||
hash ^= byte.charCodeAt(0);
|
||||
hash = Math.imul(hash, 0x01000193) >>> 0;
|
||||
}
|
||||
return hash.toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
return serialize(value);
|
||||
}
|
||||
|
||||
function serialize(value: unknown): string {
|
||||
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
|
||||
if (Array.isArray(value)) return `[${value.map(serialize).join(',')}]`;
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, item]) => item !== undefined)
|
||||
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${serialize(item)}`);
|
||||
return `{${entries.join(',')}}`;
|
||||
}
|
||||
|
||||
export type AcceptSnapshotResult<T> =
|
||||
| { readonly outcome: 'accepted'; readonly snapshot: FreshSnapshot<T> }
|
||||
| { readonly outcome: 'invalidated'; readonly reason: InvalidationReason };
|
||||
|
||||
export interface AcceptSnapshotOptions<T> {
|
||||
/** Raw fetched value (untrusted JSON). */
|
||||
readonly value: unknown;
|
||||
/** Schema validator; returns `null` when the value does not match. */
|
||||
readonly validate: (value: unknown) => FreshPayload<T> | null;
|
||||
/** Previously accepted snapshot for this surface, if any. */
|
||||
readonly previous: FreshSnapshot<T> | null;
|
||||
readonly policy: FreshnessPolicy;
|
||||
readonly source: string;
|
||||
/**
|
||||
* Version carried by the incoming payload when the transport exposes one.
|
||||
* Must not regress below the accepted snapshot's version.
|
||||
*/
|
||||
readonly incomingVersion?: number;
|
||||
readonly now: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and accept a fetched value as a snapshot, or invalidate it.
|
||||
*
|
||||
* Invalidation rules (each treated as unavailable, never rendered current):
|
||||
* - schema mismatch: the payload fails validation
|
||||
* - cross-workspace: the payload's workspace differs from the verified one
|
||||
* - version regression: payload/schema version is below the accepted one
|
||||
*/
|
||||
export function acceptSnapshot<T>(options: AcceptSnapshotOptions<T>): AcceptSnapshotResult<T> {
|
||||
const payload = options.validate(options.value);
|
||||
if (payload === null) {
|
||||
return { outcome: 'invalidated', reason: 'schema-mismatch' };
|
||||
}
|
||||
|
||||
// Workspace identity: the payload's own scope wins; a collection with no
|
||||
// intrinsic identity (e.g. an empty list after every project was deleted)
|
||||
// keeps the previously verified scope rather than resetting to the policy
|
||||
// default, so a legitimately empty response is not mistaken for a scope
|
||||
// change.
|
||||
const workspace = payload.workspace ?? options.previous?.workspace ?? options.policy.workspace;
|
||||
if (options.previous !== null && options.previous.workspace !== workspace) {
|
||||
return { outcome: 'invalidated', reason: 'cross-workspace' };
|
||||
}
|
||||
if (options.previous !== null && options.policy.schemaVersion < options.previous.schemaVersion) {
|
||||
return { outcome: 'invalidated', reason: 'version-regression' };
|
||||
}
|
||||
if (
|
||||
options.incomingVersion !== undefined &&
|
||||
options.previous !== null &&
|
||||
options.incomingVersion < options.previous.version
|
||||
) {
|
||||
return { outcome: 'invalidated', reason: 'version-regression' };
|
||||
}
|
||||
|
||||
const snapshot: FreshSnapshot<T> = {
|
||||
data: payload.data,
|
||||
source: options.source,
|
||||
workspace,
|
||||
version: options.incomingVersion ?? (options.previous?.version ?? 0) + 1,
|
||||
schemaVersion: options.policy.schemaVersion,
|
||||
fetchedAt: options.now,
|
||||
digest: computeDigest(payload.data),
|
||||
};
|
||||
return { outcome: 'accepted', snapshot };
|
||||
}
|
||||
|
||||
export interface ComputeFreshnessOptions {
|
||||
readonly snapshot: FreshSnapshot<unknown> | null;
|
||||
readonly policy: FreshnessPolicy;
|
||||
readonly now: number;
|
||||
/**
|
||||
* True when the snapshot cannot be trusted as current regardless of age:
|
||||
* the latest revalidation failed, or the snapshot was restored from cache
|
||||
* and has not been verified by a fetch in this session.
|
||||
*/
|
||||
readonly degraded?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the freshness state of a snapshot. A missing snapshot is
|
||||
* `unavailable` (never "empty and healthy"); a degraded or aged snapshot is
|
||||
* `stale` (situational awareness only).
|
||||
*/
|
||||
export function computeFreshness(options: ComputeFreshnessOptions): FreshnessState {
|
||||
const { snapshot, policy, now, degraded = false } = options;
|
||||
if (snapshot === null) return 'unavailable';
|
||||
if (degraded) return 'stale';
|
||||
if (now - snapshot.fetchedAt > policy.staleAfterMs) return 'stale';
|
||||
return 'current';
|
||||
}
|
||||
|
||||
/** Only verified-current data may back a state-changing action. */
|
||||
export function canMutate(state: FreshnessState): boolean {
|
||||
return state === 'current';
|
||||
}
|
||||
|
||||
/** Defense in depth: reject the mutation call itself on non-current data. */
|
||||
export function assertMutable(state: FreshnessState): void {
|
||||
if (!canMutate(state)) {
|
||||
throw new StaleMutationError(state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine freshness across a multi-collection surface (primary + secondaries).
|
||||
* The primary collection gates the surface: unknown while it loads,
|
||||
* unavailable when it fails. Missing secondaries degrade the surface to
|
||||
* `partial`; aged collections degrade it to `stale`.
|
||||
*/
|
||||
export function combineFreshness(
|
||||
primary: FreshnessState,
|
||||
secondaries: readonly FreshnessState[],
|
||||
): FreshnessState {
|
||||
if (primary === 'unavailable') return 'unavailable';
|
||||
if (primary === 'unknown') return 'unknown';
|
||||
if (secondaries.includes('unavailable')) return 'partial';
|
||||
if (secondaries.includes('unknown')) return 'unknown';
|
||||
if (secondaries.includes('stale') || primary === 'stale') return 'stale';
|
||||
if (secondaries.includes('partial')) return 'partial';
|
||||
return 'current';
|
||||
}
|
||||
|
||||
/** Render-safe age label for snapshot provenance. */
|
||||
export function formatAge(fetchedAt: number, now: number): string {
|
||||
const ageMs = Math.max(0, now - fetchedAt);
|
||||
if (ageMs < 10_000) return 'just now';
|
||||
const minutes = Math.floor(ageMs / 60_000);
|
||||
if (minutes < 1) return 'under a minute ago';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
/** Derived verdict placeholder for non-current inputs — never a green value. */
|
||||
export const UNKNOWN_VERDICT = '?';
|
||||
|
||||
export function verdictValue(verified: boolean, value: string): string {
|
||||
return verified ? value : UNKNOWN_VERDICT;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { acceptSnapshot, DEFAULT_FRESHNESS_POLICY } from './model';
|
||||
import { clearSnapshotCache, readSnapshotCache, writeSnapshotCache } from './snapshot-cache';
|
||||
import { validateProjectCollection, validateTaskCollection } from './validators';
|
||||
import { projectFixtures, taskFixtures } from '@/spa/pages/page-fixtures';
|
||||
import type { Project, Task } from '@/lib/types';
|
||||
|
||||
const KEY = 'test:tasks';
|
||||
const NOW = 1_800_000_000_000;
|
||||
const policy = { ...DEFAULT_FRESHNESS_POLICY, staleAfterMs: 60_000 };
|
||||
|
||||
function storedTaskSnapshot() {
|
||||
const result = acceptSnapshot({
|
||||
value: taskFixtures,
|
||||
validate: validateTaskCollection,
|
||||
previous: null,
|
||||
policy,
|
||||
source: 'gateway:/api/tasks',
|
||||
now: NOW,
|
||||
});
|
||||
if (result.outcome !== 'accepted') throw new Error('fixture setup failed');
|
||||
return result.snapshot;
|
||||
}
|
||||
|
||||
function storedProjectSnapshot() {
|
||||
const result = acceptSnapshot({
|
||||
value: projectFixtures,
|
||||
validate: validateProjectCollection,
|
||||
previous: null,
|
||||
policy,
|
||||
source: 'gateway:/api/projects',
|
||||
now: NOW,
|
||||
});
|
||||
if (result.outcome !== 'accepted') throw new Error('fixture setup failed');
|
||||
return result.snapshot;
|
||||
}
|
||||
|
||||
function readTasks() {
|
||||
return readSnapshotCache({
|
||||
key: KEY,
|
||||
workspace: policy.workspace,
|
||||
policy,
|
||||
validate: validateTaskCollection,
|
||||
});
|
||||
}
|
||||
|
||||
/** Write an arbitrary value directly at the raw cache slot. */
|
||||
function writeRaw(key: string, value: unknown): void {
|
||||
sessionStorage.setItem(`mosaic:freshness:v1:${key}`, JSON.stringify(value));
|
||||
}
|
||||
|
||||
/** Parse and re-write the stored entry (for tampering with internals). */
|
||||
function tamperStored<T>(key: string, mutate: (stored: T) => void): void {
|
||||
const parsed = JSON.parse(sessionStorage.getItem(`mosaic:freshness:v1:${key}`) ?? '{}') as T;
|
||||
mutate(parsed);
|
||||
writeRaw(key, parsed);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
describe('readSnapshotCache', () => {
|
||||
it('misses when nothing is stored', () => {
|
||||
expect(readTasks()).toEqual({ outcome: 'miss' });
|
||||
});
|
||||
|
||||
it('hits for a well-formed entry and preserves provenance', () => {
|
||||
const snapshot = storedTaskSnapshot();
|
||||
writeSnapshotCache(KEY, snapshot);
|
||||
|
||||
const result = readTasks();
|
||||
expect(result.outcome).toBe('hit');
|
||||
if (result.outcome === 'hit') {
|
||||
expect(result.snapshot.data).toEqual(taskFixtures);
|
||||
expect(result.snapshot.source).toBe('gateway:/api/tasks');
|
||||
expect(result.snapshot.version).toBe(snapshot.version);
|
||||
expect(result.snapshot.fetchedAt).toBe(snapshot.fetchedAt);
|
||||
expect(result.snapshot.workspace).toBe(snapshot.workspace);
|
||||
}
|
||||
});
|
||||
|
||||
it('invalidates unparsable entries as cache corruption', () => {
|
||||
sessionStorage.setItem(`mosaic:freshness:v1:${KEY}`, '{not json');
|
||||
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cache-corruption' });
|
||||
});
|
||||
|
||||
it('invalidates structurally wrong entries as cache corruption', () => {
|
||||
const malformed: unknown[] = [
|
||||
'nested but not a snapshot',
|
||||
{ data: taskFixtures }, // missing provenance fields
|
||||
{
|
||||
data: taskFixtures,
|
||||
source: 1,
|
||||
workspace: 'w',
|
||||
version: 1,
|
||||
schemaVersion: 1,
|
||||
fetchedAt: 1,
|
||||
digest: 'x',
|
||||
},
|
||||
null,
|
||||
17,
|
||||
];
|
||||
for (const entry of malformed) {
|
||||
writeRaw(KEY, entry);
|
||||
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cache-corruption' });
|
||||
}
|
||||
});
|
||||
|
||||
it('invalidates digest mismatches as cache corruption (tampered data)', () => {
|
||||
writeSnapshotCache(KEY, storedTaskSnapshot());
|
||||
tamperStored<{ data: Task[] }>(KEY, (stored) => {
|
||||
stored.data = [...stored.data, { ...stored.data[0]!, id: 'injected-task' }];
|
||||
});
|
||||
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cache-corruption' });
|
||||
});
|
||||
|
||||
it('invalidates entries scoped to another workspace', () => {
|
||||
const snapshot = storedTaskSnapshot();
|
||||
writeSnapshotCache(KEY, { ...snapshot, workspace: 'someone-else' });
|
||||
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cross-workspace' });
|
||||
});
|
||||
|
||||
it('invalidates entries written by a newer schema as a version regression', () => {
|
||||
const snapshot = storedTaskSnapshot();
|
||||
writeSnapshotCache(KEY, { ...snapshot, schemaVersion: policy.schemaVersion + 1 });
|
||||
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'version-regression' });
|
||||
});
|
||||
|
||||
it('invalidates entries whose data no longer validates (schema mismatch)', () => {
|
||||
writeSnapshotCache(KEY, storedTaskSnapshot());
|
||||
tamperStored<{ data: unknown }>(KEY, (stored) => {
|
||||
stored.data = { malformed: true };
|
||||
});
|
||||
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'schema-mismatch' });
|
||||
});
|
||||
|
||||
it('never reports a corrupted raw entry as a hit (negative control)', () => {
|
||||
for (const raw of ['{oops', 'null', '"string"', '[]', '12']) {
|
||||
sessionStorage.setItem(`mosaic:freshness:v1:${KEY}`, raw);
|
||||
const result = readTasks();
|
||||
expect(result.outcome).not.toBe('hit');
|
||||
expect(result.outcome).toBe('invalidated');
|
||||
}
|
||||
});
|
||||
|
||||
it('scopes project collections by their workspace identity', () => {
|
||||
const snapshot = storedProjectSnapshot();
|
||||
writeSnapshotCache('test:projects', snapshot);
|
||||
|
||||
const sameScope = readSnapshotCache({
|
||||
key: 'test:projects',
|
||||
workspace: 'user-1',
|
||||
policy,
|
||||
validate: validateProjectCollection,
|
||||
});
|
||||
expect(sameScope.outcome).toBe('hit');
|
||||
|
||||
const foreignScope = readSnapshotCache({
|
||||
key: 'test:projects',
|
||||
workspace: 'user-2',
|
||||
policy,
|
||||
validate: validateProjectCollection,
|
||||
});
|
||||
expect(foreignScope).toEqual({ outcome: 'invalidated', reason: 'cross-workspace' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeSnapshotCache round-trip', () => {
|
||||
it('round-trips an accepted project snapshot', () => {
|
||||
const snapshot = storedProjectSnapshot();
|
||||
writeSnapshotCache('test:projects', snapshot);
|
||||
const result = readSnapshotCache({
|
||||
key: 'test:projects',
|
||||
workspace: snapshot.workspace,
|
||||
policy,
|
||||
validate: validateProjectCollection,
|
||||
});
|
||||
expect(result.outcome).toBe('hit');
|
||||
if (result.outcome === 'hit') {
|
||||
expect(result.snapshot.data).toEqual(projectFixtures as Project[]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearSnapshotCache', () => {
|
||||
it('drops the entry so the next read misses', () => {
|
||||
writeSnapshotCache(KEY, storedTaskSnapshot());
|
||||
expect(readTasks().outcome).toBe('hit');
|
||||
clearSnapshotCache(KEY);
|
||||
expect(readTasks()).toEqual({ outcome: 'miss' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
computeDigest,
|
||||
type FreshPayload,
|
||||
type FreshSnapshot,
|
||||
type FreshnessPolicy,
|
||||
type InvalidationReason,
|
||||
} from './model';
|
||||
|
||||
/**
|
||||
* Session-scoped last-known snapshot cache (RI-5-001).
|
||||
*
|
||||
* Restored snapshots are situational awareness only: they surface as `stale`
|
||||
* until a fetch re-verifies them. A cache entry that is corrupted, belongs to
|
||||
* another workspace, was written by a newer schema, or no longer validates is
|
||||
* invalidated (treated as unavailable, never rendered as current).
|
||||
*/
|
||||
|
||||
const CACHE_PREFIX = 'mosaic:freshness:v1';
|
||||
|
||||
interface StoredSnapshot {
|
||||
data: unknown;
|
||||
source: string;
|
||||
workspace: string;
|
||||
version: number;
|
||||
schemaVersion: number;
|
||||
fetchedAt: number;
|
||||
digest: string;
|
||||
}
|
||||
|
||||
export type SnapshotCacheRead<T> =
|
||||
| { readonly outcome: 'hit'; readonly snapshot: FreshSnapshot<T> }
|
||||
| { readonly outcome: 'miss' }
|
||||
| { readonly outcome: 'invalidated'; readonly reason: InvalidationReason };
|
||||
|
||||
export interface ReadSnapshotCacheOptions<T> {
|
||||
readonly key: string;
|
||||
readonly workspace: string;
|
||||
readonly policy: FreshnessPolicy;
|
||||
readonly validate: (value: unknown) => FreshPayload<T> | null;
|
||||
}
|
||||
|
||||
function cacheKey(key: string): string {
|
||||
return `${CACHE_PREFIX}:${key}`;
|
||||
}
|
||||
|
||||
function isStoredSnapshot(value: unknown): value is StoredSnapshot {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof candidate['data'] === 'object' &&
|
||||
candidate['data'] !== null &&
|
||||
typeof candidate['source'] === 'string' &&
|
||||
typeof candidate['workspace'] === 'string' &&
|
||||
typeof candidate['version'] === 'number' &&
|
||||
typeof candidate['schemaVersion'] === 'number' &&
|
||||
typeof candidate['fetchedAt'] === 'number' &&
|
||||
typeof candidate['digest'] === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function getStorage(): Storage | null {
|
||||
try {
|
||||
return globalThis.sessionStorage ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a cached snapshot under the active workspace scope. Every failure
|
||||
* mode maps to an explicit invalidation reason or a miss — never to data
|
||||
* that renders as current.
|
||||
*/
|
||||
export function readSnapshotCache<T>(options: ReadSnapshotCacheOptions<T>): SnapshotCacheRead<T> {
|
||||
const storage = getStorage();
|
||||
if (storage === null) return { outcome: 'miss' };
|
||||
|
||||
let raw: string | null;
|
||||
try {
|
||||
raw = storage.getItem(cacheKey(options.key));
|
||||
} catch {
|
||||
return { outcome: 'miss' };
|
||||
}
|
||||
if (raw === null) return { outcome: 'miss' };
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return { outcome: 'invalidated', reason: 'cache-corruption' };
|
||||
}
|
||||
if (!isStoredSnapshot(parsed)) {
|
||||
return { outcome: 'invalidated', reason: 'cache-corruption' };
|
||||
}
|
||||
if (parsed.workspace !== options.workspace) {
|
||||
return { outcome: 'invalidated', reason: 'cross-workspace' };
|
||||
}
|
||||
if (parsed.schemaVersion > options.policy.schemaVersion) {
|
||||
// Written by a newer build than the running client: version regression.
|
||||
return { outcome: 'invalidated', reason: 'version-regression' };
|
||||
}
|
||||
|
||||
const payload = options.validate(parsed.data);
|
||||
if (payload === null) {
|
||||
return { outcome: 'invalidated', reason: 'schema-mismatch' };
|
||||
}
|
||||
if (computeDigest(payload.data) !== parsed.digest) {
|
||||
return { outcome: 'invalidated', reason: 'cache-corruption' };
|
||||
}
|
||||
|
||||
return {
|
||||
outcome: 'hit',
|
||||
snapshot: {
|
||||
data: payload.data,
|
||||
source: parsed.source,
|
||||
workspace: parsed.workspace,
|
||||
version: parsed.version,
|
||||
schemaVersion: parsed.schemaVersion,
|
||||
fetchedAt: parsed.fetchedAt,
|
||||
digest: parsed.digest,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Persist a verified snapshot. Failures are non-fatal (cache is best-effort). */
|
||||
export function writeSnapshotCache<T>(key: string, snapshot: FreshSnapshot<T>): void {
|
||||
const storage = getStorage();
|
||||
if (storage === null) return;
|
||||
const stored: StoredSnapshot = {
|
||||
data: snapshot.data,
|
||||
source: snapshot.source,
|
||||
workspace: snapshot.workspace,
|
||||
version: snapshot.version,
|
||||
schemaVersion: snapshot.schemaVersion,
|
||||
fetchedAt: snapshot.fetchedAt,
|
||||
digest: snapshot.digest,
|
||||
};
|
||||
try {
|
||||
storage.setItem(cacheKey(key), JSON.stringify(stored));
|
||||
} catch {
|
||||
// Quota or serialization failures simply skip caching.
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop a cached snapshot (used when a surface invalidates its cache entry). */
|
||||
export function clearSnapshotCache(key: string): void {
|
||||
const storage = getStorage();
|
||||
if (storage === null) return;
|
||||
try {
|
||||
storage.removeItem(cacheKey(key));
|
||||
} catch {
|
||||
// Ignorable: a wedged storage entry is detected as corruption on read.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Task } from '@/lib/types';
|
||||
import { acceptSnapshot, StaleMutationError, DEFAULT_FRESHNESS_POLICY } from './model';
|
||||
import type { FreshnessFailure } from './use-fresh-collection';
|
||||
import {
|
||||
describeFailure,
|
||||
useFreshCollection,
|
||||
type FreshCollection,
|
||||
type UseFreshCollectionOptions,
|
||||
} from './use-fresh-collection';
|
||||
import { validateProjectCollection, validateTaskCollection } from './validators';
|
||||
import { projectFixtures, taskFixtures } from '@/spa/pages/page-fixtures';
|
||||
|
||||
/**
|
||||
* Failure-matrix coverage for the freshness seam (RI-5-001): network failure,
|
||||
* auth failure, malformed response, cache corruption, stale age, schema
|
||||
* mismatch, cross-workspace, recovery, and stale-action rejection — with
|
||||
* negative controls proving no case yields current data or an enabled
|
||||
* mutation.
|
||||
*/
|
||||
|
||||
const NOW = 1_800_000_000_000;
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement;
|
||||
let latest: FreshCollection<Task[]> | null = null;
|
||||
|
||||
function Probe({
|
||||
options,
|
||||
}: {
|
||||
options: UseFreshCollectionOptions<Task[]>;
|
||||
}): React.ReactElement | null {
|
||||
latest = useFreshCollection<Task[]>(options);
|
||||
return null;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root?.unmount();
|
||||
});
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
latest = null;
|
||||
sessionStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function renderCollection(
|
||||
options: UseFreshCollectionOptions<Task[]>,
|
||||
): Promise<FreshCollection<Task[]>> {
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(<Probe options={options} />);
|
||||
});
|
||||
if (latest === null) throw new Error('hook did not run');
|
||||
return latest;
|
||||
}
|
||||
|
||||
function taskOptions(
|
||||
overrides: Partial<UseFreshCollectionOptions<Task[]>> = {},
|
||||
): UseFreshCollectionOptions<Task[]> {
|
||||
return {
|
||||
source: 'gateway:/api/tasks',
|
||||
fetcher: () => Promise.resolve(taskFixtures),
|
||||
validate: validateTaskCollection,
|
||||
cacheKey: 'tasks',
|
||||
clock: () => NOW,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function authError(statusCode: number): Error & { statusCode: number } {
|
||||
return Object.assign(new Error(`Request failed with ${statusCode}`), { statusCode });
|
||||
}
|
||||
|
||||
function seedCache(key: string): number {
|
||||
const result = acceptSnapshot({
|
||||
value: taskFixtures,
|
||||
validate: validateTaskCollection,
|
||||
previous: null,
|
||||
policy: DEFAULT_FRESHNESS_POLICY,
|
||||
source: 'gateway:/api/tasks',
|
||||
now: NOW,
|
||||
});
|
||||
if (result.outcome !== 'accepted') throw new Error('fixture setup failed');
|
||||
sessionStorage.setItem(`mosaic:freshness:v1:${key}`, JSON.stringify({ ...result.snapshot }));
|
||||
return result.snapshot.version;
|
||||
}
|
||||
|
||||
describe('useFreshCollection failure matrix', () => {
|
||||
it('is unknown (not empty) while the first validation is in flight', async () => {
|
||||
const deferred = createDeferred<Task[]>();
|
||||
const collection = await renderCollection(taskOptions({ fetcher: () => deferred.promise }));
|
||||
|
||||
expect(collection.freshness).toBe('unknown');
|
||||
expect(collection.validating).toBe(true);
|
||||
expect(collection.data).toBeNull();
|
||||
expect(collection.canMutate).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
deferred.resolve(taskFixtures);
|
||||
await deferred.promise;
|
||||
});
|
||||
});
|
||||
|
||||
it('becomes current with provenance after a verified fetch', async () => {
|
||||
const collection = await renderCollection(taskOptions());
|
||||
|
||||
expect(collection.freshness).toBe('current');
|
||||
expect(collection.data).toEqual(taskFixtures);
|
||||
expect(collection.snapshot?.source).toBe('gateway:/api/tasks');
|
||||
expect(collection.snapshot?.version).toBe(1);
|
||||
expect(collection.failure).toBeNull();
|
||||
expect(collection.canMutate).toBe(true);
|
||||
// Verified snapshot is persisted for last-known restore.
|
||||
expect(sessionStorage.getItem('mosaic:freshness:v1:tasks')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('treats a network failure as unavailable — never an empty healthy collection', async () => {
|
||||
const collection = await renderCollection(
|
||||
taskOptions({ fetcher: () => Promise.reject(new Error('network down')) }),
|
||||
);
|
||||
|
||||
expect(collection.freshness).toBe('unavailable');
|
||||
expect(collection.data).toBeNull();
|
||||
expect(collection.failure).toEqual({ kind: 'fetch', message: 'network down' });
|
||||
expect(collection.canMutate).toBe(false);
|
||||
expect(describeFailure(collection.failure)).toBe('network down');
|
||||
});
|
||||
|
||||
it('treats an auth failure as unavailable and drops the last-known snapshot', async () => {
|
||||
let call = 0;
|
||||
const collection = await renderCollection(
|
||||
taskOptions({
|
||||
fetcher: () => {
|
||||
call += 1;
|
||||
return call === 1 ? Promise.resolve(taskFixtures) : Promise.reject(authError(401));
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(collection.freshness).toBe('current');
|
||||
|
||||
await act(async () => {
|
||||
await collection.revalidate();
|
||||
});
|
||||
|
||||
expect(latest?.freshness).toBe('unavailable');
|
||||
expect(latest?.data).toBeNull();
|
||||
expect(latest?.failure?.kind).toBe('fetch');
|
||||
// The previous user's data must not linger in the session cache.
|
||||
expect(sessionStorage.getItem('mosaic:freshness:v1:tasks')).toBeNull();
|
||||
});
|
||||
|
||||
it('invalidates a malformed response as a schema mismatch', async () => {
|
||||
const collection = await renderCollection(
|
||||
taskOptions({ fetcher: () => Promise.resolve({ malformed: true }) }),
|
||||
);
|
||||
|
||||
expect(collection.freshness).toBe('unavailable');
|
||||
expect(collection.data).toBeNull();
|
||||
expect(collection.failure).toEqual({ kind: 'invalidated', reason: 'schema-mismatch' });
|
||||
expect(collection.canMutate).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the previous snapshot as labeled stale when a later payload mismatches', async () => {
|
||||
let call = 0;
|
||||
const collection = await renderCollection(
|
||||
taskOptions({
|
||||
fetcher: () => {
|
||||
call += 1;
|
||||
return call === 1 ? Promise.resolve(taskFixtures) : Promise.resolve('garbage');
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(collection.freshness).toBe('current');
|
||||
|
||||
await act(async () => {
|
||||
await collection.revalidate();
|
||||
});
|
||||
|
||||
expect(latest?.freshness).toBe('stale');
|
||||
expect(latest?.data).toEqual(taskFixtures);
|
||||
expect(latest?.failure).toEqual({ kind: 'invalidated', reason: 'schema-mismatch' });
|
||||
expect(latest?.canMutate).toBe(false);
|
||||
});
|
||||
|
||||
it('drops the snapshot when the workspace changes under it (cross-workspace)', async () => {
|
||||
let call = 0;
|
||||
const collection = await renderCollection(
|
||||
taskOptions({
|
||||
fetcher: () => {
|
||||
call += 1;
|
||||
return Promise.resolve(
|
||||
call === 1 ? projectFixtures : [{ ...projectFixtures[0], userId: 'user-2' }],
|
||||
);
|
||||
},
|
||||
validate: validateProjectCollection as unknown as (value: unknown) => {
|
||||
data: Task[];
|
||||
workspace: string | null;
|
||||
},
|
||||
source: 'gateway:/api/projects',
|
||||
}),
|
||||
);
|
||||
expect(collection.freshness).toBe('current');
|
||||
|
||||
await act(async () => {
|
||||
await collection.revalidate();
|
||||
});
|
||||
|
||||
expect(latest?.freshness).toBe('unavailable');
|
||||
expect(latest?.data).toBeNull();
|
||||
expect(latest?.failure).toEqual({ kind: 'invalidated', reason: 'cross-workspace' });
|
||||
});
|
||||
|
||||
it('ages from current to stale and refuses mutations on stale data', async () => {
|
||||
let fakeNow = NOW;
|
||||
const collection = await renderCollection(
|
||||
taskOptions({
|
||||
clock: () => fakeNow,
|
||||
policy: { staleAfterMs: 40 },
|
||||
tickMs: 10,
|
||||
}),
|
||||
);
|
||||
expect(collection.freshness).toBe('current');
|
||||
|
||||
// Age the snapshot past the policy and let the tick recompute.
|
||||
fakeNow = NOW + 60;
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
});
|
||||
|
||||
expect(latest?.freshness).toBe('stale');
|
||||
expect(latest?.data).toEqual(taskFixtures);
|
||||
expect(latest?.canMutate).toBe(false);
|
||||
|
||||
const operation = vi.fn(async () => 'result');
|
||||
await expect(latest?.mutate(operation)).rejects.toBeInstanceOf(StaleMutationError);
|
||||
expect(operation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('recovers to current after a successful revalidation', async () => {
|
||||
let call = 0;
|
||||
const collection = await renderCollection(
|
||||
taskOptions({
|
||||
fetcher: () => {
|
||||
call += 1;
|
||||
return call === 1
|
||||
? Promise.reject(new Error('first attempt failed'))
|
||||
: Promise.resolve(taskFixtures);
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(collection.freshness).toBe('unavailable');
|
||||
|
||||
await act(async () => {
|
||||
await collection.revalidate();
|
||||
});
|
||||
|
||||
expect(latest?.freshness).toBe('current');
|
||||
expect(latest?.failure).toBeNull();
|
||||
|
||||
const operation = vi.fn(async (data: Task[]) => data.length);
|
||||
await expect(latest?.mutate(operation)).resolves.toBe(taskFixtures.length);
|
||||
expect(operation).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('restores a cached snapshot as unverified stale data, then verifies it', async () => {
|
||||
const seededVersion = seedCache('tasks');
|
||||
const deferred = createDeferred<Task[]>();
|
||||
const collection = await renderCollection(taskOptions({ fetcher: () => deferred.promise }));
|
||||
|
||||
// Restored data is situational awareness only: labeled stale, never
|
||||
// current, and mutations are refused before verification.
|
||||
expect(collection.freshness).toBe('stale');
|
||||
expect(collection.data).toEqual(taskFixtures);
|
||||
expect(collection.canMutate).toBe(false);
|
||||
await expect(collection.mutate(vi.fn())).rejects.toBeInstanceOf(StaleMutationError);
|
||||
|
||||
await act(async () => {
|
||||
deferred.resolve(taskFixtures);
|
||||
await deferred.promise;
|
||||
});
|
||||
|
||||
expect(latest?.freshness).toBe('current');
|
||||
expect(latest?.snapshot?.version).toBe(seededVersion + 1);
|
||||
});
|
||||
|
||||
it('never promotes corrupted cache data to current (cache corruption)', async () => {
|
||||
sessionStorage.setItem('mosaic:freshness:v1:tasks', '{"data":');
|
||||
const collection = await renderCollection(
|
||||
taskOptions({ fetcher: () => Promise.reject(new Error('still down')) }),
|
||||
);
|
||||
|
||||
expect(collection.freshness).toBe('unavailable');
|
||||
expect(collection.data).toBeNull();
|
||||
expect(collection.canMutate).toBe(false);
|
||||
// The corrupted entry is dropped so it cannot come back.
|
||||
expect(sessionStorage.getItem('mosaic:freshness:v1:tasks')).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses mutations while unknown or unavailable — the call itself, not just the button', async () => {
|
||||
const deferred = createDeferred<Task[]>();
|
||||
const unknown = await renderCollection(taskOptions({ fetcher: () => deferred.promise }));
|
||||
const operation = vi.fn(async () => 'result');
|
||||
await expect(unknown.mutate(operation)).rejects.toBeInstanceOf(StaleMutationError);
|
||||
expect(operation).not.toHaveBeenCalled();
|
||||
await act(async () => {
|
||||
deferred.reject(new Error('failed'));
|
||||
await deferred.promise.catch(() => undefined);
|
||||
});
|
||||
|
||||
const unavailable = latest!;
|
||||
await expect(unavailable.mutate(operation)).rejects.toBeInstanceOf(StaleMutationError);
|
||||
expect(operation).not.toHaveBeenCalled();
|
||||
expect(unavailable.canMutate).toBe(false);
|
||||
});
|
||||
|
||||
it('degrades to stale with last-known data when a revalidation fails after success', async () => {
|
||||
let call = 0;
|
||||
const collection = await renderCollection(
|
||||
taskOptions({
|
||||
fetcher: () => {
|
||||
call += 1;
|
||||
return call === 1
|
||||
? Promise.resolve(taskFixtures)
|
||||
: Promise.reject(new Error('connection lost'));
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(collection.freshness).toBe('current');
|
||||
|
||||
await act(async () => {
|
||||
await collection.revalidate();
|
||||
});
|
||||
|
||||
expect(latest?.freshness).toBe('stale');
|
||||
expect(latest?.data).toEqual(taskFixtures);
|
||||
const failure: FreshnessFailure | null = latest?.failure ?? null;
|
||||
expect(failure).toEqual({ kind: 'fetch', message: 'connection lost' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
acceptSnapshot,
|
||||
assertMutable,
|
||||
computeFreshness,
|
||||
DEFAULT_FRESHNESS_POLICY,
|
||||
invalidationReasonLabels,
|
||||
type FreshPayload,
|
||||
type FreshSnapshot,
|
||||
type FreshnessPolicy,
|
||||
type FreshnessState,
|
||||
type InvalidationReason,
|
||||
StaleMutationError,
|
||||
} from './model';
|
||||
import { clearSnapshotCache, readSnapshotCache, writeSnapshotCache } from './snapshot-cache';
|
||||
|
||||
/**
|
||||
* Freshness-aware collection fetch hook (RI-5-001).
|
||||
*
|
||||
* One hook owns one gateway collection end to end: fetch, schema validation,
|
||||
* snapshot acceptance with provenance, session-scoped last-known caching,
|
||||
* aging, and the mutation guard. Pages consume `freshness` and never infer
|
||||
* health from emptiness.
|
||||
*/
|
||||
|
||||
/** Why the latest validation did not produce a current snapshot. */
|
||||
export type FreshnessFailure =
|
||||
| { readonly kind: 'fetch'; readonly message: string }
|
||||
| { readonly kind: 'invalidated'; readonly reason: InvalidationReason };
|
||||
|
||||
export interface UseFreshCollectionOptions<T> {
|
||||
/** Source identity for provenance labels, e.g. `gateway:/api/tasks`. */
|
||||
readonly source: string;
|
||||
/** Performs the unvalidated fetch. The hook owns abort and verification. */
|
||||
readonly fetcher: (signal: AbortSignal) => Promise<unknown>;
|
||||
/**
|
||||
* Runtime schema validator. Returning `null` invalidates the payload
|
||||
* (`schema-mismatch`) instead of letting malformed JSON flow into render.
|
||||
*/
|
||||
readonly validate: (value: unknown) => FreshPayload<T> | null;
|
||||
/** Overrides of the default freshness policy. */
|
||||
readonly policy?: Partial<FreshnessPolicy>;
|
||||
/**
|
||||
* Session cache key for last-known snapshots. `null`/omitted disables
|
||||
* restore. Restored snapshots are unverified: they render only as
|
||||
* labeled `stale` data until a fetch re-verifies them.
|
||||
*/
|
||||
readonly cacheKey?: string | null;
|
||||
/** Injectable clock for deterministic age transitions in tests. */
|
||||
readonly clock?: () => number;
|
||||
/** Aging tick interval override (default derived from `staleAfterMs`). */
|
||||
readonly tickMs?: number;
|
||||
/** When false, no fetch runs (surfaces stay `unavailable`/`unknown`). */
|
||||
readonly enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface FreshCollection<T> {
|
||||
/** Last verified (or restored-unverified) snapshot, or `null`. */
|
||||
readonly snapshot: FreshSnapshot<T> | null;
|
||||
/** Snapshot data or `null` — never a fabricated empty collection. */
|
||||
readonly data: T | null;
|
||||
readonly freshness: FreshnessState;
|
||||
/** True while a validation request is in flight. */
|
||||
readonly validating: boolean;
|
||||
/** Outcome of the latest failed validation, `null` when healthy. */
|
||||
readonly failure: FreshnessFailure | null;
|
||||
/** False unless freshness is `current`; drives disabled UI affordances. */
|
||||
readonly canMutate: boolean;
|
||||
/** Re-run the fetch and re-verify. Always allowed (it is a read). */
|
||||
readonly revalidate: () => Promise<void>;
|
||||
/**
|
||||
* Run a state-changing operation against verified-current data only.
|
||||
* Rejects with `StaleMutationError` on any other state — the guard fires
|
||||
* even if a disabled button was bypassed (defense in depth).
|
||||
*/
|
||||
readonly mutate: <R>(operation: (data: T) => Promise<R>) => Promise<R>;
|
||||
}
|
||||
|
||||
const defaultClock = (): number => Date.now();
|
||||
|
||||
function resolveTickMs(policy: FreshnessPolicy, override?: number): number {
|
||||
if (override !== undefined && override > 0) return override;
|
||||
return Math.min(5_000, Math.max(250, Math.floor(policy.staleAfterMs / 4)));
|
||||
}
|
||||
|
||||
function isAuthFailure(caught: unknown): boolean {
|
||||
return (
|
||||
typeof caught === 'object' &&
|
||||
caught !== null &&
|
||||
'statusCode' in caught &&
|
||||
((caught as { statusCode?: unknown }).statusCode === 401 ||
|
||||
(caught as { statusCode?: unknown }).statusCode === 403)
|
||||
);
|
||||
}
|
||||
|
||||
function fetchFailureMessage(caught: unknown): string {
|
||||
if (caught instanceof Error && caught.message.trim().length > 0) return caught.message;
|
||||
return 'The request failed.';
|
||||
}
|
||||
|
||||
/** Human-readable summary of a failure for unavailable/stale notices. */
|
||||
export function describeFailure(failure: FreshnessFailure | null): string | null {
|
||||
if (failure === null) return null;
|
||||
if (failure.kind === 'fetch') return failure.message;
|
||||
return `The snapshot was invalidated: ${invalidationReasonLabels[failure.reason]}.`;
|
||||
}
|
||||
|
||||
export function useFreshCollection<T>(options: UseFreshCollectionOptions<T>): FreshCollection<T> {
|
||||
const optionsRef = useRef(options);
|
||||
optionsRef.current = options;
|
||||
|
||||
const policy = useMemo<FreshnessPolicy>(
|
||||
() => ({ ...DEFAULT_FRESHNESS_POLICY, ...options.policy }),
|
||||
[options.policy],
|
||||
);
|
||||
const policyRef = useRef(policy);
|
||||
policyRef.current = policy;
|
||||
|
||||
const clockRef = useRef(options.clock ?? defaultClock);
|
||||
clockRef.current = options.clock ?? defaultClock;
|
||||
|
||||
const [snapshot, setSnapshot] = useState<FreshSnapshot<T> | null>(null);
|
||||
const [failure, setFailure] = useState<FreshnessFailure | null>(null);
|
||||
const [unverified, setUnverified] = useState(false);
|
||||
const [validating, setValidating] = useState(options.enabled !== false);
|
||||
const [now, setNow] = useState(() => (options.clock ?? defaultClock)());
|
||||
|
||||
const snapshotRef = useRef(snapshot);
|
||||
snapshotRef.current = snapshot;
|
||||
const failureRef = useRef(failure);
|
||||
failureRef.current = failure;
|
||||
const unverifiedRef = useRef(unverified);
|
||||
unverifiedRef.current = unverified;
|
||||
|
||||
const runRef = useRef(0);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const revalidate = useCallback(async (): Promise<void> => {
|
||||
const current = optionsRef.current;
|
||||
if (current.enabled === false) {
|
||||
setValidating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const runId = ++runRef.current;
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
setValidating(true);
|
||||
|
||||
let value: unknown;
|
||||
try {
|
||||
value = await current.fetcher(controller.signal);
|
||||
} catch (caught) {
|
||||
if (runRef.current !== runId || controller.signal.aborted) return;
|
||||
if (isAuthFailure(caught)) {
|
||||
// An unauthenticated viewer must not keep (or be served) the
|
||||
// previous user's last-known data.
|
||||
setSnapshot(null);
|
||||
setUnverified(false);
|
||||
if (current.cacheKey) clearSnapshotCache(current.cacheKey);
|
||||
}
|
||||
setFailure({ kind: 'fetch', message: fetchFailureMessage(caught) });
|
||||
setValidating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (runRef.current !== runId) return;
|
||||
|
||||
const result = acceptSnapshot({
|
||||
value,
|
||||
validate: current.validate,
|
||||
previous: snapshotRef.current,
|
||||
policy: policyRef.current,
|
||||
source: current.source,
|
||||
now: clockRef.current(),
|
||||
});
|
||||
|
||||
if (result.outcome === 'accepted') {
|
||||
setSnapshot(result.snapshot);
|
||||
setUnverified(false);
|
||||
setFailure(null);
|
||||
if (current.cacheKey) writeSnapshotCache(current.cacheKey, result.snapshot);
|
||||
} else {
|
||||
if (result.reason === 'cross-workspace') {
|
||||
// Data verified for a different workspace must not linger as
|
||||
// last-known situational awareness either.
|
||||
setSnapshot(null);
|
||||
setUnverified(false);
|
||||
}
|
||||
if (current.cacheKey) clearSnapshotCache(current.cacheKey);
|
||||
setFailure({ kind: 'invalidated', reason: result.reason });
|
||||
}
|
||||
setValidating(false);
|
||||
}, []);
|
||||
|
||||
// Restore the last-known snapshot (unverified) and run the first fetch.
|
||||
useEffect(() => {
|
||||
if (optionsRef.current.enabled === false) {
|
||||
setValidating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = optionsRef.current.cacheKey;
|
||||
if (cacheKey) {
|
||||
const restored = readSnapshotCache<T>({
|
||||
key: cacheKey,
|
||||
workspace: policyRef.current.workspace,
|
||||
policy: policyRef.current,
|
||||
validate: optionsRef.current.validate,
|
||||
});
|
||||
if (restored.outcome === 'hit') {
|
||||
setSnapshot(restored.snapshot);
|
||||
setUnverified(true);
|
||||
} else if (restored.outcome === 'invalidated') {
|
||||
// A corrupted/foreign/regressed entry is dropped immediately; it must
|
||||
// never surface as data. The fetch decides the visible state.
|
||||
clearSnapshotCache(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
void revalidate();
|
||||
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
// Mount-once by design: `revalidate` is stable and reads live options
|
||||
// through refs, so it never needs to re-run when options change.
|
||||
// Route-param pages remount this hook via an identity `key` instead.
|
||||
}, [revalidate]);
|
||||
|
||||
// Aging tick: recomputes freshness as the snapshot ages past the policy.
|
||||
useEffect(() => {
|
||||
const interval = setInterval(
|
||||
() => {
|
||||
setNow(clockRef.current());
|
||||
},
|
||||
resolveTickMs(policyRef.current, optionsRef.current.tickMs),
|
||||
);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const freshness = useMemo<FreshnessState>(() => {
|
||||
if (snapshot === null) return validating ? 'unknown' : 'unavailable';
|
||||
return computeFreshness({
|
||||
snapshot,
|
||||
policy,
|
||||
now,
|
||||
degraded: failure !== null || unverified,
|
||||
});
|
||||
// `now` from state covers age; refs inside computeFreshness are pure.
|
||||
}, [snapshot, validating, failure, unverified, now, policy]);
|
||||
|
||||
const canMutate = freshness === 'current';
|
||||
|
||||
const mutate = useCallback(async <R>(operation: (data: T) => Promise<R>): Promise<R> => {
|
||||
const currentSnapshot = snapshotRef.current;
|
||||
// No verified snapshot at all: with nothing verified there is nothing
|
||||
// current to mutate, regardless of the recorded failure.
|
||||
if (currentSnapshot === null) throw new StaleMutationError('unavailable');
|
||||
const state = computeFreshness({
|
||||
snapshot: currentSnapshot,
|
||||
policy: policyRef.current,
|
||||
now: clockRef.current(),
|
||||
degraded: failureRef.current !== null || unverifiedRef.current,
|
||||
});
|
||||
assertMutable(state);
|
||||
return operation(currentSnapshot.data);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
snapshot,
|
||||
data: snapshot === null ? null : snapshot.data,
|
||||
freshness,
|
||||
validating,
|
||||
failure,
|
||||
canMutate,
|
||||
revalidate,
|
||||
mutate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Mission, Project, Task } from '@/lib/types';
|
||||
import {
|
||||
validateMissionCollection,
|
||||
validateProjectCollection,
|
||||
validateProjectEntity,
|
||||
validateTaskCollection,
|
||||
} from './validators';
|
||||
import { missionFixtures, projectFixtures, taskFixtures } from '@/spa/pages/page-fixtures';
|
||||
|
||||
describe('validateTaskCollection', () => {
|
||||
it('accepts a well-formed task collection', () => {
|
||||
expect(validateTaskCollection(taskFixtures)).toEqual({
|
||||
data: taskFixtures,
|
||||
workspace: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts an empty collection (a healthy empty state is a valid payload)', () => {
|
||||
expect(validateTaskCollection([])).toEqual({ data: [], workspace: null });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['not an array', { items: [] }],
|
||||
['item is not an object', ['nope']],
|
||||
['missing id', [{ ...(taskFixtures[0] as Task), id: undefined }]],
|
||||
['missing title', [{ ...(taskFixtures[0] as Task), title: undefined }]],
|
||||
['unknown status enum', [{ ...(taskFixtures[0] as Task), status: 'finished' }]],
|
||||
['unknown priority enum', [{ ...(taskFixtures[0] as Task), priority: 'urgent' }]],
|
||||
['tags of the wrong type', [{ ...(taskFixtures[0] as Task), tags: 'spa' }]],
|
||||
['metadata of the wrong type', [{ ...(taskFixtures[0] as Task), metadata: 'notes' }]],
|
||||
['createdAt of the wrong type', [{ ...(taskFixtures[0] as Task), createdAt: 1234 }]],
|
||||
['null sneaks past a required string', [{ ...(taskFixtures[0] as Task), title: null }]],
|
||||
])('rejects a malformed payload: %s', (_label, value) => {
|
||||
expect(validateTaskCollection(value)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateMissionCollection', () => {
|
||||
it('accepts a well-formed mission collection', () => {
|
||||
expect(validateMissionCollection(missionFixtures)).toEqual({
|
||||
data: missionFixtures,
|
||||
workspace: null,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['not an array', null],
|
||||
['item missing name', [{ ...(missionFixtures[0] as Mission), name: 42 }]],
|
||||
['unknown status enum', [{ ...(missionFixtures[0] as Mission), status: 'canceled' }]],
|
||||
['projectId of the wrong type', [{ ...(missionFixtures[0] as Mission), projectId: 7 }]],
|
||||
])('rejects a malformed payload: %s', (_label, value) => {
|
||||
expect(validateMissionCollection(value)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateProjectCollection', () => {
|
||||
it('accepts a uniform workspace-scoped collection and reports its workspace', () => {
|
||||
expect(validateProjectCollection(projectFixtures)).toEqual({
|
||||
data: projectFixtures,
|
||||
workspace: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts an empty collection with no workspace identity', () => {
|
||||
expect(validateProjectCollection([])).toEqual({ data: [], workspace: null });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['not an array', 42],
|
||||
['item missing userId', [{ ...(projectFixtures[0] as Project), userId: undefined }]],
|
||||
['unknown status enum', [{ ...(projectFixtures[0] as Project), status: 'live' }]],
|
||||
['description of the wrong type', [{ ...(projectFixtures[0] as Project), description: 1 }]],
|
||||
])('rejects a malformed payload: %s', (_label, value) => {
|
||||
expect(validateProjectCollection(value)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a collection mixing workspace identities (cross-workspace leak)', () => {
|
||||
const mixed = [
|
||||
projectFixtures[0] as Project,
|
||||
{ ...(projectFixtures[1] as Project), userId: 'user-2' },
|
||||
];
|
||||
expect(validateProjectCollection(mixed)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateProjectEntity', () => {
|
||||
it('accepts a well-formed project and reports its workspace', () => {
|
||||
expect(validateProjectEntity(projectFixtures[0])).toEqual({
|
||||
data: projectFixtures[0],
|
||||
workspace: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['not an object', 'project-1'],
|
||||
['null', null],
|
||||
['array', [projectFixtures[0]]],
|
||||
['missing userId', [{ ...(projectFixtures[0] as Project), userId: null }]],
|
||||
])('rejects a malformed entity: %s', (_label, value) => {
|
||||
expect(validateProjectEntity(value)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { Mission, Project, Task, MissionStatus, TaskPriority, TaskStatus } from '@/lib/types';
|
||||
import type { FreshPayload } from './model';
|
||||
|
||||
/**
|
||||
* Runtime schema validators for gateway collections (RI-5-001).
|
||||
*
|
||||
* `api<T>()` returns untrusted JSON cast to `T`; these validators are the
|
||||
* seam where a malformed response becomes an explicit schema mismatch
|
||||
* instead of flowing into the render path as if it were healthy data.
|
||||
*/
|
||||
|
||||
const taskStatuses: readonly TaskStatus[] = [
|
||||
'not-started',
|
||||
'in-progress',
|
||||
'blocked',
|
||||
'done',
|
||||
'cancelled',
|
||||
];
|
||||
const taskPriorities: readonly TaskPriority[] = ['critical', 'high', 'medium', 'low'];
|
||||
const missionStatuses: readonly MissionStatus[] = [
|
||||
'planning',
|
||||
'active',
|
||||
'paused',
|
||||
'completed',
|
||||
'failed',
|
||||
];
|
||||
const projectStatuses: readonly Project['status'][] = ['active', 'paused', 'completed', 'archived'];
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isString(value: unknown): value is string {
|
||||
return typeof value === 'string';
|
||||
}
|
||||
|
||||
function isNullableString(value: unknown): value is string | null {
|
||||
return value === null || typeof value === 'string';
|
||||
}
|
||||
|
||||
function isOneOf<T extends string>(value: unknown, allowed: readonly T[]): value is T {
|
||||
return typeof value === 'string' && (allowed as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function isNullableRecord(value: unknown): value is Record<string, unknown> | null {
|
||||
return value === null || isRecord(value);
|
||||
}
|
||||
|
||||
function isNullableStringArray(value: unknown): value is string[] | null {
|
||||
if (value === null) return true;
|
||||
if (!Array.isArray(value)) return false;
|
||||
return value.every((item) => typeof item === 'string');
|
||||
}
|
||||
|
||||
function isIsoLike(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.length > 0;
|
||||
}
|
||||
|
||||
function isTask(value: unknown): value is Task {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
isString(value['id']) &&
|
||||
isString(value['title']) &&
|
||||
isOneOf(value['status'], taskStatuses) &&
|
||||
isOneOf(value['priority'], taskPriorities) &&
|
||||
isNullableString(value['projectId']) &&
|
||||
isNullableString(value['missionId']) &&
|
||||
isNullableString(value['assignee']) &&
|
||||
isNullableStringArray(value['tags']) &&
|
||||
isNullableRecord(value['metadata']) &&
|
||||
isNullableString(value['dueDate']) &&
|
||||
isIsoLike(value['createdAt']) &&
|
||||
isIsoLike(value['updatedAt'])
|
||||
);
|
||||
}
|
||||
|
||||
/** Tasks carry no workspace identity; scope falls back to the policy. */
|
||||
export function validateTaskCollection(value: unknown): FreshPayload<Task[]> | null {
|
||||
if (!Array.isArray(value) || !value.every(isTask)) return null;
|
||||
return { data: value as Task[], workspace: null };
|
||||
}
|
||||
|
||||
function isMission(value: unknown): value is Mission {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
isString(value['id']) &&
|
||||
isString(value['name']) &&
|
||||
isOneOf(value['status'], missionStatuses) &&
|
||||
isNullableString(value['projectId']) &&
|
||||
isNullableString(value['description']) &&
|
||||
isNullableRecord(value['metadata']) &&
|
||||
isIsoLike(value['createdAt']) &&
|
||||
isIsoLike(value['updatedAt'])
|
||||
);
|
||||
}
|
||||
|
||||
/** Missions carry no workspace identity; scope falls back to the policy. */
|
||||
export function validateMissionCollection(value: unknown): FreshPayload<Mission[]> | null {
|
||||
if (!Array.isArray(value) || !value.every(isMission)) return null;
|
||||
return { data: value as Mission[], workspace: null };
|
||||
}
|
||||
|
||||
function isProject(value: unknown): value is Project {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
isString(value['id']) &&
|
||||
isString(value['name']) &&
|
||||
isOneOf(value['status'], projectStatuses) &&
|
||||
isString(value['userId']) &&
|
||||
isNullableString(value['description']) &&
|
||||
isNullableRecord(value['metadata']) &&
|
||||
isIsoLike(value['createdAt']) &&
|
||||
isIsoLike(value['updatedAt'])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects are workspace-scoped: every item must carry the same `userId`.
|
||||
* A collection mixing identities (cross-workspace leak) is a schema
|
||||
* mismatch; the uniform `userId` becomes the snapshot workspace.
|
||||
*/
|
||||
export function validateProjectCollection(value: unknown): FreshPayload<Project[]> | null {
|
||||
if (!Array.isArray(value) || !value.every(isProject)) return null;
|
||||
const projects = value as Project[];
|
||||
const workspaces = new Set(projects.map((project) => project.userId));
|
||||
if (workspaces.size > 1) return null;
|
||||
return { data: projects, workspace: projects.length > 0 ? projects[0]!.userId : null };
|
||||
}
|
||||
|
||||
/** Single project entity (project detail primary collection). */
|
||||
export function validateProjectEntity(value: unknown): FreshPayload<Project> | null {
|
||||
if (!isProject(value)) return null;
|
||||
const project = value as Project;
|
||||
return { data: project, workspace: project.userId };
|
||||
}
|
||||
@@ -35,6 +35,7 @@ afterEach(async () => {
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
apiMock.mockReset();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
async function renderProjectDetailPage(): Promise<ReturnType<typeof createMemoryRouter>> {
|
||||
@@ -64,21 +65,49 @@ function clickButtonByText(text: string): void {
|
||||
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
}
|
||||
|
||||
async function flushAct(): Promise<void> {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const projectOneTasks = taskFixtures.filter((task) => task.projectId === 'project-1');
|
||||
|
||||
function mockHealthyLoad(): void {
|
||||
apiMock
|
||||
.mockResolvedValueOnce(projectFixtures[0])
|
||||
.mockResolvedValueOnce(missionFixtures)
|
||||
.mockResolvedValueOnce(projectOneTasks);
|
||||
}
|
||||
|
||||
describe('ProjectDetailPage', () => {
|
||||
it('loads the project, tasks, missions, and optional PRD content for the active project', async () => {
|
||||
apiMock
|
||||
.mockResolvedValueOnce(projectFixtures[0])
|
||||
.mockResolvedValueOnce(missionFixtures)
|
||||
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
||||
mockHealthyLoad();
|
||||
|
||||
await renderProjectDetailPage();
|
||||
|
||||
expect(apiMock.mock.calls).toEqual([
|
||||
['/api/projects/project-1'],
|
||||
['/api/missions'],
|
||||
['/api/tasks?projectId=project-1'],
|
||||
expect(apiMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
'/api/projects/project-1',
|
||||
'/api/missions',
|
||||
'/api/tasks?projectId=project-1',
|
||||
]);
|
||||
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'current',
|
||||
);
|
||||
expect(container.textContent).toContain('Mosaic Stack');
|
||||
expect(container.textContent).toContain('Route /projects/:id');
|
||||
expect(container.textContent).toContain('Tasks');
|
||||
@@ -101,10 +130,7 @@ describe('ProjectDetailPage', () => {
|
||||
});
|
||||
|
||||
it('opens and closes the existing read-only task modal from the tasks tab', async () => {
|
||||
apiMock
|
||||
.mockResolvedValueOnce(projectFixtures[0])
|
||||
.mockResolvedValueOnce(missionFixtures)
|
||||
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
||||
mockHealthyLoad();
|
||||
|
||||
await renderProjectDetailPage();
|
||||
|
||||
@@ -134,35 +160,153 @@ describe('ProjectDetailPage', () => {
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the project with an empty missions tab when the missions request fails', async () => {
|
||||
apiMock
|
||||
.mockResolvedValueOnce(projectFixtures[0])
|
||||
.mockRejectedValueOnce(new Error('Missions request failed'))
|
||||
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
||||
it('shows verified completion verdicts when the task collection is current', async () => {
|
||||
mockHealthyLoad();
|
||||
|
||||
await renderProjectDetailPage();
|
||||
|
||||
expect(container.textContent).toContain('Mosaic Stack');
|
||||
expect(container.querySelector('[role="alert"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('Missions (0)');
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('No missions for this project');
|
||||
const doneCard = [...container.querySelectorAll('div')].find(
|
||||
(candidate) => candidate.textContent === 'Done1',
|
||||
);
|
||||
expect(doneCard).toBeTruthy();
|
||||
const inProgressCard = [...container.querySelectorAll('div')].find(
|
||||
(candidate) => candidate.textContent === 'In Progress1',
|
||||
);
|
||||
expect(inProgressCard).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders a visible alert when the project request fails and lets the user navigate back', async () => {
|
||||
it('renders an explicit unavailable missions tab when the missions request fails (partial, not empty)', async () => {
|
||||
apiMock
|
||||
.mockResolvedValueOnce(projectFixtures[0])
|
||||
.mockRejectedValueOnce(new Error('Missions request failed'))
|
||||
.mockResolvedValueOnce(projectOneTasks);
|
||||
|
||||
await renderProjectDetailPage();
|
||||
|
||||
// Secondary failure degrades the surface to partial; the project itself
|
||||
// still renders.
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'partial',
|
||||
);
|
||||
expect(container.textContent).toContain('Mosaic Stack');
|
||||
const partial = container.querySelector('[role="status"]');
|
||||
expect(partial?.textContent).toContain('Missions');
|
||||
expect(partial?.textContent).toContain('unavailable');
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('Missions (?)');
|
||||
});
|
||||
|
||||
const alert = container.querySelector('[role="alert"]');
|
||||
expect(alert?.textContent).toContain('Missions request failed');
|
||||
// Negative control: a failed fetch must not look like an empty list.
|
||||
expect(container.textContent).not.toContain('No missions for this project');
|
||||
});
|
||||
|
||||
it('marks derived verdicts unknown when the tasks collection is unavailable', async () => {
|
||||
apiMock
|
||||
.mockResolvedValueOnce(projectFixtures[0])
|
||||
.mockResolvedValueOnce(missionFixtures)
|
||||
.mockRejectedValueOnce(new Error('Tasks request failed'));
|
||||
|
||||
await renderProjectDetailPage();
|
||||
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'partial',
|
||||
);
|
||||
|
||||
// Completion verdicts become unknown ('?') — never green counts.
|
||||
for (const label of ['Done', 'In Progress', 'Blocked', 'Tasks']) {
|
||||
const unknownCard = [...container.querySelectorAll('div')].find(
|
||||
(candidate) => candidate.textContent === `${label}?`,
|
||||
);
|
||||
expect(unknownCard, `expected ${label} card to render ?`).toBeTruthy();
|
||||
}
|
||||
// Negative control: no green "Done 1" verdict anywhere.
|
||||
expect(
|
||||
[...container.querySelectorAll('div')].some((candidate) => candidate.textContent === 'Done1'),
|
||||
).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('Tasks (?)');
|
||||
});
|
||||
|
||||
const alert = container.querySelector('[role="alert"]');
|
||||
expect(alert?.textContent).toContain('Tasks request failed');
|
||||
// Negative control: no healthy empty task list from a failed fetch.
|
||||
expect(container.textContent).not.toContain('No tasks found');
|
||||
expect(container.querySelector('table')).toBeNull();
|
||||
});
|
||||
|
||||
it('recovers a partial surface to current after revalidation', async () => {
|
||||
apiMock
|
||||
.mockResolvedValueOnce(projectFixtures[0])
|
||||
.mockResolvedValueOnce(missionFixtures)
|
||||
.mockRejectedValueOnce(new Error('Tasks request failed'))
|
||||
.mockResolvedValueOnce(projectFixtures[0])
|
||||
.mockResolvedValueOnce(missionFixtures)
|
||||
.mockResolvedValueOnce(projectOneTasks);
|
||||
|
||||
await renderProjectDetailPage();
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'partial',
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('Revalidate');
|
||||
});
|
||||
await flushAct();
|
||||
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'current',
|
||||
);
|
||||
expect(
|
||||
[...container.querySelectorAll('div')].some((candidate) => candidate.textContent === 'Done1'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("never shows one project's data on another project's route after navigation", async () => {
|
||||
mockHealthyLoad();
|
||||
|
||||
const router = await renderProjectDetailPage();
|
||||
expect(container.textContent).toContain('Mosaic Stack');
|
||||
|
||||
const deferred = createDeferred<(typeof projectFixtures)[number]>();
|
||||
apiMock
|
||||
.mockResolvedValueOnce(deferred.promise)
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
await act(async () => {
|
||||
await router.navigate('/projects/project-2');
|
||||
});
|
||||
|
||||
// While project-2 loads, nothing from project-1 may render on its route.
|
||||
expect(container.textContent).toContain('Loading project...');
|
||||
expect(container.textContent).not.toContain('Mosaic Stack');
|
||||
expect(container.textContent).not.toContain('Route /projects/:id');
|
||||
|
||||
await act(async () => {
|
||||
deferred.resolve(projectFixtures[1]!);
|
||||
await deferred.promise;
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('Agent Runtime');
|
||||
expect(apiMock.mock.calls[3]?.[0]).toBe('/api/projects/project-2');
|
||||
});
|
||||
|
||||
it('renders a visible unavailable state when the project request fails and lets the user navigate back', async () => {
|
||||
apiMock
|
||||
.mockRejectedValueOnce(new Error('Project request failed'))
|
||||
.mockResolvedValueOnce(missionFixtures)
|
||||
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
||||
.mockResolvedValueOnce(projectOneTasks);
|
||||
|
||||
const router = await renderProjectDetailPage();
|
||||
|
||||
const alert = container.querySelector('[role="alert"]');
|
||||
expect(alert).toBeTruthy();
|
||||
expect(alert?.textContent).toContain('Project request failed');
|
||||
expect(alert?.textContent).toContain('not an empty result');
|
||||
expect(container.textContent).not.toContain('Mosaic Stack');
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
import { useEffect, useState, type ReactElement } from 'react';
|
||||
import { useState, type ReactElement } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { MissionTimeline } from '@/components/projects/mission-timeline';
|
||||
import { PrdViewer } from '@/components/projects/prd-viewer';
|
||||
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
|
||||
import { TaskListView } from '@/components/tasks/task-list-view';
|
||||
import { TaskStatusSummary } from '@/components/tasks/task-status-summary';
|
||||
import {
|
||||
PartialDataNotice,
|
||||
StaleDataNotice,
|
||||
UnavailableDataNotice,
|
||||
} from '@/components/freshness/freshness-notices';
|
||||
import { api } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Mission, Project, Task, TaskStatus } from '@/lib/types';
|
||||
import { getErrorMessage } from './page-errors';
|
||||
import {
|
||||
combineFreshness,
|
||||
UNKNOWN_VERDICT,
|
||||
verdictValue,
|
||||
type FreshSnapshot,
|
||||
} from '@/lib/freshness/model';
|
||||
import { describeFailure, useFreshCollection } from '@/lib/freshness/use-fresh-collection';
|
||||
import {
|
||||
validateMissionCollection,
|
||||
validateProjectEntity,
|
||||
validateTaskCollection,
|
||||
} from '@/lib/freshness/validators';
|
||||
|
||||
type Tab = 'overview' | 'tasks' | 'missions' | 'prd';
|
||||
|
||||
@@ -51,73 +67,62 @@ function TabButton({ id, label, activeTab, onClick }: TabButtonProps): ReactElem
|
||||
);
|
||||
}
|
||||
|
||||
/** Remounts per project id so no state from one project renders for another. */
|
||||
export function ProjectDetailPage(): ReactElement {
|
||||
const { id = '' } = useParams();
|
||||
return <ProjectDetail id={id} key={id} />;
|
||||
}
|
||||
|
||||
function ProjectDetail({ id }: { id: string }): ReactElement {
|
||||
const navigate = useNavigate();
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [missions, setMissions] = useState<Mission[]>([]);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const enabled = id.length > 0;
|
||||
|
||||
// Primary collection gates the surface; missions and tasks are secondaries
|
||||
// whose failures degrade the surface to `partial` instead of rendering
|
||||
// empty healthy lists.
|
||||
const project = useFreshCollection<Project>({
|
||||
source: `gateway:/api/projects/${id}`,
|
||||
fetcher: (signal) => api<unknown>(`/api/projects/${id}`, { signal }),
|
||||
validate: validateProjectEntity,
|
||||
// No last-known restore: the entity carries workspace identity that
|
||||
// cannot be scope-checked before display (see ProjectsPage note).
|
||||
enabled,
|
||||
});
|
||||
const missions = useFreshCollection<Mission[]>({
|
||||
source: 'gateway:/api/missions',
|
||||
fetcher: (signal) => api<unknown>('/api/missions', { signal }),
|
||||
validate: validateMissionCollection,
|
||||
cacheKey: enabled ? 'missions' : null,
|
||||
enabled,
|
||||
});
|
||||
const tasks = useFreshCollection<Task[]>({
|
||||
source: `gateway:/api/tasks?projectId=${id}`,
|
||||
fetcher: (signal) => api<unknown>(`/api/tasks?projectId=${id}`, { signal }),
|
||||
validate: validateTaskCollection,
|
||||
cacheKey: enabled ? `project-tasks:${id}` : null,
|
||||
enabled,
|
||||
});
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||
const [taskFilter, setTaskFilter] = useState<TaskStatus | 'all'>('all');
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
setError('Project id is missing.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const surface = combineFreshness(project.freshness, [missions.freshness, tasks.freshness]);
|
||||
const tasksVerified = tasks.freshness === 'current';
|
||||
const projectMissions = missions.data?.filter((mission) => mission.projectId === id) ?? null;
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const retryAll = (): void => {
|
||||
void Promise.all([project.revalidate(), missions.revalidate(), tasks.revalidate()]);
|
||||
};
|
||||
|
||||
void Promise.all([
|
||||
api<Project>('/api/projects/' + id),
|
||||
api<Mission[]>('/api/missions').catch(() => [] as Mission[]),
|
||||
api<Task[]>('/api/tasks?projectId=' + id).catch(() => [] as Task[]),
|
||||
])
|
||||
.then(([loadedProject, allMissions, loadedTasks]) => {
|
||||
if (cancelled) return;
|
||||
setProject(loadedProject);
|
||||
setMissions(allMissions.filter((mission) => mission.projectId === id));
|
||||
setTasks(loadedTasks);
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (cancelled) return;
|
||||
setError(getErrorMessage(caught, 'Failed to load project.'));
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||
<header className="mb-6 border-b px-1 pb-3">
|
||||
<h1 className="text-2xl font-semibold">Project</h1>
|
||||
</header>
|
||||
<p className="py-16 text-center text-sm text-text-muted">Loading project...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !project) {
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||
<header className="mb-6 border-b px-1 pb-3">
|
||||
<h1 className="text-2xl font-semibold">Project</h1>
|
||||
</header>
|
||||
<div role="alert" className="rounded-lg border border-error/40 px-4 py-3 text-sm">
|
||||
{error ?? 'Project not found.'}
|
||||
Project id is missing.
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -130,18 +135,81 @@ export function ProjectDetailPage(): ReactElement {
|
||||
);
|
||||
}
|
||||
|
||||
if (project.freshness === 'unknown') {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||
<header className="mb-6 border-b px-1 pb-3">
|
||||
<h1 className="text-2xl font-semibold">Project</h1>
|
||||
</header>
|
||||
<p className="py-16 text-center text-sm text-text-muted">Loading project...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (project.freshness === 'unavailable' || project.data === null) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||
<header className="mb-6 border-b px-1 pb-3">
|
||||
<h1 className="text-2xl font-semibold">Project</h1>
|
||||
</header>
|
||||
<UnavailableDataNotice
|
||||
title="This project"
|
||||
detail={describeFailure(project.failure)}
|
||||
onRetry={retryAll}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/projects')}
|
||||
className="mt-4 w-fit text-sm underline"
|
||||
>
|
||||
Back to projects
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const projectTasks = tasks.data ?? null;
|
||||
const filteredTasks =
|
||||
taskFilter === 'all' ? tasks : tasks.filter((task) => task.status === taskFilter);
|
||||
const prdContent = getPrdContent(project);
|
||||
projectTasks === null
|
||||
? []
|
||||
: taskFilter === 'all'
|
||||
? projectTasks
|
||||
: projectTasks.filter((task) => task.status === taskFilter);
|
||||
|
||||
// Derived completion verdicts: unknown (never green) unless the task
|
||||
// collection is verified current.
|
||||
const doneCount = projectTasks?.filter((task) => task.status === 'done').length ?? 0;
|
||||
const inProgressCount = projectTasks?.filter((task) => task.status === 'in-progress').length ?? 0;
|
||||
const blockedCount = projectTasks?.filter((task) => task.status === 'blocked').length ?? 0;
|
||||
|
||||
const prdContent = getPrdContent(project.data);
|
||||
const tabs: Array<{ id: Tab; label: string }> = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'tasks', label: `Tasks (${tasks.length})` },
|
||||
{ id: 'missions', label: `Missions (${missions.length})` },
|
||||
{
|
||||
id: 'tasks',
|
||||
label: `Tasks (${projectTasks === null ? UNKNOWN_VERDICT : projectTasks.length})`,
|
||||
},
|
||||
{
|
||||
id: 'missions',
|
||||
label: `Missions (${projectMissions === null ? UNKNOWN_VERDICT : projectMissions.length})`,
|
||||
},
|
||||
...(prdContent ? [{ id: 'prd' as const, label: 'PRD' }] : []),
|
||||
];
|
||||
|
||||
const staleSnapshot: FreshSnapshot<unknown> | null =
|
||||
project.freshness === 'stale'
|
||||
? project.snapshot
|
||||
: missions.freshness === 'stale'
|
||||
? missions.snapshot
|
||||
: tasks.freshness === 'stale'
|
||||
? tasks.snapshot
|
||||
: null;
|
||||
const missingSections: string[] = [];
|
||||
if (missions.freshness === 'unavailable') missingSections.push('Missions');
|
||||
if (tasks.freshness === 'unavailable') missingSections.push('Tasks');
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||
<div data-freshness={surface} className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||
<header className="mb-6 border-b px-1 pb-3">
|
||||
<nav className="mb-4 flex items-center gap-2 text-sm text-text-muted">
|
||||
<button
|
||||
@@ -152,49 +220,64 @@ export function ProjectDetailPage(): ReactElement {
|
||||
Projects
|
||||
</button>
|
||||
<span>/</span>
|
||||
<span className="text-text-primary">{project.name}</span>
|
||||
<span className="text-text-primary">{project.data.name}</span>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-semibold text-text-primary">{project.name}</h1>
|
||||
<h1 className="text-2xl font-semibold text-text-primary">{project.data.name}</h1>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2 py-0.5 text-xs',
|
||||
projectStatusColors[project.status] ?? 'bg-gray-600/20 text-gray-400',
|
||||
projectStatusColors[project.data.status] ?? 'bg-gray-600/20 text-gray-400',
|
||||
)}
|
||||
>
|
||||
{project.status}
|
||||
{project.data.status}
|
||||
</span>
|
||||
</div>
|
||||
{project.description ? (
|
||||
<p className="mt-1 text-sm text-text-muted">{project.description}</p>
|
||||
{project.data.description ? (
|
||||
<p className="mt-1 text-sm text-text-muted">{project.data.description}</p>
|
||||
) : null}
|
||||
<p className="mt-2 text-xs text-text-muted">
|
||||
Created {new Date(project.createdAt).toLocaleDateString()} · Updated{' '}
|
||||
{new Date(project.updatedAt).toLocaleDateString()}
|
||||
Created {new Date(project.data.createdAt).toLocaleDateString()} · Updated{' '}
|
||||
{new Date(project.data.updatedAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{staleSnapshot !== null ? (
|
||||
<div className="mb-6">
|
||||
<StaleDataNotice label={staleSnapshot} onRetry={retryAll} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{missingSections.length > 0 ? (
|
||||
<div className="mb-6">
|
||||
<PartialDataNotice missing={missingSections} onRetry={retryAll} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatCard label="Tasks" value={String(tasks.length)} />
|
||||
<StatCard
|
||||
label="Tasks"
|
||||
value={projectTasks === null ? UNKNOWN_VERDICT : String(projectTasks.length)}
|
||||
/>
|
||||
<StatCard
|
||||
label="Done"
|
||||
value={String(tasks.filter((task) => task.status === 'done').length)}
|
||||
valueClass="text-success"
|
||||
value={verdictValue(tasksVerified, String(doneCount))}
|
||||
valueClass={tasksVerified ? 'text-success' : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
label="In Progress"
|
||||
value={String(tasks.filter((task) => task.status === 'in-progress').length)}
|
||||
valueClass="text-blue-400"
|
||||
value={verdictValue(tasksVerified, String(inProgressCount))}
|
||||
valueClass={tasksVerified ? 'text-blue-400' : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
label="Blocked"
|
||||
value={String(tasks.filter((task) => task.status === 'blocked').length)}
|
||||
valueClass={tasks.some((task) => task.status === 'blocked') ? 'text-error' : undefined}
|
||||
value={verdictValue(tasksVerified, String(blockedCount))}
|
||||
valueClass={tasksVerified && blockedCount > 0 ? 'text-error' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -211,23 +294,43 @@ export function ProjectDetailPage(): ReactElement {
|
||||
</div>
|
||||
|
||||
{activeTab === 'overview' ? (
|
||||
<OverviewTab project={project} missions={missions} tasks={tasks} />
|
||||
<OverviewTab project={project.data} missions={projectMissions} tasks={projectTasks} />
|
||||
) : null}
|
||||
|
||||
{activeTab === 'tasks' ? (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<TaskStatusSummary
|
||||
tasks={tasks}
|
||||
activeFilter={taskFilter}
|
||||
onFilterChange={setTaskFilter}
|
||||
{projectTasks === null ? (
|
||||
<UnavailableDataNotice
|
||||
title="Tasks"
|
||||
detail={describeFailure(tasks.failure)}
|
||||
onRetry={retryAll}
|
||||
/>
|
||||
</div>
|
||||
<TaskListView tasks={filteredTasks} onTaskClick={setSelectedTask} />
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<TaskStatusSummary
|
||||
tasks={projectTasks}
|
||||
activeFilter={taskFilter}
|
||||
onFilterChange={setTaskFilter}
|
||||
/>
|
||||
</div>
|
||||
<TaskListView tasks={filteredTasks} onTaskClick={setSelectedTask} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'missions' ? <MissionTimeline missions={missions} /> : null}
|
||||
{activeTab === 'missions' ? (
|
||||
projectMissions === null ? (
|
||||
<UnavailableDataNotice
|
||||
title="Missions"
|
||||
detail={describeFailure(missions.failure)}
|
||||
onRetry={retryAll}
|
||||
/>
|
||||
) : (
|
||||
<MissionTimeline missions={projectMissions} />
|
||||
)
|
||||
) : null}
|
||||
|
||||
{activeTab === 'prd' && prdContent ? (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6">
|
||||
@@ -248,18 +351,26 @@ function OverviewTab({
|
||||
tasks,
|
||||
}: {
|
||||
project: Project;
|
||||
missions: Mission[];
|
||||
tasks: Task[];
|
||||
missions: Mission[] | null;
|
||||
tasks: Task[] | null;
|
||||
}): ReactElement {
|
||||
const recentTasks = [...tasks]
|
||||
.sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime())
|
||||
.slice(0, 5);
|
||||
const recentTasks =
|
||||
tasks === null
|
||||
? null
|
||||
: [...tasks]
|
||||
.sort(
|
||||
(left, right) =>
|
||||
new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(),
|
||||
)
|
||||
.slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Recent Tasks</h2>
|
||||
{recentTasks.length === 0 ? (
|
||||
{recentTasks === null ? (
|
||||
<UnavailableDataNotice title="Tasks" />
|
||||
) : recentTasks.length === 0 ? (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
|
||||
<p className="text-sm text-text-muted">No tasks yet</p>
|
||||
</div>
|
||||
@@ -287,7 +398,9 @@ function OverviewTab({
|
||||
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Missions</h2>
|
||||
{missions.length === 0 ? (
|
||||
{missions === null ? (
|
||||
<UnavailableDataNotice title="Missions" />
|
||||
) : missions.length === 0 ? (
|
||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
|
||||
<p className="text-sm text-text-muted">No missions yet</p>
|
||||
</div>
|
||||
|
||||
@@ -51,6 +51,7 @@ afterEach(async () => {
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
apiMock.mockReset();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
async function renderProjectsPage(): Promise<ReturnType<typeof createMemoryRouter>> {
|
||||
@@ -71,6 +72,22 @@ async function renderProjectsPage(): Promise<ReturnType<typeof createMemoryRoute
|
||||
return router;
|
||||
}
|
||||
|
||||
function clickButtonByText(text: string): void {
|
||||
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
||||
candidate.textContent?.includes(text),
|
||||
);
|
||||
if (!button) {
|
||||
throw new Error(`Button containing "${text}" not found`);
|
||||
}
|
||||
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
}
|
||||
|
||||
async function flushAct(): Promise<void> {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('ProjectsPage', () => {
|
||||
it('shows a visible loading state while the project request is in flight', async () => {
|
||||
const deferred = createDeferred<typeof projectFixtures>();
|
||||
@@ -91,7 +108,7 @@ describe('ProjectsPage', () => {
|
||||
|
||||
const router = await renderProjectsPage();
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/projects');
|
||||
expect(apiMock.mock.calls[0]?.[0]).toBe('/api/projects');
|
||||
expect(container.textContent).toContain('Mosaic Stack');
|
||||
expect(container.textContent).toContain('Agent Runtime');
|
||||
|
||||
@@ -108,7 +125,7 @@ describe('ProjectsPage', () => {
|
||||
expect(container.textContent).toContain('Project detail target');
|
||||
});
|
||||
|
||||
it('renders the empty state when the API returns no projects', async () => {
|
||||
it('renders the empty state only for a verified empty collection', async () => {
|
||||
apiMock.mockResolvedValueOnce([]);
|
||||
|
||||
await renderProjectsPage();
|
||||
@@ -117,9 +134,12 @@ describe('ProjectsPage', () => {
|
||||
expect(container.textContent).toContain(
|
||||
'Projects will appear here when created via the gateway API',
|
||||
);
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'current',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a visible alert when the projects request fails', async () => {
|
||||
it('renders a failed fetch as an explicit unavailable state, never an empty collection', async () => {
|
||||
apiMock.mockRejectedValueOnce(new Error('Projects are unavailable'));
|
||||
|
||||
await renderProjectsPage();
|
||||
@@ -127,5 +147,51 @@ describe('ProjectsPage', () => {
|
||||
const alert = container.querySelector('[role="alert"]');
|
||||
expect(alert).toBeTruthy();
|
||||
expect(alert?.textContent).toContain('Projects are unavailable');
|
||||
expect(alert?.textContent).toContain('not an empty result');
|
||||
|
||||
// Negative controls: no healthy empty state and no project cards render
|
||||
// from a failed fetch.
|
||||
expect(container.textContent).not.toContain('No projects yet');
|
||||
expect(container.textContent).not.toContain('Mosaic Stack');
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders an auth failure as unavailable and recovers after retry', async () => {
|
||||
apiMock
|
||||
.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), { statusCode: 401 }))
|
||||
.mockResolvedValueOnce(projectFixtures);
|
||||
|
||||
await renderProjectsPage();
|
||||
|
||||
const alert = container.querySelector('[role="alert"]');
|
||||
expect(alert?.textContent).toContain('Unauthorized');
|
||||
expect(container.textContent).not.toContain('No projects yet');
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('Retry');
|
||||
});
|
||||
await flushAct();
|
||||
|
||||
expect(container.querySelector('[role="alert"]')).toBeNull();
|
||||
expect(container.textContent).toContain('Mosaic Stack');
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'current',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a schema-mismatched response as unavailable, never as data', async () => {
|
||||
apiMock.mockResolvedValueOnce({ results: projectFixtures });
|
||||
|
||||
await renderProjectsPage();
|
||||
|
||||
const alert = container.querySelector('[role="alert"]');
|
||||
expect(alert?.textContent).toContain('not an empty result');
|
||||
expect(container.textContent).not.toContain('Mosaic Stack');
|
||||
expect(container.textContent).not.toContain('No projects yet');
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'unavailable',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,53 +1,51 @@
|
||||
import { useEffect, useState, type ReactElement } from 'react';
|
||||
import { type ReactElement } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ProjectCard } from '@/components/projects/project-card';
|
||||
import { StaleDataNotice, UnavailableDataNotice } from '@/components/freshness/freshness-notices';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Project } from '@/lib/types';
|
||||
import { getErrorMessage } from './page-errors';
|
||||
import { useFreshCollection, describeFailure } from '@/lib/freshness/use-fresh-collection';
|
||||
import { validateProjectCollection } from '@/lib/freshness/validators';
|
||||
|
||||
export function ProjectsPage(): ReactElement {
|
||||
const navigate = useNavigate();
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void api<Project[]>('/api/projects')
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
setProjects(response);
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (cancelled) return;
|
||||
setError(getErrorMessage(caught, 'Failed to load projects.'));
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
const projects = useFreshCollection<Project[]>({
|
||||
source: 'gateway:/api/projects',
|
||||
fetcher: (signal) => api<unknown>('/api/projects', { signal }),
|
||||
validate: validateProjectCollection,
|
||||
// Projects carry workspace identity (userId) that is only knowable from
|
||||
// the payload itself, so a restored entry cannot be scope-checked before
|
||||
// display. Conservative choice: no last-known restore for this surface;
|
||||
// cross-workspace switching is still invalidated at verification time.
|
||||
});
|
||||
const retry = (): void => {
|
||||
void projects.revalidate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||
<div
|
||||
data-freshness={projects.freshness}
|
||||
className="flex min-h-screen flex-col px-4 py-6 sm:px-6"
|
||||
>
|
||||
<header className="mb-6 border-b px-1 pb-3">
|
||||
<h1 className="text-2xl font-semibold">Projects</h1>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<div role="alert" className="mb-6 rounded-lg border border-error/40 px-4 py-3 text-sm">
|
||||
{error}
|
||||
{projects.freshness === 'stale' && projects.snapshot ? (
|
||||
<div className="mb-6">
|
||||
<StaleDataNotice label={projects.snapshot} onRetry={retry} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
{projects.freshness === 'unknown' ? (
|
||||
<p className="py-8 text-center text-sm text-text-muted">Loading projects...</p>
|
||||
) : projects.length === 0 ? (
|
||||
) : projects.freshness === 'unavailable' ? (
|
||||
<UnavailableDataNotice
|
||||
title="Projects"
|
||||
detail={describeFailure(projects.failure)}
|
||||
onRetry={retry}
|
||||
/>
|
||||
) : projects.data !== null && projects.data.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<h2 className="text-lg font-medium text-text-secondary">No projects yet</h2>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
@@ -56,7 +54,7 @@ export function ProjectsPage(): ReactElement {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
{(projects.data ?? []).map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
|
||||
@@ -3,6 +3,9 @@ import { createRoot, type Root } from 'react-dom/client';
|
||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { taskFixtures } from './page-fixtures';
|
||||
import { acceptSnapshot, DEFAULT_FRESHNESS_POLICY } from '@/lib/freshness/model';
|
||||
import { writeSnapshotCache } from '@/lib/freshness/snapshot-cache';
|
||||
import { validateTaskCollection } from '@/lib/freshness/validators';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn(),
|
||||
@@ -48,6 +51,7 @@ afterEach(async () => {
|
||||
document.body.replaceChildren();
|
||||
root = null;
|
||||
apiMock.mockReset();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
async function renderTasksPage(): Promise<void> {
|
||||
@@ -72,6 +76,13 @@ function clickButtonByText(text: string): void {
|
||||
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
}
|
||||
|
||||
/** Flush pending promise callbacks inside the act environment. */
|
||||
async function flushAct(): Promise<void> {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('TasksPage', () => {
|
||||
it('shows a visible loading state before the tasks request settles', async () => {
|
||||
const deferred = createDeferred<typeof taskFixtures>();
|
||||
@@ -132,7 +143,7 @@ describe('TasksPage', () => {
|
||||
expect(container.textContent).toContain('Wire list and kanban modal interactions');
|
||||
});
|
||||
|
||||
it('renders a visible alert when the tasks request fails', async () => {
|
||||
it('renders a failed fetch as an explicit unavailable state, never an empty healthy board', async () => {
|
||||
apiMock.mockRejectedValueOnce(new Error('Tasks request failed'));
|
||||
|
||||
await renderTasksPage();
|
||||
@@ -140,5 +151,80 @@ describe('TasksPage', () => {
|
||||
const alert = container.querySelector('[role="alert"]');
|
||||
expect(alert).toBeTruthy();
|
||||
expect(alert?.textContent).toContain('Tasks request failed');
|
||||
expect(alert?.textContent).toContain('not an empty result');
|
||||
|
||||
// Negative controls: no board, no healthy empty-state markers, and the
|
||||
// surface is marked unavailable rather than current.
|
||||
expect(container.textContent).not.toContain('Not Started');
|
||||
expect(container.textContent).not.toContain('No tasks');
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
it('recovers to a current board after retrying a failed fetch', async () => {
|
||||
apiMock
|
||||
.mockRejectedValueOnce(new Error('Tasks request failed'))
|
||||
.mockResolvedValueOnce(taskFixtures);
|
||||
|
||||
await renderTasksPage();
|
||||
expect(container.querySelector('[role="alert"]')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
clickButtonByText('Retry');
|
||||
});
|
||||
await flushAct();
|
||||
|
||||
expect(container.querySelector('[role="alert"]')).toBeNull();
|
||||
expect(container.textContent).toContain('Not Started');
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'current',
|
||||
);
|
||||
});
|
||||
|
||||
it('labels restored last-known data as stale with source, version, and age until verified', async () => {
|
||||
// Seed a last-known snapshot fetched five minutes ago; the page must
|
||||
// render it only under an explicit staleness label while the fetch is
|
||||
// still in flight.
|
||||
const restored = acceptSnapshot({
|
||||
value: taskFixtures,
|
||||
validate: validateTaskCollection,
|
||||
previous: null,
|
||||
policy: DEFAULT_FRESHNESS_POLICY,
|
||||
source: 'gateway:/api/tasks',
|
||||
now: Date.now() - 5 * 60_000,
|
||||
});
|
||||
if (restored.outcome !== 'accepted') throw new Error('fixture setup failed');
|
||||
writeSnapshotCache('tasks', restored.snapshot);
|
||||
|
||||
const deferred = createDeferred<typeof taskFixtures>();
|
||||
apiMock.mockReturnValueOnce(deferred.promise);
|
||||
|
||||
await renderTasksPage();
|
||||
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'stale',
|
||||
);
|
||||
const banner = container.querySelector('[role="status"]');
|
||||
expect(banner?.textContent).toContain('last-known');
|
||||
expect(banner?.textContent).toContain('may be out of date');
|
||||
expect(banner?.textContent).toContain('gateway:/api/tasks');
|
||||
expect(banner?.textContent).toContain('snapshot v1');
|
||||
expect(banner?.textContent).toContain('5m ago');
|
||||
|
||||
// Last-known data still renders as situational awareness under the label.
|
||||
expect(container.textContent).toContain('Route /tasks');
|
||||
expect(container.textContent).not.toContain('Loading tasks...');
|
||||
|
||||
// Verification lands: the banner clears and the surface becomes current.
|
||||
await act(async () => {
|
||||
deferred.resolve(taskFixtures);
|
||||
await deferred.promise;
|
||||
});
|
||||
|
||||
expect(container.querySelector('[role="status"]')).toBeNull();
|
||||
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
|
||||
'current',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,45 +1,32 @@
|
||||
import { useEffect, useState, type ReactElement } from 'react';
|
||||
import { useState, type ReactElement } from 'react';
|
||||
import { KanbanBoard } from '@/components/tasks/kanban-board';
|
||||
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
|
||||
import { TaskListView } from '@/components/tasks/task-list-view';
|
||||
import { StaleDataNotice, UnavailableDataNotice } from '@/components/freshness/freshness-notices';
|
||||
import { api } from '@/lib/api';
|
||||
import { cn } from '@/lib/cn';
|
||||
import type { Task } from '@/lib/types';
|
||||
import { getErrorMessage } from './page-errors';
|
||||
import { useFreshCollection, describeFailure } from '@/lib/freshness/use-fresh-collection';
|
||||
import { validateTaskCollection } from '@/lib/freshness/validators';
|
||||
|
||||
type ViewMode = 'list' | 'kanban';
|
||||
|
||||
export function TasksPage(): ReactElement {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const tasks = useFreshCollection<Task[]>({
|
||||
source: 'gateway:/api/tasks',
|
||||
fetcher: (signal) => api<unknown>('/api/tasks', { signal }),
|
||||
validate: validateTaskCollection,
|
||||
cacheKey: 'tasks',
|
||||
});
|
||||
const [view, setView] = useState<ViewMode>('kanban');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void api<Task[]>('/api/tasks')
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
setTasks(response);
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (cancelled) return;
|
||||
setError(getErrorMessage(caught, 'Failed to load tasks.'));
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
const retry = (): void => {
|
||||
void tasks.revalidate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||
<div data-freshness={tasks.freshness} className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||
<header className="mb-6 flex items-center justify-between gap-4 border-b px-1 pb-3">
|
||||
<h1 className="text-2xl font-semibold">Tasks</h1>
|
||||
<div className="flex rounded-lg border border-surface-border">
|
||||
@@ -70,18 +57,24 @@ export function TasksPage(): ReactElement {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<div role="alert" className="mb-6 rounded-lg border border-error/40 px-4 py-3 text-sm">
|
||||
{error}
|
||||
{tasks.freshness === 'stale' && tasks.snapshot ? (
|
||||
<div className="mb-6">
|
||||
<StaleDataNotice label={tasks.snapshot} onRetry={retry} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
{tasks.freshness === 'unknown' ? (
|
||||
<p className="py-8 text-center text-sm text-text-muted">Loading tasks...</p>
|
||||
) : tasks.freshness === 'unavailable' ? (
|
||||
<UnavailableDataNotice
|
||||
title="Tasks"
|
||||
detail={describeFailure(tasks.failure)}
|
||||
onRetry={retry}
|
||||
/>
|
||||
) : view === 'kanban' ? (
|
||||
<KanbanBoard tasks={tasks} onTaskClick={setSelectedTask} />
|
||||
<KanbanBoard tasks={tasks.data ?? []} onTaskClick={setSelectedTask} />
|
||||
) : (
|
||||
<TaskListView tasks={tasks} onTaskClick={setSelectedTask} />
|
||||
<TaskListView tasks={tasks.data ?? []} onTaskClick={setSelectedTask} />
|
||||
)}
|
||||
|
||||
{selectedTask ? (
|
||||
|
||||
+35
-246
@@ -1,14 +1,5 @@
|
||||
# PRD: Mosaic Stack v0.1.0
|
||||
|
||||
## Current addendum: #1194 — Installed framework-tool drift detection
|
||||
|
||||
- Compare the framework tools shipped with the executing Mosaic package against the deployed `$MOSAIC_HOME/tools` tree by content hash.
|
||||
- Treat every shipped `tools/**` file as framework-owned/required according to `framework-manifest.txt`, while excluding the explicit operator-owned credential carve-out and preserving installed-only operator/unknown files.
|
||||
- Distinguish and count `IN_SYNC`, `STALE`, `NOT_INSTALLED`, and installed-only classifications; fail non-zero when shipped tools are stale or absent and refuse self-comparison that would make drift unobservable.
|
||||
- Surface the observational check through `mosaic doctor`; do not refresh files, restart seats, or mutate live tooling.
|
||||
- Document identity/messaging/gate behavior changes in the current stale set, the reviewed quiet-window keep-mode refresh command, and post-refresh probes against the installed path.
|
||||
- Prove by construction that a stale and missing deployed tool are detected; that regression must fail before this checker exists.
|
||||
|
||||
## Metadata
|
||||
|
||||
- **Owner:** Jason Woltje
|
||||
@@ -111,128 +102,6 @@ Context compaction, session replacement, and same-PID runtime reloads can leave
|
||||
|
||||
---
|
||||
|
||||
## Pi Persistent Goal Loop (#1150)
|
||||
|
||||
### Problem and objective
|
||||
|
||||
A Pi agent can stop after a plausible-looking answer even when the operator's broader objective is
|
||||
not complete, and ordinary compaction can weaken or omit the original objective. Mosaic needs an
|
||||
optional, operator-controlled goal loop that keeps a Pi session oriented, checks progress at native
|
||||
lifecycle boundaries, and resumes work until completion is verified or a bounded safety state is
|
||||
reached.
|
||||
|
||||
The objective is a Mosaic-owned Pi extension deployed from the framework into
|
||||
`~/.config/mosaic/runtime/pi/`. It must not install into or depend on `~/.pi/agent/extensions/`.
|
||||
|
||||
### Scope
|
||||
|
||||
#### In scope
|
||||
|
||||
1. `PGL-REQ-01`: The framework SHALL ship a dedicated Pi goal extension under
|
||||
`packages/mosaic/framework/runtime/pi/`, seed it under `$MOSAIC_HOME/runtime/pi/`, and make
|
||||
`mosaic pi` load it alongside the core Mosaic extension when present.
|
||||
2. `PGL-REQ-02`: `/goal` SHALL support setting a goal plus status, pause, resume, cancel, and help
|
||||
operations without silently replacing an active goal.
|
||||
3. `PGL-REQ-03`: Active branch-specific goal state SHALL be persisted in Pi custom session entries,
|
||||
restored on session start and tree navigation, and never rely on a compaction summary as its
|
||||
source of truth.
|
||||
4. `PGL-REQ-04`: A hidden goal contract SHALL be injected through Pi's `context` event before every
|
||||
model request so it remains effective across tool turns, retries, and post-compaction requests.
|
||||
5. `PGL-REQ-05`: The harness SHALL inspect every `turn_end` and successful `session_compact` event.
|
||||
A structured terminating goal-report tool SHALL capture `continue`, evidence-bearing `achieved`,
|
||||
or `blocked` status without requiring a redundant model turn.
|
||||
6. `PGL-REQ-06`: An achievement claim SHALL remain provisional until a second consecutive
|
||||
evidence-bearing verification report. Any continuation report or successful compaction during
|
||||
verification SHALL reset the verification sequence.
|
||||
7. `PGL-REQ-07`: Continuation SHALL be initiated at safe lifecycle boundaries, primarily
|
||||
`agent_settled`; manual compaction and restored active sessions may schedule a deferred idle
|
||||
continuation without re-entering compaction handlers.
|
||||
8. `PGL-REQ-08`: The loop SHALL have operator cancellation plus bounded turn and repeated-no-progress
|
||||
limits. Exhausted or blocked goals pause rather than continuing indefinitely.
|
||||
9. `PGL-REQ-09`: Framework installation and update SHALL preserve normal manifest ownership: the
|
||||
goal extension is framework-owned under `runtime/**`, while no goal extension or configuration
|
||||
asset is created or modified under the operator's main Pi configuration. Pi remains the owner of
|
||||
its native session files used by `appendEntry()`.
|
||||
|
||||
#### Out of scope
|
||||
|
||||
1. A mathematical guarantee that an arbitrary natural-language goal is semantically complete.
|
||||
2. Automatically executing user-supplied shell predicates or accepting executable validation code in
|
||||
`/goal` arguments.
|
||||
3. Restarting Pi after process, host, or supervisor failure; the existing Mosaic fleet/runtime
|
||||
supervisor owns process durability.
|
||||
4. Gateway, database, web UI, Discord, or cross-harness goal orchestration in this slice.
|
||||
|
||||
### User and stakeholder requirements
|
||||
|
||||
- An operator can start a goal from Pi and see its current phase, evidence, limits, and latest report.
|
||||
- The agent remains oriented after each turn and compaction until verified, paused, blocked,
|
||||
exhausted, or cancelled.
|
||||
- Local testing uses a file under `~/.config/mosaic/runtime/pi/`; the feature never writes an
|
||||
extension asset to `~/.pi/agent/extensions/`.
|
||||
- Framework updates deploy the same reviewed extension source through Mosaic's existing manifest
|
||||
sync path.
|
||||
|
||||
### Non-functional requirements
|
||||
|
||||
1. **Safety:** bounded continuation, explicit cancellation, no arbitrary command execution, and no
|
||||
completion without non-empty reported evidence.
|
||||
2. **Reliability:** serialized continuation scheduling, branch-aware restoration, compaction-safe
|
||||
context injection, and stale-timer cancellation on session shutdown.
|
||||
3. **Performance:** no extra nested judge-model request on every turn; structured reporting uses the
|
||||
active agent's final terminating tool call.
|
||||
4. **Observability:** Pi status/notifications expose phase and bounded counters without recording
|
||||
credentials or hidden model reasoning.
|
||||
5. **Maintainability:** the state machine is deterministic and behavior-tested independently from Pi
|
||||
provider/network access.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
1. `AC-PGL-01`: A framework-sync fixture installs the extension at
|
||||
`$MOSAIC_HOME/runtime/pi/goal-extension.ts`, and launcher tests prove both Mosaic Pi extensions are
|
||||
emitted in deterministic order while absent optional files remain backward-compatible.
|
||||
2. `AC-PGL-02`: Command tests prove set/status/pause/resume/cancel behavior, active-goal replacement
|
||||
refusal, and bounded input handling.
|
||||
3. `AC-PGL-03`: Lifecycle tests prove every turn is recorded, active context is injected on every
|
||||
request, two evidence-bearing achievement reports are required, and `agent_settled` continues an
|
||||
unmet goal without duplicate scheduling.
|
||||
4. `AC-PGL-04`: Compaction and restoration tests prove goal state survives, verification is reset and
|
||||
rechecked after compaction, manual compaction continuation is deferred until idle, and tree/session
|
||||
branch state is reconstructed correctly.
|
||||
5. `AC-PGL-05`: Limit tests prove max-turn and repeated-no-progress exhaustion stop autonomous
|
||||
continuation, while pause/cancel/blocked states do not restart.
|
||||
6. `AC-PGL-06`: Focused tests, package typecheck/lint/test, repository quality gates, a local Pi load
|
||||
smoke test from `~/.config/mosaic/runtime/pi/`, independent review, and terminal-green CI pass before
|
||||
issue #1150 closes.
|
||||
|
||||
### Constraints, risks, and assumptions
|
||||
|
||||
- Dependency: Pi's extension API must continue to provide `registerCommand`, `registerTool`,
|
||||
`context`, `turn_end`, `agent_settled`, `session_compact`, session custom entries, and terminating
|
||||
tool results.
|
||||
- Risk: the working agent can overstate completion. Mitigation: structured evidence, a mandatory
|
||||
second verification pass, explicit semantic limitations, and operator-visible reports.
|
||||
- Risk: an impossible goal can consume unbounded resources. Mitigation: hard turn/no-progress bounds
|
||||
and paused terminal states.
|
||||
- Risk: automatic continuation can race compaction or session replacement. Mitigation: drive from
|
||||
`agent_settled`, defer idle restarts, generation-check timers, and clear timers on shutdown.
|
||||
- `ASSUMPTION:` Two consecutive evidence-bearing reports are the initial local verification policy;
|
||||
rationale: it provides a real recheck without doubling every turn's model cost. Future policy may
|
||||
add independent or deterministic validators.
|
||||
- `ASSUMPTION:` Default limits are 40 turns and 6 repeated no-progress reports, configurable only by
|
||||
bounded Mosaic environment settings; rationale: useful persistence with a finite autonomous budget.
|
||||
- `ASSUMPTION:` Documentation remains canonical in-repo for this slice; no external docs publication
|
||||
is requested.
|
||||
|
||||
### Testing and delivery intent
|
||||
|
||||
Use TDD for the deterministic controller and lifecycle invariants. Test with fake Pi lifecycle
|
||||
objects first, then run a local load/smoke test from the deployed Mosaic path. Deliver source, tests,
|
||||
launcher wiring, framework/runtime documentation, user/developer guides, and sitemap updates in one
|
||||
reviewed squash PR to `main` with terminal-green CI.
|
||||
|
||||
---
|
||||
|
||||
## Fleet Declarative Configuration Management Workstream (FCM, #758)
|
||||
|
||||
### Problem and objective
|
||||
@@ -277,68 +146,6 @@ lands. M0 consists only of these normative requirements, the complete task DAG,
|
||||
documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards
|
||||
are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
|
||||
|
||||
### Fleet git identity launch propagation (#1043)
|
||||
|
||||
#### Problem and objective
|
||||
|
||||
A fleet seat can have a registered per-agent Git credential while its launched runtime process lacks
|
||||
`MOSAIC_GIT_IDENTITY`. The credential resolver then cannot select the seat identity reliably, which
|
||||
blocks repository operations on fail-closed estates and can fall through to an unrelated identity on
|
||||
estates where that refusal is not active. The objective is to make Git identity a deterministic,
|
||||
roster-derived part of the generated launch projection and prove it reaches the launched process.
|
||||
|
||||
#### Normative requirements
|
||||
|
||||
1. `FGI-REQ-01`: Every generated fleet agent projection SHALL declare
|
||||
`MOSAIC_GIT_IDENTITY=<MOSAIC_AGENT_NAME>`; a differing or unsafe identity SHALL fail closed before
|
||||
tmux launch.
|
||||
2. `FGI-REQ-02`: The clean `/usr/bin/env -i` pane boundary SHALL pass every variable declared by the
|
||||
generated projection, including `MOSAIC_GIT_IDENTITY`, to the launched runtime process.
|
||||
3. `FGI-REQ-03`: A behavioral integration test SHALL set-compare the complete generated projection
|
||||
against the launched process environment. Source-text/string-presence assertions are insufficient.
|
||||
4. `FGI-REQ-04`: Verification SHALL include RED-first evidence and a delete-the-subject mutation that
|
||||
removes Git-identity pane propagation and makes the behavioral test fail.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
1. `AC-FGI-01`: A launched seat process contains every key/value pair declared by its generated
|
||||
environment projection, including the roster-derived Git identity.
|
||||
2. `AC-FGI-02`: Missing, unsafe, or split Git identity is rejected before a tmux session is created.
|
||||
3. `AC-FGI-03`: Focused launcher and generated-environment tests, repository quality gates,
|
||||
independent review, and the required RED/green/R7 evidence are recorded before push.
|
||||
|
||||
### Framework shell assertion portability (#1098)
|
||||
|
||||
#### Problem and objective
|
||||
|
||||
The blocking framework-shell chain can report that a pane command omitted `/usr/bin/env -i` even when
|
||||
`-i` matched successfully. A short-circuiting `grep -q` under `set -o pipefail` may close its pipe after
|
||||
the match and cause an upstream producer to exit with SIGPIPE, turning a valid semantic result into a
|
||||
nonzero aggregate pipeline. The objective is to inspect the captured NUL-delimited argv directly and
|
||||
make failures carry the observed records needed for diagnosis.
|
||||
|
||||
#### Normative requirements
|
||||
|
||||
1. `FSP-REQ-01`: The pane-boundary test SHALL validate an adjacent `/usr/bin/env`, `-i` argv pair from
|
||||
the authoritative NUL-delimited tmux capture without a short-circuit pipeline whose upstream status
|
||||
can override a successful match.
|
||||
2. `FSP-REQ-02`: Missing, reversed, or non-adjacent boundary tokens SHALL fail, while valid boundaries
|
||||
SHALL remain valid regardless of trailing argv size, pipe capacity, process scheduling, or host/CI
|
||||
utility implementation.
|
||||
3. `FSP-REQ-03`: A failed boundary check SHALL print stable indexed, shell-escaped observed argv records
|
||||
before exiting nonzero; the fixture SHALL continue to contain generated non-secret launch data only.
|
||||
4. `FSP-REQ-04`: Verification SHALL include RED-first large-payload evidence, negative token-order
|
||||
controls, the complete focused launcher suite, canonical Woodpecker CI, and independent review.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
1. `AC-FSP-01`: A large captured argv with adjacent `/usr/bin/env`, `-i` passes even when the former
|
||||
`grep -q` pipeline returns nonzero from an upstream SIGPIPE.
|
||||
2. `AC-FSP-02`: Missing executable, missing flag, and detached/reversed flag fixtures return nonzero and
|
||||
emit the indexed observed argv.
|
||||
3. `AC-FSP-03`: The focused suite passes on the development host and CI image, and the merged-main
|
||||
Woodpecker pipeline is terminal green before #1098 closes.
|
||||
|
||||
---
|
||||
|
||||
## Exact Cross-Harness Fleet Communications Contract (#766)
|
||||
@@ -1538,59 +1345,6 @@ All work is **alpha** (< 0.1.0) until Jason approves 0.1.0 beta release.
|
||||
|
||||
---
|
||||
|
||||
## Workspace placement guard hardening (#1174)
|
||||
|
||||
### Problem and objective
|
||||
|
||||
The Bash pre-tool guard must prevent Git checkouts and repository state from being placed under
|
||||
`$HOME` without refusing ordinary Git commands merely because a source, option value, branch name,
|
||||
or metadata mentions `$HOME`. A guard that over-blocks routine work is unsafe because operators
|
||||
will route around it.
|
||||
|
||||
### Scope and requirements
|
||||
|
||||
1. `WPG-REQ-01`: `git clone` and `git worktree add` placement SHALL be judged from their placement
|
||||
operands, not from every HOME-shaped word in the command.
|
||||
2. `WPG-REQ-02`: Clone sources, references, templates, environment assignments, and non-placement
|
||||
worktree metadata MAY resolve under HOME when all placement operands resolve elsewhere.
|
||||
3. `WPG-REQ-03`: Both attached and separate-value `--separate-git-dir` forms SHALL remain placement
|
||||
operands and SHALL be refused when they resolve under HOME.
|
||||
4. `WPG-REQ-04`: Option classification SHALL account for Git's rule-generated boolean negations
|
||||
without relying on an enumerable allowlist of flag spellings.
|
||||
5. `WPG-REQ-05`: Quote removal, escapes, shell command boundaries, redirections, and end-of-options
|
||||
handling SHALL preserve existing fail-closed checkout coverage.
|
||||
6. `WPG-REQ-06`: Absolute placement aliases SHALL resolve shell-known HOME spellings, dot segments,
|
||||
repeated separators, and existing symlink parents before the HOME boundary comparison.
|
||||
7. Relative targets whose effective path depends on the shell cwd are out of scope and tracked by
|
||||
#1197.
|
||||
|
||||
### Acceptance and verification
|
||||
|
||||
1. Git's own option parser accepts each tested flag, including generated `--no-*` forms, while the
|
||||
guard allows a HOME-valued source with an explicit safe destination.
|
||||
2. Equivalent clone and worktree fixtures cover rule-generated negations and remain discriminating
|
||||
against the prior head where the defect existed.
|
||||
3. Real HOME destinations and both `--separate-git-dir` forms remain blocked, including placements
|
||||
after shell command boundaries.
|
||||
4. The full hermetic guard suite, syntax/static checks, adversarial probes, independent review, and
|
||||
terminal-green CI pass before merge.
|
||||
5. Any option-classification residual is documented with its deliberate failure direction.
|
||||
|
||||
### Constraints, risks, and assumptions
|
||||
|
||||
- Security and usability are co-equal: neither a placement bypass nor routine over-block is an
|
||||
acceptable repair.
|
||||
- `ASSUMPTION:` The value-taking option surface exposed by the installed Git version is closed and
|
||||
measurable through Git's own parser/help output; rationale: boolean flags are rule-generated,
|
||||
while separate-value options have explicit grammar and must be classified as such.
|
||||
- Risk: a future Git release may add a new value-taking placement option. Mitigation: document the
|
||||
chosen residual direction and pin every currently supported placement option in behavior tests.
|
||||
- Risk: a symlink can be replaced after pre-execution canonicalization. Mitigation: resolve every
|
||||
existing parent physically and document the remaining inherent TOCTOU window; the worktree helper
|
||||
remains the authoritative path-derivation mechanism, with atomic closure tracked by #1199.
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. RESOLVED: **pgvector is sufficient** for semantic search at v0.1.0 scale (personal/family/team = thousands to low hundreds-of-thousands of vectors). `@mosaicstack/memory` defines a `VectorStore` interface with pgvector as the default adapter. The interface boundary makes Qdrant a drop-in migration if PG resource contention or scale demands it later. Zero additional infrastructure for v0.1.0. Rationale: Reduces ops burden; pgvector HNSW indexes are fast at this scale; interface abstraction costs almost nothing now.
|
||||
@@ -1614,3 +1368,38 @@ will route around it.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -19,15 +19,6 @@
|
||||
- [MVP mission manifest](MISSION-MANIFEST.md) — control-plane mission rollup; activity and status remain under its authorized owner.
|
||||
- [Documentation catalog and truth audit](reports/documentation/2026-08-10-docs-catalog-audit.md) — complete baseline inventory, evidence labels, broken-link clusters, and migration recommendations.
|
||||
|
||||
## Pi persistent goals
|
||||
|
||||
- [Persistent goal user guide](_old_structure/guides/user-guide.md#pi-persistent-goals) — `/goal` commands, verification behavior, limits, compaction/resume semantics, and limitations.
|
||||
- [Goal extension developer guide](guides/dev-guide.md#pi-persistent-goal-extension) — framework ownership, launcher ordering, lifecycle design, tests, and local Mosaic-path smoke workflow.
|
||||
- [Goal loop operations](_old_structure/guides/admin-guide.md#pi-goal-loop-operations) — deployment ownership, bounded settings, pause/resume procedures, and supervisor boundary.
|
||||
- [Pi runtime reference](../packages/mosaic/framework/runtime/pi/RUNTIME.md#extensions) — deployed paths, command summary, and bounded environment settings.
|
||||
|
||||
## Fleet configuration management
|
||||
|
||||
## Protected current authority and executable books
|
||||
|
||||
These paths remain canonical because current source/tests consume them or because the KBN authority process protects them. Relocation requires an explicitly coordinated authority and consumer migration, not documentation-only cleanup.
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
3. [Provider Configuration](#provider-configuration)
|
||||
4. [MCP Server Configuration](#mcp-server-configuration)
|
||||
5. [Environment Variables Reference](#environment-variables-reference)
|
||||
6. [Pi Goal Loop Operations](#pi-goal-loop-operations)
|
||||
7. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
6. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -265,16 +264,6 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
|
||||
| `AGENT_SYSTEM_PROMPT` | — | Platform-level system prompt injected into all sessions |
|
||||
| `AGENT_USER_TOOLS` | all tools | Comma-separated allowlist of tools for non-admin users |
|
||||
|
||||
### Mosaic Pi goal loop
|
||||
|
||||
| Variable | Default | Description |
|
||||
| ----------------------------- | ------- | -------------------------------------------------------------------- |
|
||||
| `MOSAIC_GOAL_MAX_TURNS` | `40` | Per-goal autonomous turn limit; accepted range `1..500` |
|
||||
| `MOSAIC_GOAL_MAX_NO_PROGRESS` | `6` | Consecutive identical progress-report limit; accepted range `1..100` |
|
||||
|
||||
These variables are consumed by the framework-owned Pi goal extension at goal creation. Invalid or
|
||||
out-of-range values fall back to the defaults; they do not disable the bounds.
|
||||
|
||||
### Providers
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -385,29 +374,3 @@ Session cleanup is scoped to one session identifier and only removes that sessio
|
||||
| Variable | Default | Description |
|
||||
| ----------------------- | ----------------------------- | ------------------------------------------ |
|
||||
| `MOSAIC_WORKSPACE_ROOT` | monorepo root (auto-detected) | Root path for mission workspace operations |
|
||||
|
||||
---
|
||||
|
||||
## Pi Goal Loop Operations
|
||||
|
||||
The reviewed runtime asset is deployed at
|
||||
`~/.config/mosaic/runtime/pi/goal-extension.ts` by framework install/update. Do not install another
|
||||
copy under `~/.pi/agent/extensions/`; duplicate registration can create suffixed commands and two
|
||||
competing lifecycle controllers.
|
||||
|
||||
Operational checks:
|
||||
|
||||
1. Run `mosaic pi` and verify `/goal help` is available.
|
||||
2. Use `/goal status` to inspect phase, turn/no-progress limits, compaction checks, and evidence.
|
||||
Reports persist in Pi session data; controller-owned state redacts common credential shapes, but
|
||||
Pi's model/tool-call history is separate. Operators must not place secrets or raw sensitive output
|
||||
in goals, pause reasons, or evidence.
|
||||
3. Use `/goal pause <reason>` before planned maintenance or manual investigation. Pause and cancel
|
||||
abort the current goal-driven run when Pi is busy.
|
||||
4. Use `/goal resume` only after addressing a blocker; counters restart with the configured bounds.
|
||||
5. Use `/goal cancel` before replacing an unfinished goal.
|
||||
|
||||
A blocked or exhausted goal remains stopped and visible; Mosaic does not automatically raise its
|
||||
limits or restart the process. Framework sync owns file deployment, while Pi's native session file
|
||||
owns branch replay. Process/host restart remains the responsibility of the existing runtime or fleet
|
||||
supervisor.
|
||||
|
||||
@@ -8,10 +8,9 @@
|
||||
4. [Tasks](#tasks)
|
||||
5. [Settings](#settings)
|
||||
6. [CLI Usage](#cli-usage)
|
||||
7. [Pi Persistent Goals](#pi-persistent-goals)
|
||||
8. [Sub-package Commands](#sub-package-commands)
|
||||
9. [Telemetry](#telemetry)
|
||||
10. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
7. [Sub-package Commands](#sub-package-commands)
|
||||
8. [Telemetry](#telemetry)
|
||||
9. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -318,57 +317,6 @@ mosaic prdy
|
||||
mosaic quality-rails
|
||||
```
|
||||
|
||||
## Pi Persistent Goals
|
||||
|
||||
`mosaic pi` loads a Mosaic-owned goal extension from
|
||||
`~/.config/mosaic/runtime/pi/goal-extension.ts`. It is deliberately not installed in
|
||||
`~/.pi/agent/extensions/`; framework installation and updates manage it with the rest of the Mosaic
|
||||
runtime assets.
|
||||
|
||||
Start Pi, then set a goal:
|
||||
|
||||
```text
|
||||
/goal set Deliver the feature, tests, documentation, and verification evidence
|
||||
# Shorthand:
|
||||
/goal Deliver the feature, tests, documentation, and verification evidence
|
||||
```
|
||||
|
||||
Control and inspect the loop with:
|
||||
|
||||
| Command | Behavior |
|
||||
| ---------------------- | ------------------------------------------------------------------ |
|
||||
| `/goal status` | Show phase, limits, compaction checks, latest report, and evidence |
|
||||
| `/goal pause [reason]` | Stop autonomous continuation while preserving the goal |
|
||||
| `/goal resume` | Resume with fresh turn and no-progress counters |
|
||||
| `/goal cancel` | Cancel the goal and remove its active status |
|
||||
| `/goal help` | Show command help |
|
||||
|
||||
While a goal is active, Mosaic injects its contract before every Pi model request and checks every
|
||||
completed model/tool turn. The agent ends each work cycle with the structured
|
||||
`mosaic_goal_report` tool. `achieved` is provisional until a second consecutive report rechecks the
|
||||
whole goal with evidence. A continuation report or a successful compaction resets provisional
|
||||
verification.
|
||||
|
||||
Goal statements and reports are stored in Pi session data. Mosaic redacts common credential shapes
|
||||
before appending its goal-state entries and before goal tool output or `/goal status`, but
|
||||
pattern-based redaction is not a secret store. Pi's own model-message and tool-call records are
|
||||
outside that redactor. Never put tokens, passwords, private keys, connection strings, or raw
|
||||
sensitive output in a goal or report; cite the command, artifact, and pass/fail result instead.
|
||||
|
||||
The loop stops instead of running forever when it is paused, blocked, cancelled, verified, reaches
|
||||
its turn limit, or repeats the same no-progress report too many times. Defaults are 40 turns and 6
|
||||
repeated no-progress reports. Operators may lower or raise them within enforced bounds before
|
||||
launching Pi:
|
||||
|
||||
```bash
|
||||
MOSAIC_GOAL_MAX_TURNS=60 MOSAIC_GOAL_MAX_NO_PROGRESS=8 mosaic pi
|
||||
```
|
||||
|
||||
Goal state is branch-specific Pi session data. It survives compaction and session resume, but Pi's
|
||||
process still must be relaunched or supervised after a process/host failure. This initial verifier
|
||||
checks structured evidence twice; it cannot mathematically prove every arbitrary natural-language
|
||||
goal. Use explicit acceptance criteria and inspect `/goal status` for consequential work.
|
||||
|
||||
---
|
||||
|
||||
### Claude Code Skill Registration
|
||||
|
||||
+10
-13
@@ -5,14 +5,14 @@ Generated environment files are rebuildable projections, not an operator-editabl
|
||||
|
||||
## Launch chain
|
||||
|
||||
| Layer | Responsibility |
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket; Git identity is derived from the exact agent name. |
|
||||
| Projection writer | Renders deterministic fleet/agents/<name>.env.generated from the roster. |
|
||||
| Optional local data | Reads a strict, data-only fleet/agents/<name>.env.local; it cannot shadow generated keys. |
|
||||
| systemd | Starts the launcher with env -i and fixed bootstrap data. It does not preload either environment file. |
|
||||
| session launcher | Validates generated and local data before it queries, creates, or stops an exact tmux session. |
|
||||
| runtime launch | Derives the fixed mosaic yolo <runtime> argument array from validated roster data, then seeds the runtime contract. |
|
||||
| Layer | Responsibility |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket. |
|
||||
| Projection writer | Renders deterministic fleet/agents/<name>.env.generated from the roster. |
|
||||
| Optional local data | Reads a strict, data-only fleet/agents/<name>.env.local; it cannot shadow generated keys. |
|
||||
| systemd | Starts the launcher with env -i and fixed bootstrap data. It does not preload either environment file. |
|
||||
| session launcher | Validates generated and local data before it queries, creates, or stops an exact tmux session. |
|
||||
| runtime launch | Derives the fixed mosaic yolo <runtime> argument array from validated roster data, then seeds the runtime contract. |
|
||||
|
||||
The launcher never `source`s or `eval`s an environment file and never accepts an environment-supplied
|
||||
command. `MOSAIC_AGENT_COMMAND`, command/channel overrides, unknown keys, generated-key shadowing,
|
||||
@@ -24,7 +24,6 @@ secret-like key names, duplicate keys, comments, quoted/export syntax, and unsaf
|
||||
|
||||
```dotenv
|
||||
MOSAIC_AGENT_NAME=<roster name>
|
||||
MOSAIC_GIT_IDENTITY=<roster name>
|
||||
MOSAIC_AGENT_CLASS=<roster class>
|
||||
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
||||
MOSAIC_AGENT_MODEL=<roster model hint>
|
||||
@@ -34,10 +33,8 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||
```
|
||||
|
||||
`MOSAIC_GIT_IDENTITY` is not independently configurable: it must equal `MOSAIC_AGENT_NAME`, preventing
|
||||
split runtime and repository identity authority. The generated launch contract supports `claude`,
|
||||
`codex`, `opencode`, and `pi`. mosaic fleet add rejects another runtime before it writes the roster or
|
||||
modifies generated, local, or quarantine state.
|
||||
The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. mosaic fleet add
|
||||
rejects another runtime before it writes the roster or modifies generated, local, or quarantine state.
|
||||
The legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket;
|
||||
it has no generated-launch adapter and cannot be added through this path.
|
||||
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
The launcher consumes validated data, not shell configuration.
|
||||
|
||||
1. Read and validate the canonical roster.
|
||||
2. Render deterministic <name>.env.generated data from that roster, including `MOSAIC_GIT_IDENTITY` derived exactly from the roster agent name.
|
||||
2. Render deterministic <name>.env.generated data from that roster.
|
||||
3. Parse optional <name>.env.local through a strict allowlist.
|
||||
4. Reject generated-key shadowing, unknown or sensitive-looking keys, unsafe paths/values, duplicates, malformed lines, shell syntax, and command overrides.
|
||||
5. Reject a Git identity that is unsafe or differs from the generated agent name.
|
||||
6. Derive the runtime command from validated runtime/model/reasoning data and pass every generated projection entry through the clean process environment boundary.
|
||||
7. Target only the exact configured tmux socket and roster session after ownership checks.
|
||||
5. Derive the runtime command from validated runtime/model/reasoning data.
|
||||
6. Target only the exact configured tmux socket and roster session after ownership checks.
|
||||
|
||||
## File precedence and ownership
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ values, credential material, or command text.
|
||||
|
||||
```dotenv
|
||||
MOSAIC_AGENT_NAME=<roster name>
|
||||
MOSAIC_GIT_IDENTITY=<roster name>
|
||||
MOSAIC_AGENT_CLASS=<roster class>
|
||||
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
||||
MOSAIC_AGENT_MODEL=<roster model hint>
|
||||
@@ -45,9 +44,8 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||
```
|
||||
|
||||
`MOSAIC_GIT_IDENTITY` is derived from and must equal `MOSAIC_AGENT_NAME`; it is not a separate
|
||||
operator-controlled identity authority. The generated launch contract supports only `claude`, `codex`,
|
||||
`opencode`, and `pi`. fleet add uses that same runtime authority and rejects any other runtime before it writes the roster or changes
|
||||
The generated launch contract supports only `claude`, `codex`, `opencode`, and `pi`. fleet add
|
||||
uses that same runtime authority and rejects any other runtime before it writes the roster or changes
|
||||
projection, local, or quarantine files. The legacy dogfood stub on its separate `mosaic-factory`
|
||||
socket remains an observability canary; it has no generated-launch adapter and cannot be added through
|
||||
this projection path.
|
||||
|
||||
@@ -9,9 +9,8 @@
|
||||
5. [Adding New MCP Tools](#adding-new-mcp-tools)
|
||||
6. [Database Schema and Migrations](#database-schema-and-migrations)
|
||||
7. [Claude Code Skill Bridge](#claude-code-skill-bridge)
|
||||
8. [Pi Persistent Goal Extension](#pi-persistent-goal-extension)
|
||||
9. [API Endpoint Reference](#api-endpoint-reference)
|
||||
10. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
8. [API Endpoint Reference](#api-endpoint-reference)
|
||||
9. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -397,85 +396,6 @@ M1 intentionally manages Claude Code only. Pi's Mosaic launcher can discover the
|
||||
canonical root directly. Codex still relies on the existing full skill-sync
|
||||
linker and needs separate parity analysis before this lifecycle API is extended.
|
||||
|
||||
## Pi Persistent Goal Extension
|
||||
|
||||
The source of the Mosaic-owned Pi goal controller is:
|
||||
|
||||
```text
|
||||
packages/mosaic/framework/runtime/pi/goal-extension.ts
|
||||
```
|
||||
|
||||
The framework manifest classifies `runtime/**` as framework-owned. Both the bash installer and the
|
||||
TypeScript file adapter therefore deploy the same reviewed source to:
|
||||
|
||||
```text
|
||||
$MOSAIC_HOME/runtime/pi/goal-extension.ts
|
||||
# default: ~/.config/mosaic/runtime/pi/goal-extension.ts
|
||||
```
|
||||
|
||||
Do not copy or link this extension into `~/.pi/agent/extensions/`. The launcher function
|
||||
`discoverPiExtensionArgs()` emits the core `mosaic-extension.ts` first and the optional
|
||||
`goal-extension.ts` second, preserving compatibility with an older installed framework that does
|
||||
not have the goal file yet.
|
||||
|
||||
### Lifecycle design
|
||||
|
||||
| Pi API | Goal-controller responsibility |
|
||||
| ------------------------------ | --------------------------------------------------------------------------------- |
|
||||
| `registerCommand('goal')` | Set, inspect, pause, resume, or cancel one branch-specific goal |
|
||||
| `registerTool(...)` | Record a terminating structured progress report with evidence |
|
||||
| `context` | Inject the active goal contract before every provider request |
|
||||
| `turn_end` | Record every turn, reject mixed final reports, and enforce the turn bound |
|
||||
| `agent_settled` | Start one deduplicated continuation only after Pi has no retry/compact/queue work |
|
||||
| `session_compact` | Record the compact check, reset provisional verification, and defer idle work |
|
||||
| `session_start`/`session_tree` | Rebuild state from custom entries on the active branch |
|
||||
| `session_shutdown` | Invalidate deferred callbacks and clear UI state |
|
||||
|
||||
State is appended as `mosaic-goal-state` custom entries, which do not enter model context. The
|
||||
`context` hook creates a fresh hidden `mosaic-goal-context` message for each request instead of
|
||||
trusting compaction summaries. The `mosaic_goal_report` result uses `terminate: true`; when it is the
|
||||
sole final tool call, Pi avoids an unnecessary model response before the controller decides whether
|
||||
to verify, continue, or stop.
|
||||
|
||||
Before state is appended or displayed, the controller applies bounded credential-pattern redaction
|
||||
to the goal statement, report summary/evidence/next step, and stop reason. Fingerprints are computed
|
||||
over redacted report content. Pi session entries are append-only, so a credential-bearing legacy
|
||||
entry cannot honestly be erased by the extension: restoration fails closed, emits a warning, and
|
||||
requires removal of the affected session before setting a new goal. This is defense-in-depth rather
|
||||
than a secret-storage contract, and it does not rewrite Pi's separate model-message/tool-call
|
||||
history. Goal prompts tell the agent not to submit credentials or raw sensitive output, and tests use
|
||||
canaries to prove known forms do not reach new custom entries, status text, context, or tool details
|
||||
while ordinary typed fields such as `token: string` remain intact.
|
||||
|
||||
Completion remains evidence-gated but semantic: two consecutive `achieved` reports are required,
|
||||
and the second run is explicitly a verification pass. This avoids an extra judge-model request after
|
||||
every turn. Deterministic validator commands are intentionally not accepted as `/goal` input in this
|
||||
slice, so never describe this mechanism as proof of arbitrary natural-language completion.
|
||||
|
||||
### Tests and local smoke workflow
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/mosaic exec vitest run \
|
||||
src/runtime/pi-goal-extension.spec.ts \
|
||||
src/commands/launch.spec.ts \
|
||||
src/config/file-adapter.test.ts
|
||||
|
||||
bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
|
||||
```
|
||||
|
||||
For an additive local smoke test without reseeding unrelated live framework files:
|
||||
|
||||
```bash
|
||||
install -D -m 0644 \
|
||||
packages/mosaic/framework/runtime/pi/goal-extension.ts \
|
||||
~/.config/mosaic/runtime/pi/goal-extension.ts
|
||||
|
||||
pi --extension ~/.config/mosaic/runtime/pi/goal-extension.ts
|
||||
```
|
||||
|
||||
Use `/goal help`, `/goal set ...`, and `/goal status` in that test session. A released framework
|
||||
sync installs the file, and a released Mosaic CLI loads it automatically through `mosaic pi`.
|
||||
|
||||
## API Endpoint Reference
|
||||
|
||||
All endpoints are served by the gateway at `http://localhost:14242` by default.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# 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 | done | 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 | PR #1302 (head e06a47fac591): CI green (2484), independent review APPROVED (Gitea review 187, fargo seat, 2026-08-18) — 54 rows / 21 canonical checks / dispositions 43-2-9-0 verified by row-count and code spot-checks. Merged by fargo at pinned head. |
|
||||
| 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,186 @@
|
||||
# Quality-Rails Probe Inventory — RI-3-001
|
||||
|
||||
- **Task:** RI-3-001 (SDLC-D-037 first half; PRD § Release Integrity Workstream, RI-N4)
|
||||
- **Date:** 2026-08-18
|
||||
- **Base:** `origin/next` @ `8199261c` (branch `docs/ri-050-qr-probe-inventory`)
|
||||
- **Follow-up:** RI-3-002 consumes the dispositions here when building the single TS evaluator.
|
||||
|
||||
## 0. Scope and method
|
||||
|
||||
Every mechanism in this repository that verifies a quality, integrity, safety, or release
|
||||
property — TypeScript checks, shell probes, pipeline steps, git hooks, and installer-side
|
||||
assertions — gets one row. Each row's "what it actually verifies" was written from the
|
||||
probe's **code**, not its name or docs. Framework tool unit/regression suites (git wrappers,
|
||||
wake, tmux, orchestrator, …) are treated as one enforcement surface (`test:framework-shell`)
|
||||
because they test tool behavior rather than repo quality; their wiring integrity is itself
|
||||
guarded by `check-test-enumeration.sh`, and the quality-relevant members are rowed
|
||||
individually.
|
||||
|
||||
**Kinds:** `ts` (TypeScript/Node check), `shell` (bash/python probe), `pipeline-step`
|
||||
(exists only inside a Woodpecker pipeline).
|
||||
|
||||
**Enforcement points:** `local` (operator-invoked), `pre-commit`, `pre-push`,
|
||||
`CI ci.yml#<step>`, `publish.yml#<step>` (CI on push to main/next), `turbo <task>`,
|
||||
`agent-runtime` (framework hooks on an agent host), `installer` (host install path),
|
||||
`unwired`.
|
||||
|
||||
**Dispositions** (recommendations for RI-3-002): `preserve` (keep as-is; already the
|
||||
canonical or a correct guard-of-the-guard), `strengthen` (keep, but a concrete gap must
|
||||
close — usually absorption into the TS evaluator), `strengthen (review)` (viable retirement
|
||||
candidate once the evaluator absorbs it; do not retire yet). Note: RI-N4 requires that
|
||||
effective shell probes be **absorbed before** their independent paths retire — no row here
|
||||
is marked `retire` because no absorption exists yet.
|
||||
|
||||
## 1. Inventory
|
||||
|
||||
### 1.1 Repo-level gate tasks (pnpm / turbo)
|
||||
|
||||
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
|
||||
| ------------------------------------- | ------------------------------------------------------------------------------------ | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `pnpm preflight` (checkout preflight) | `scripts/preflight.mjs` | ts | Six gate binaries (eslint, husky, prettier, tsc, turbo, vitest) exist and are executable in `node_modules/.bin` (exit 42 if not); no stale `.mosaic-test-work/web-build.lock` (exit 43); `apps/web/.next` is a real directory (not a symlink), every entry owned by the current uid, and its `.mosaic-source-hash` fingerprint + `.mosaic-symlink-manifest` hash match the certified build written by `scripts/build-web.mjs` | `pre-push`; inside `pnpm typecheck` (→ `CI ci.yml#typecheck`, verify-release `typecheck` stage) | QC-1 Checkout integrity | preserve | Blocks a poisoned/stale generated `.next` from faking a green typecheck (the five-month-stale-`.next` class); trust chain is self-contained per-checkout. |
|
||||
| `pnpm typecheck` | root `package.json` → `turbo run typecheck` | ts | Per-package `tsc --noEmit` (all 20 packages); turbo `typecheck` depends on `^build`, so package builds must succeed first; prefixed by checkout preflight | `CI ci.yml#typecheck`; `pre-push`; verify-release `typecheck` stage; `turbo typecheck` | QC-2 Workspace typecheck | preserve | The single workspace-wide type gate; CI and hooks invoke the same task, no divergent checklist. |
|
||||
| `pnpm lint` | root `package.json` → `turbo run lint` | ts | Per-package `eslint src` under root `eslint.config.mjs` (ignores `dist`, `.next`, `framework/**`, etc.) | `CI ci.yml#lint`; `pre-push`; verify-release `lint` stage; `turbo lint` | QC-3 Workspace lint | preserve | Same-task invocation from every surface; no second lint definition. |
|
||||
| `pnpm format:check` | root `package.json` → `prettier --check` | ts | Prettier parse/format equality over `**/*.{ts,tsx,js,jsx,json,md}` minus `.prettierignore` (generated trees, `docs/scratchpads/`, venvs, …) | `CI ci.yml#format`; `pre-push`; verify-release `format` stage | QC-4 Format check | preserve | Single formatter, single ignore list, enforced identically everywhere. |
|
||||
| `pnpm test` | root `package.json` `test` = `test:checkout` && `turbo run test` && `test:installer` | ts | (a) `node --test scripts/*.test.mjs` — checkout-tool units; (b) per-package `vitest run` (mosaic appends the 47-command `test:framework-shell` chain); (c) `tools/install-next-lane.test.sh`; turbo `test` declares DB env vars and depends on `^build` | `CI ci.yml#test` (with `DATABASE_URL` + `db:migrate` first); verify-release `test` stage; `turbo test` | QC-5 Test suite execution | preserve | One composed test command; the chain property (any link red ⇒ step red) is the gate. |
|
||||
| `pnpm build` | root `package.json` → `turbo run build` | ts | Per-package build (`tsc`/Next) with `^build` dependency and `dist/**` outputs | `publish.yml#build`; verify-release `build` stage; `turbo build` | QC-6 Workspace build | preserve | Publish artifacts derive from the same build task CI verifies. |
|
||||
|
||||
### 1.2 Framework quality shell probes (`packages/mosaic/framework/tools/quality/`)
|
||||
|
||||
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
|
||||
| ------------------------------------------- | ----------------------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| Sanitization gate | `scripts/verify-sanitized.sh` | shell | Built-in self-test first (planted identity/structural/YAML+service fixtures; exit 2 if the regexes or extension coverage break), then: (1) identity denylist grep (`jarvis\|jason\|woltje\|brain.woltje.com\|/home/jwoltje\|\bPDA\b`) over all shipped text files **including** `examples/`; (2) structural grep for private `$HOME/src` defaults in shipped scripts **excluding** `examples/`. Any hit ⇒ exit 1 | `CI ci.yml#sanitization`; verify-release `sanitization` stage | QC-7 Framework sanitization | preserve | Labeled one-time regression guard with a self-test that prevents silent no-op; correctly scoped (identity vs structural) and documented as not a general PII detector. |
|
||||
| Resident-context budget | `scripts/check-resident-budget.sh` (+ `--self-test`) | shell | Self-test of the comparator, then `wc -l` vs per-file ceilings (CONSTITUTION 120, AGENTS 120, each RUNTIME.md 90); missing file ⇒ fail; over ceiling ⇒ exit 1 | `CI ci.yml#sanitization` (both modes); verify-release `sanitization` stage | QC-8 Resident-context budget | preserve | Caps the container (lines), never the wording — the deliberate anti-drift design (DESIGN §7); CI-enforceable half only, by design. |
|
||||
| Test-membership enumeration guard (#1017) | `scripts/check-test-enumeration.sh` + `test-enumeration-exclusions.txt` | shell | Parses surface S1 (`packages/mosaic` `test:framework-shell` via JSON+shlex) and S2 (every `framework/tools/\*.sh | .py`token in`ci.yml`, comment lines stripped); population = `_test_.sh`under`framework/tools`; FAILS on: suite-shaped file on disk neither enumerated nor signed-excluded; surface naming a path missing on disk (both directions); exclusion without reason / stale / outside population / contradicting enumeration. Proves **naming, not reachability** (stated in-file) | `CI ci.yml#sanitization` (direct line); link [0] of `test:framework-shell` (thus `CI ci.yml#test`); verify-release `sanitization` stage | QC-9 Test-membership enumeration | preserve | Makes silent under-run impossible; invoked from both surfaces it audits so severing the chain cannot silence it. |
|
||||
| Enumeration-guard needles | `scripts/test-check-test-enumeration.sh` | shell | Needle/control fixtures driven through `--root`: every promised failure mode must trip the guard **on its own words**, plus controls that must pass (null-case defense); covers commented-out ci.yml lines (F1) and line-range parsing (n2b) | `test:framework-shell` → `CI ci.yml#test`; verify-release `test` stage | QC-9 Test-membership enumeration | preserve | Guard-of-the-guard with both polarities; same canonical check by design. |
|
||||
| Upgrade manifest guard (#791 HARD GATE) | `scripts/test-upgrade-manifest-guard.sh` | shell | Keep-mode `install.sh` upgrade against seeded throwaway `MOSAIC_HOME`: every operator sentinel — including an **unanticipated** one — survives byte-identical with unchanged mtime; framework files still update; retired framework files pruned; matrix run with rsync present AND absent (keep path must be rsync-independent); fail-closed matrix (empty/operator-only/malformed/missing manifest aborts loudly, operator files untouched); operator secret never appears in installer output | `CI ci.yml#upgrade-guard`; verify-release `upgrade-guard` stage | QC-10 Upgrade/install safety | preserve | The operator-data hard gate for the `mosaic update` path; negative controls are load-bearing and documented. |
|
||||
| Upgrade rollback gate (#791 B1) | `scripts/test-upgrade-rollback.sh` | shell | Mid-sync failure (PATH-shadowing `cp` shim) must trigger snapshot restore: restore message fires, corrupted file restored, target byte-identical to pre-upgrade; control installer with `set -E` stripped must NOT roll back (proves errtrace is load-bearing); plus signal/exit-guard controls | `CI ci.yml#upgrade-guard`; verify-release `upgrade-guard` stage | QC-10 Upgrade/install safety | preserve | Proves the rollback trap actually fires; the `-E`-stripped control keeps Part A honest. |
|
||||
| Durable-snapshot gate (#791 PR2) | `scripts/test-upgrade-durable-snapshot.sh` | shell | Pre-update snapshot taken before any mutation (0700/0600 perms, secret never logged, retention-pruned); post-sync verify net restores operator files a manifest bug lets the sync touch; CWE-59 symlink-leaf guard proven with a portable cp shim in both polarities (write-through-link must not happen); v1→v2 migration semantics (intended `bin/` removal not healed) | `CI ci.yml#upgrade-guard`; verify-release `upgrade-guard` stage | QC-10 Upgrade/install safety | preserve | Covers tampering and leak vectors the manifest guard cannot see; the shim rationale (busybox vs GNU cp) is documented in-file. |
|
||||
| Install migration matrix (v2→v3) | `scripts/test-install-migration.sh` | shell | Fixture matrix running the real installer with `MOSAIC_SYNC_ONLY=1`: fresh install seeds + stamps version 3; legacy user-edited AGENTS overwritten with `.pre-constitution.bak` preserved (and idempotent); tuned STANDARDS overwritten; operator files (SOUL, credentials) preserved. Mirrors the TS suite `packages/mosaic/src/config/file-adapter.test.ts` — both installers must behave identically | `CI ci.yml#upgrade-guard`; verify-release `upgrade-guard` stage | QC-10 Upgrade/install safety | preserve | Pins the shell/TS installer parity contract; removal would orphan that parity requirement. |
|
||||
| Enforcement verification probe (bash) | `scripts/verify.sh` | shell | Attempts **real commits** in the target repo: planted type error must produce a commit blocked with `error`; planted `any` must trip `no-explicit-any`; planted lint error must trip `prettier`; gitleaks binary must exist (3a) and detect a planted AWS key via `gitleaks git --pre-commit --staged --redact` (3b). Verdicts are output-grep matches on hook stderr | `local` via installed `mosaic-quality-verify` on scaffolded target projects; **not run in this repo's CI** | QC-20 Downstream enforcement verification | strengthen (review) | Mechanism is genuinely behavioral (stronger than file presence) but verdict logic is grep-on-output and it is unwired here; absorb as the evaluator's enforcement-probe check (the RI-N4 evaluator invokes it or reimplements it) before retiring the shell path. |
|
||||
| Enforcement verification probe (PowerShell) | `scripts/verify.ps1` | shell | Windows port of `verify.sh`: same planted-commit tests with `$output -match` matching; no gitleaks self-test parity beyond the same checks | `local` (Windows operator); no Windows CI runner exists | QC-20 Downstream enforcement verification | strengthen (review) | A hand-maintained twin of `verify.sh` with no CI coverage — exactly the drift shape the single evaluator removes; retire after the TS evaluator owns the probe. |
|
||||
| Quality template installer (bash) | `scripts/install.sh` | shell | Copies template files (`.husky/pre-commit` incl. mandatory gitleaks, `.lintstagedrc.js`, `.eslintrc.js`, `tsconfig.json`, `.woodpecker.yml`, `.gitleaks.toml`) into a target project; **warns** (does not verify) about `package.json` snippet merge; no post-condition check | `local` / via `mosaic-quality-apply` | QC-21 Downstream rails scaffolding | strengthen (review) | Duplicates the TS `quality-rails init` scaffolder for a different template set; converging on one scaffolder (with post-scaffold verification) is prerequisite to retiring this path. |
|
||||
| Quality template installer (PowerShell) | `scripts/install.ps1` | shell | Windows twin of the template copy above | `local` (Windows operator) | QC-21 Downstream rails scaffolding | strengthen (review) | Same twin-drift risk as `verify.ps1`; no runner exercises it. |
|
||||
| `mosaic-quality-verify` adapter | `framework/tools/_scripts/mosaic-quality-verify` | shell | Thin adapter: validates target dir exists, asserts `verify.sh` present+executable, `cd` target, exec it. No verdict logic of its own | `local` (installed framework bin) | QC-20 Downstream enforcement verification | preserve | Already the thin-adapter shape RI-N4 prescribes for shell surfaces. |
|
||||
| `mosaic-quality-apply` adapter | `framework/tools/_scripts/mosaic-quality-apply` | shell | Thin adapter: arg validation then exec of quality `install.sh --template … --target …` | `local` (installed framework bin) | QC-21 Downstream rails scaffolding | preserve | Thin adapter, no separate verdict; disposition follows its target script's convergence. |
|
||||
| Roster schema regression | `scripts/test-roster-schema.py` | shell | jsonschema `Draft202012Validator` over `fleet/roster.schema.json` with valid/invalid connector-kind fixtures (tmux/discord/matrix conditional fields) | **unwired** — not on S1 or S2, not signed-excluded; also outside the enumeration guard's `*.sh` population, so the guard cannot see it | QC-5 Test suite execution | strengthen (review) | A real regression suite that currently runs nowhere; wire it into a CI surface or sign an exclusion — leaving it invisible re-arms the exact gap #1017 closed. |
|
||||
| Framework shell chain (S1) | `packages/mosaic/package.json` `test:framework-shell` | shell | 47-command `&&` chain: enumeration guard + needles, 14 lease-broker/mutator-gate python unitests, `check-runtime-launches.py`, and ~30 framework-tool shell suites (git wrappers, wake, woodpecker, tmux, glpi, orchestrator, `_scripts`). Quality-relevant members rowed separately below | `turbo test` → `CI ci.yml#test`; verify-release `test` stage | QC-5 Test suite execution | preserve | The chain is the execution surface the enumeration guard audits; known residuals: a failing link stops later suites (measured in #1270 — suites after position 44 had not run), and the guard proves naming, not reachability. |
|
||||
|
||||
### 1.3 Framework runtime hooks and their harnesses (agent-host enforcement)
|
||||
|
||||
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
|
||||
| ------------------------------------- | ----------------------------------------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | --------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| QA edit hook seam | `framework/tools/qa/qa-hook-stdin.sh` (+ `qa-hook-handler.sh`) | shell | PostToolUse stdin hook: extracts edited file from the tool JSON (jq or grep fallback), skips non-JS/TS, then the deps-preflight gate — exits 1 with the legible sentinel `deps not installed — run pnpm install` when `node_modules/.bin` is missing/empty (the #856 false-red class); the downstream handler only files QA remediation **report templates** (no verification logic) | `agent-runtime` (framework `runtime/claude/settings.json` PostToolUse); never CI | QC-16 Agent-runtime edit-time checks | strengthen (review) | The sentinel gate is real enforcement; the handler's report-filing adds no verdict and its name promises more than the code does — evaluator absorption should keep the sentinel, drop the report theater. |
|
||||
| Typecheck-on-edit hook | `framework/tools/qa/typecheck-hook.sh` | shell | PostToolUse: for edited `.ts/.tsx`, finds nearest `tsconfig.json` and runs `tsc --noEmit`, surfacing errors nonzero to the agent immediately | `agent-runtime` (framework `runtime/claude/settings.json` PostToolUse) | QC-16 Agent-runtime edit-time checks | strengthen (review) | Edit-time duplicate of QC-2 with independent invocation logic; keep behavior, converge invocation through the evaluator adapter. |
|
||||
| Deps-preflight harness | `framework/tools/qa/test-deps-preflight.sh` | shell | Five assertions against the seam incl. a documented RED control (raw `not found`), sentinel behavior for missing and empty `.bin`, and no-false-positive once populated | `test:framework-shell` → `CI ci.yml#test` | QC-16 Agent-runtime edit-time checks | preserve | Guard-of-the-check with a red control; keeps the sentinel from regressing. |
|
||||
| Prompt-helper RCE regression | `framework/tools/_scripts/test-mosaic-init-rce.sh` | shell | Sources the prompt helpers and proves a literal `$(touch /tmp/pwned)` answer round-trips verbatim and never executes (no `/tmp/pwned` created) | `test:framework-shell` → `CI ci.yml#test` | QC-5 Test suite execution | preserve | Cheap, load-bearing security regression on the installer's input path. |
|
||||
| Install-ordering harness (#869 C2) | `framework/tools/_scripts/test-install-ordering-guard.sh` | shell | Drives `mosaic-link-runtime-assets` with a fake `mosaic` on PATH: probe ok ⇒ settings copied + exit 0; probe fail ⇒ exit 1 with degraded outcome but all other runtime files still copied; `--allow-inactive-enforcement` forwarded; no-mosaic-on-PATH ⇒ python3 fallback strips enforcement hooks and exits 1; fallback + flag ⇒ wires as-is, exit 0 | `test:framework-shell` → `CI ci.yml#test` | QC-17 Lease-enforcement wiring safety | preserve | Exercises the shell wiring seam independently of the TS guard's own spec suite (complementary coverage, by design). |
|
||||
| Fleet-transport harness (#1240) | `framework/tools/_scripts/test-fleet-transport-check.sh` | shell | Extracts the shipped `check_fleet_transport`/`fleet_declared_transport` functions **from the shipped scripts** (fails loud if extraction yields nothing) and drives both implementations (mosaic-doctor + `tools/install.sh`) from one case table | `test:framework-shell` → `CI ci.yml#test` | QC-18 Operator-host drift audit | preserve | The anti-drift harness for the one rule shipped twice; extraction-from-source keeps it from testing a stale copy. |
|
||||
| Terminal-green contract (RM-61/#1000) | `framework/tools/woodpecker/test-terminal-green-contract.sh` + `verify-terminal-green.py` | shell | Red-first fixtures: pipeline JSON variants (service failure, step failure, cancelled, etc.) must produce the correct terminal-green verdict; controls must pass | `test:framework-shell` → `CI ci.yml#test` | QC-5 Test suite execution | preserve | Keeps the CI-wait wrapper's green-detection honest; a false green here would poison every merge gate that trusts `pr-ci-wait.sh`. |
|
||||
| Lease-gate launch invariant | `framework/tools/lease-broker/check-runtime-launches.py` | shell | Scans production roots (`packages/`, `apps/`, `plugins/`, `tools/`) across sh/py/ts/yaml suffixes for Claude/Pi process launches **outside** the lease gate; allowlist-based; fails CI on violation | `test:framework-shell` → `CI ci.yml#test` | QC-15 Lease-gate architecture invariant | preserve | The only architectural "no ungated launches" rail; grep+allowlist is the right cost/benefit for this invariant. |
|
||||
|
||||
### 1.4 TypeScript quality logic (`@mosaicstack/quality-rails` + mosaic CLI)
|
||||
|
||||
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
|
||||
| ---------------------------------------- | ---------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `quality-rails check` | `packages/quality-rails/src/cli.ts` (`mosaic quality-rails check --project`) | ts | **Expected-file presence only**: loops `expectedFilesForKind` (node: `.eslintrc`, `biome.json`, `.githooks/pre-commit`, `PR-CHECKLIST.md`; python: `pyproject.toml`+hooks+checklist; rust: `rustfmt.toml`+…) and exits 1 listing missing paths. Does not execute any linter, formatter, hook, or scanner | `local` (operator CLI); **no CI wiring in this repo** | QC-19 Downstream rails presence check | strengthen | This is the RI-N4 evaluator seed. Today presence ≠ parity (explicitly called out by RI-N4): it must grow typed verdicts (`passed/failed/blocked/error/not-applicable`), check versioning/subject/reason, digested definitions, and absorb the effective shell probes (QC-20 first). |
|
||||
| `quality-rails doctor` | `packages/quality-rails/src/cli.ts` | ts | Same presence data as `check`, printed with ok/missing lines; **cannot fail** (no nonzero exit on missing files) | `local` (operator CLI) | QC-19 Downstream rails presence check | strengthen | A doctor that cannot fail is advisory; fold into `check` (or return typed states) when the evaluator lands. |
|
||||
| `quality-rails init` | `packages/quality-rails/src/cli.ts` + `scaffolder.ts`/`templates.ts` | ts | Scaffolds rails files per detected kind/profile (linters/formatters lists are advisory strings; hooks flag always true); writes files, prints follow-ups — no post-condition verification | `local` (operator CLI) | QC-21 Downstream rails scaffolding | strengthen (review) | Second scaffolding path alongside quality `install.sh` (§1.2); converge on one with post-scaffold verification before retiring either. |
|
||||
| Lease activation probe (#869 C1, hidden) | `packages/mosaic/src/commands/lease-activation-probe.ts` | ts | Real capability probe, not file presence: resolves the installed mosaic CLI and requires it to advertise the exact `{name, version}` activation contract; all deps injectable; registered as hidden CLI command and consumed by C2/C5 | `local` (hidden CLI + consumed by C2/C5); spec-tested via `lease-activation-probe.spec.ts` in `turbo test` | QC-17 Lease-enforcement wiring safety | preserve | The versioned-contract probe is precisely the fail-closed capability check RI-N2 generalizes; already typed and injectable. |
|
||||
| Install-ordering guard (#869 C2, hidden) | `packages/mosaic/src/commands/install-ordering-guard.ts` | ts | Decides whether enforcement hook entries are written into the `~/.claude/settings.json` the framework reseed ships: not activatable ⇒ strip hooks + nonzero loud outcome (default); explicit per-invocation `--allow-inactive-enforcement` opt-out wires-with-warning. Never touches the runtime gate's own fail-closed behavior | `installer` (framework reseed via `mosaic-link-runtime-assets`); spec + shell harness coverage in `turbo test` | QC-17 Lease-enforcement wiring safety | preserve | Correct default-deny with an explicit, non-env opt-out; test-locked from both the TS and shell sides. |
|
||||
| Lease doctor check (#869 C5) | `packages/mosaic/src/commands/lease-doctor-check.ts` | ts | Combines hook-wiring detection in `~/.claude/settings.json` with C1 activatable and C3 broker-supervisor health: wired ∧ (¬activatable ∨ ¬healthy) ⇒ loud `[ERROR]` that forces `mosaic doctor` exit 1 regardless of the bash audit's own exit | `local` (inside `mosaic doctor`); spec coverage in `turbo test` | QC-17 Lease-enforcement wiring safety | preserve | Closes the "bricked host looks green" hole; cannot be masked by the bash script — that composition is the point. |
|
||||
| `mosaic doctor` (framework drift audit) | `packages/mosaic/src/commands/launch.ts` (`doctor`) + `framework/tools/_scripts/mosaic-doctor` | shell+ts | Bash audit of the installed framework home: ~40 expected files/dirs present; runtime files are copies (not symlinks) matching source (`cmp`) or composed runtime-contract markers; hard-gates block present in AGENTS.md; sequential-thinking MCP configured; fleet transport binary present per roster (warn); legacy symlink trees gone; skills synced — **warn-based, exit 1 only with `--fail-on-warn`**, plus C5's forced error | `local` (operator audit) | QC-18 Operator-host drift audit | preserve | Host-state audit CI cannot see (user files by design, DESIGN §7); advisory exit is the documented contract — do not silently change it. |
|
||||
| `mosaic gateway doctor` | `packages/mosaic/src/commands/gateway-doctor.ts` | ts | Probes per-service health (PostgreSQL, Valkey, pgvector) via `@mosaicstack/storage`, reports tier and JSON; exit 1 only when at least one **required** service fails (yellow stays 0) | `local` (operator) | QC-18 Operator-host drift audit | preserve | Service health with correct red/yellow exit semantics; JSON mode exists for scripting. |
|
||||
| `mosaic gateway verify` | `packages/mosaic/src/commands/gateway/verify.ts` | ts | Post-install liveness: daemon meta via HTTP with retries, admin token on file, bootstrap endpoint reachable; aggregated pass/fail | `local`; consumed by `tools/e2e-install-test.sh` | QC-18 Operator-host drift audit | preserve | The first-run proof the installer E2E relies on; retry-aware so startup races don't false-red. |
|
||||
| `mosaic fleet doctor` | `packages/mosaic/src/commands/fleet-reconciler-command.ts` | ts | Classifies local roster-owned drift (no mutation) from the parsed v2 roster | `local` (operator) | QC-18 Operator-host drift audit | preserve | Dry-run classification is the correct non-mutating audit shape. |
|
||||
|
||||
### 1.5 Git hooks (developer machine)
|
||||
|
||||
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
|
||||
| ------------------------- | --------------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| Pre-commit staged hygiene | `.husky/pre-commit` → `npx lint-staged` (`.lintstagedrc`) | shell | On staged files only: `prettier --write` + `eslint --fix` for ts/tsx/js/jsx; `prettier --write` for json/md/yaml/yml. **Mutating** (fixes and re-stages); commit blocks only if a fixer itself fails | `pre-commit` (every local commit; hooks activated by `install-hooks.mjs` via `core.hooksPath .husky/_`) | QC-13 Staged-change hygiene | preserve | Correct scoped fast gate; note it auto-fixes rather than rejects (deliberate). Gap: no secret scan here — see §3. |
|
||||
| Pre-push gate | `.husky/pre-push` | shell | `pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check` (no test run — documented in AGENTS.md) | `pre-push` | QC-14 Pre-push gate | preserve | Composes QC-1..4 exactly as specified in AGENTS.md; tests intentionally left to CI. |
|
||||
| Hook installer | `scripts/install-hooks.mjs` (`pnpm prepare`) | ts | Stages husky hooks into a scratch repo first, asserts husky produced its `h` shim, quarantines incomplete previous sets, verifies idempotence via full directory snapshot comparison, then sets `core.hooksPath`; skips cleanly with `HUSKY=0` or no git | `installer` (runs on `pnpm install`) | QC-13 Staged-change hygiene | preserve | Self-verifying wiring for the hook gates — a corrupted half-install cannot silently disable them. |
|
||||
|
||||
### 1.6 CI pipeline steps (`.woodpecker/`)
|
||||
|
||||
Step-to-probe mapping for container steps: `ci.yml#sanitization` = QC-7+QC-8+QC-9 (rows §1.2, plus `apk add bash` env prep); `ci.yml#upgrade-guard` = QC-10 (rows §1.2, plus `apk add rsync`); `ci.yml#typecheck`/`#lint`/`#format`/`#test` = QC-2/3/4/5 (rows §1.1). Rows below are mechanisms that exist only in a pipeline.
|
||||
|
||||
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
|
||||
| -------------------------------------- | -------------------------------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| Frozen install | `ci.yml#install` | pipeline-step | `pnpm install --frozen-lockfile --prefer-offline` against the baked ci-base store — lockfile supply integrity; a drifted lockfile fails the build before any gate runs | `CI ci.yml#install` | QC-1 Checkout integrity | preserve | Lockfile-pinned dep resolution is the supply-chain floor under every later gate. |
|
||||
| Test-step readiness prelude | `ci.yml#test` prologue | pipeline-step | Installs pinned `@earendil-works/[email protected]` (Invariant R suite requires the real binary) + openssl; waits up to 60×1s on `pg_isready` for the `ci-postgres` service and fails fast if it never comes up; runs `db:migrate` before tests | `CI ci.yml#test` | QC-5 Test suite execution | preserve | Fail-fast environment preconditions — a missing service produces a legible failure, not a wall of red tests. |
|
||||
| Publish verify step (pending RI-1-001) | `publish.yml#verify` (branch `feat/ri-050-publish-gate` @ `46784c8d`, not yet on next) | pipeline-step | (a) Commit identity: fails closed if `CI_COMMIT_SHA` empty, `git rev-parse HEAD` empty, or the two differ; (b) runs the canonical `pnpm verify:release`. **Every publish effect depends on this step; it carries no path filter** | `publish.yml#verify` | QC-11 Terminal release verification | preserve | The RI-N1 exact-commit binding; until it merges, publish steps on next depend on `build` only (see §3 gap 1). |
|
||||
| Publish error classification | `publish.yml#publish-npm` | pipeline-step | Publishes `@mosaicstack/*` (minus web) and classifies outcome: success, or the **only tolerated failure** = already-published (EPUBLISHCONFLICT / "cannot publish over" / "previously published"); explicit fatal on npm `E404/E401/ENEEDAUTH/ECONNREFUSED/ETIMEDOUT/ENOTFOUND` and on any unrecognized failure (replacing the old ` | | echo` that hid a registry 404) | `publish.yml#publish-npm` (main/tags, path-filtered on `packages/**`) | QC-12 Publish-effect integrity | preserve | Converts silent publish fall-on-floor into loud failure; allowlist-of-one error tolerance is the right shape. |
|
||||
| Next-lane publish assertions | `publish.yml#publish-next-npm` | pipeline-step | Guards: branch must be `next`, `CI_PIPELINE_NUMBER` required; registry dist-tags JSON must be usable; walks all manifests, strictly parses stable semver, rewrites `X.Y.(Z+1)-next.<N>`; publishes with `--tag next` (never latest); post-publish asserts `npm view @mosaicstack/mosaic@next` resolves to the exact expected version | `publish.yml#publish-next-npm` (push/manual on next) | QC-12 Publish-effect integrity | preserve | Durable prerelease lane with end-to-end resolution proof — the published artifact is verified, not assumed. |
|
||||
| Image destination policy | `publish.yml#build-gateway` / `#build-appservice` / `#build-web` | pipeline-step | Kaniko builds with destination policy: `next` ⇒ sha-tag only (fatal if a tag event sneaks in); `main` ⇒ sha + `latest`; tag events ⇒ sha + `<tag>`; anything else fatal. Path filters only skip **effects**, never the verify step | `publish.yml#build-*` | QC-12 Publish-effect integrity | preserve | Fail-closed tagging matrix; the exclude-list default-safe design keeps stale images impossible. |
|
||||
|
||||
Adjacent pipeline surface (not a probe): `.woodpecker/ci-image.yml` rebuilds the ci-base image on `pnpm-lock.yaml`/`Dockerfile.ci` change with an immutable `lock-<hash>` tag; pipelines consume `:latest`. Recorded for completeness — no code-quality property is checked.
|
||||
|
||||
### 1.7 Root installer tooling (`tools/`)
|
||||
|
||||
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
|
||||
| --------------------------- | --------------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Next-lane installer test | `tools/install-next-lane.test.sh` (`pnpm test:installer`) | shell | Drives `tools/install.sh --next` with faked `node`/`npm` binaries (no network): Node 20 must be rejected; installs must pin **exact** versions (mutable `@next` forbidden); fast path must not unexpectedly fall back to source; gateway-install failure takes the documented fallback | `turbo`-external tail of `pnpm test` → `CI ci.yml#test` | QC-5 Test suite execution | preserve | Hermetic (shimmed) regression net for the installer lane; runs as part of the standard test command. |
|
||||
| Clean-container install E2E | `tools/e2e-install-test.sh` | shell | Full first-run flow in a node:22-alpine container: `install.sh --yes` → `mosaic wizard` (non-interactive) → `mosaic gateway install` → `mosaic gateway verify` exit check (with EXPECTED-SKIP if the installed CLI predates `gateway verify`); skips gracefully without Docker | `local` (manual; requires Docker); **not wired in CI** | QC-5 Test suite execution | strengthen (review) | The only end-to-end proof of the install→verify path; currently operator-initiated only — wire into a periodic/manual CI lane or sign its exclusion explicitly. |
|
||||
| Host installer advisories | `tools/install.sh` (`--check`; `check_fleet_transport`) | shell | `--check` = version comparison only, no install; `check_fleet_transport` warns (non-blocking, by design — tmux is the fleet's dependency, not mosaic's) when the roster-declared transport binary is absent, naming exactly what it blocks; PATH-persistence warnings | `installer` (operator-run) | QC-18 Operator-host drift audit | preserve | Advisory-by-design warnings; the parallel doctor check is drift-tested by §1.3's harness. |
|
||||
|
||||
### 1.8 Pending workstream additions (branch `feat/ri-050-publish-gate` @ `46784c8d`)
|
||||
|
||||
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
|
||||
| ------------------------------- | ---------------------------------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------- | ----------- | ---------------------------------------------------------------------------------------------- |
|
||||
| Canonical terminal verification | `scripts/verify-release.mjs` (`pnpm verify:release`) | ts | One command replaying the full mandatory set as stages — sanitization, upgrade-guard, typecheck (incl. preflight), lint, format, test, build — mirroring `ci.yml` step-for-step; fail-fast on first failing command; requires `bash`+`rsync` on PATH; `--stage <name>` for wiring smoke-tests only | `publish.yml#verify` (pending); `local` (`pnpm verify:release`) | QC-11 Terminal release verification | preserve | The RI-N1 canonical command — CI and publication share one semantic checklist by construction. |
|
||||
| Verify-parity contract test | `scripts/verify-release.test.mjs` | ts | Parses the real `ci.yml`/`publish.yml`: stage table must match ci.yml step-for-step; every publish-effect step (name `publish*` or image-pushing) must transitively depend on `verify`; commit-identity assertion must be present; `verify` must carry no path filter | `test:checkout` → `CI ci.yml#test` (once merged) | QC-11 Terminal release verification | preserve | Guard-of-the-guard at checkout time — the two surfaces cannot drift apart silently. |
|
||||
|
||||
## 2. Canonical check set
|
||||
|
||||
The deduplicated checks every row above maps onto. IDs are stable for RI-3-002 to consume.
|
||||
|
||||
- **QC-1 Checkout integrity.** Owns: the checkout can run its gates — frozen-lockfile dependency resolution, required gate binaries present, no stale build lock, and the `apps/web/.next` generated-state trust chain (real directory, uid ownership, certified source fingerprint, certified symlink manifest). Implemented by `scripts/preflight.mjs` + frozen install steps.
|
||||
- **QC-2 Workspace typecheck.** Owns workspace-wide TypeScript soundness: per-package `tsc --noEmit` over built dependencies (`turbo typecheck`). The single definition invoked by CI, pre-push, and terminal verification.
|
||||
- **QC-3 Workspace lint.** Owns static-analysis policy: per-package ESLint under the root config. One config, one task, every surface.
|
||||
- **QC-4 Format check.** Owns formatting uniformity: Prettier check with the repo ignore list. (The pre-commit variant additionally fixes; the verdict form is this check.)
|
||||
- **QC-5 Test suite execution.** Owns execution of all test surfaces: checkout script units (`node --test`), per-package Vitest suites (including the framework shell chain and its python unitests), the installer-lane shim test, and — once wired — `test-roster-schema.py` and container E2E. Also owns guards-of-the-gate that live inside the chain (terminal-green contract, RCE regression).
|
||||
- **QC-6 Workspace build.** Owns artifact buildability: `turbo build` producing the artifacts publication consumes.
|
||||
- **QC-7 Framework sanitization.** Owns the open-source guarantee for the shipped framework package: no operator-identity tokens anywhere (examples included), no private `$HOME` defaults in shipped scripts, with a self-test that keeps the regexes honest.
|
||||
- **QC-8 Resident-context budget.** Owns the line-count ceilings on framework files injected into every agent's context (Constitution, dispatcher, RUNTIME.md slices) — the CI-enforceable half of the resident-prompt budget.
|
||||
- **QC-9 Test-membership enumeration.** Owns the property that no test suite can silently fall out of CI: disk population vs parsed enumeration surfaces, both-directions staleness, and signed exclusions with reasons. Includes its needle/control harness.
|
||||
- **QC-10 Upgrade/install safety.** Owns the #791 family: operator-path byte-identity across keep-mode upgrades (manifest guard), mid-failure rollback (errtrace-proven), durable pre-update snapshot + verify net + CWE-59 leaf guard, and the v2→v3 migration matrix with shell/TS parity.
|
||||
- **QC-11 Terminal release verification.** Owns the RI-N1 exact-commit binding: commit-identity assertion plus one canonical command (`pnpm verify:release`) replaying the complete mandatory set, with every publish effect depending on it; plus the checkout-time parity/DAG contract test that keeps pipeline and command in sync.
|
||||
- **QC-12 Publish-effect integrity.** Owns publication correctness: npm publish error classification (only already-published tolerated), next-lane versioning and post-publish resolution proof, and image destination/tag policy.
|
||||
- **QC-13 Staged-change hygiene.** Owns commit-time hygiene on staged files (prettier/eslint fix-and-restage) and the self-verifying hook wiring that guarantees the gates are actually installed.
|
||||
- **QC-14 Pre-push gate.** Owns the local push composition: preflight + typecheck + lint + format:check (tests deliberately deferred to CI).
|
||||
- **QC-15 Lease-gate architecture invariant.** Owns "no ungated runtime launches in production code": the scan + allowlist over `packages/`, `apps/`, `plugins/`, `tools/`.
|
||||
- **QC-16 Agent-runtime edit-time checks.** Owns edit-time feedback on agent hosts: the deps-preflight legibility sentinel and typecheck-on-edit, plus their regression harnesses.
|
||||
- **QC-17 Lease-enforcement wiring safety.** Owns the #869 C1/C2/C5 trio: activation capability probe (versioned contract), enforcement-hook wiring gate (default-deny with explicit opt-out), and the doctor check that surfaces a bricked host — with their shell/TS harnesses.
|
||||
- **QC-18 Operator-host drift audit.** Owns host-state health CI cannot see: `mosaic doctor` drift audit (+ fleet transport, both implementations), `fleet doctor` roster classification, `gateway doctor`/`gateway verify` service health, and installer advisories. Advisory exits are part of the contract.
|
||||
- **QC-19 Downstream rails presence check.** Owns "does a scaffolded project still carry its rails files" — today the TS `quality-rails check/doctor` presence loop; per RI-N4 this is the seed that must become the typed evaluator (presence alone is explicitly not parity).
|
||||
- **QC-20 Downstream enforcement verification.** Owns "do the rails actually block" on scaffolded projects: the behavioral planted-commit probe (type error, `any`, lint, gitleaks secret) currently in `verify.sh`/`verify.ps1` behind the `mosaic-quality-verify` adapter.
|
||||
- **QC-21 Downstream rails scaffolding.** Owns putting rails files into a target project: the shell template installer (+ PowerShell twin) and the TS `quality-rails init` scaffolder — currently two paths that must converge.
|
||||
|
||||
## 3. Coverage gaps
|
||||
|
||||
Enforced nowhere but implied, or named in docs/tooling but not wired:
|
||||
|
||||
1. **Publication not yet bound to verification on `next`.** At this base (`8199261c`), `publish.yml` publish steps depend on `build` only; the `verify` step and `scripts/verify-release.mjs` exist on `feat/ri-050-publish-gate` (`46784c8d`) but are not merged. Until RI-1-001 lands, AC-RI-1's negative control cannot hold on the real pipeline.
|
||||
2. **Playwright E2E unwired.** `apps/web` ships `test:e2e` (`playwright test`) with real suites (`admin/auth/chat/navigation.spec.ts`); neither `pnpm test` nor any CI step invokes it. The web UI's user flows are verified only when an operator runs them manually.
|
||||
3. **No secret scanning on this repo.** The framework's own template pre-commit makes gitleaks **required**, and `verify.sh` proves detection with a planted key — but this repository's `.husky/pre-commit` (lint-staged only) and CI run no secret scan. The repo ships the control it does not use.
|
||||
4. **No dependency audit.** The quality `.woodpecker.yml` templates and `docs/CI-SETUP.md` specify `npm audit --audit-level=high` as a pipeline stage; nothing equivalent runs for this repo.
|
||||
5. **No coverage thresholds.** Templates enforce 80% Jest coverage thresholds; this repo's Vitest configs collect coverage with no thresholds — coverage is measured nowhere and enforced nowhere.
|
||||
6. **`test-roster-schema.py` invisible.** A real jsonschema regression suite wired to no surface and invisible to the enumeration guard (its population is `*.sh`; the suite is `.py`). Either enumerate it or sign an exclusion — silence here is the #1017 defect shape.
|
||||
7. **Presence-checker expectations ≠ this repo.** `quality-rails check` expects `.eslintrc`, `biome.json`, `.githooks/pre-commit`, `PR-CHECKLIST.md` for node projects — none describe this monorepo (husky, flat eslint config, no biome, no PR-CHECKLIST.md). The evaluator's check set must be per-subject (versioned, digested), not one global file list.
|
||||
8. **Chain-ordering residual (documented).** `test:framework-shell` is one `&&` chain: a failing link skips every later suite while the step still fails (measured in #1270 — four suites after position 44 had not run since a prior merge). The enumeration guard proves naming, not reachability; both residuals are in-file documented but structurally unfixed.
|
||||
9. **Signed-exclusion burndown open.** 16 signed exclusions remain in `test-enumeration-exclusions.txt`; several are "unmeasured in CI image" or blocked on missing CI tooling (tmux, setsid) — tracked under #1017/#1271. Each is an enforcement promise deferred, not delivered.
|
||||
10. **Windows twins unexercised.** `verify.ps1`, `install.ps1`, `mosaic-doctor.ps1` have no runner anywhere (no Windows CI); behavioral drift from their bash twins is undetectable by construction.
|
||||
11. **QA hook name vs behavior.** `qa-hook-handler.sh` files remediation report templates but performs no verification; the seam's actual gate value is only the deps-preflight sentinel. Anything relying on "QA automation hook" as a check is relying on report-filing.
|
||||
12. **Two test paths, one gated.** CI runs tests against ci-postgres (`DATABASE_URL` set); the local PGlite path is the documented default (AGENTS.md) until KBN-101-02/101-05. Only the CI path is enforced by pipeline.
|
||||
|
||||
## 4. Disposition summary
|
||||
|
||||
| disposition | rows | checks |
|
||||
| ------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| preserve | 43 | Every canonical owner (QC-1..QC-18) plus correct guards-of-the-guard and thin adapters: all of §1.1, the CI-invoked framework probes and adapters in §1.2, all of §1.3, the C1/C2/C5 trio and doctors in §1.4, all of §1.5, all pipeline-only steps in §1.6, §1.7 rows 1 and 3, and §1.8. |
|
||||
| strengthen | 2 | `quality-rails check` and `quality-rails doctor` (QC-19) — the RI-N4 evaluator seed: typed verdicts, versioned/digested check definitions, per-subject check sets. |
|
||||
| strengthen (review) | 9 | `verify.sh` + `verify.ps1` (QC-20), quality `install.sh`/`install.ps1` + `quality-rails init` (QC-21 — scaffold-path convergence), `test-roster-schema.py` (QC-5 — wire or sign), `qa-hook-stdin.sh` seam + `typecheck-hook.sh` (QC-16), `tools/e2e-install-test.sh` (QC-5 — CI lane). |
|
||||
| retire | 0 | None meet the bar: RI-N4 requires effective shell probes be **absorbed before** their paths retire, and no absorption exists yet. The `strengthen (review)` rows are the retirement candidates for RI-3-002 once the evaluator owns their behavior. |
|
||||
|
||||
Row total: 54. Canonical checks: 21 (QC-1..QC-21).
|
||||
@@ -1,83 +0,0 @@
|
||||
# #1099 pipefail + early-exit sweep
|
||||
|
||||
Baseline: `df4c591ab42aa1ae62c12935fdc0e772684864a0`
|
||||
|
||||
This is a site inventory, not a risk count. `FIXED` means the early-exiting consumer no longer has a piped upstream process whose SIGPIPE can become the result under `pipefail`. `NOT-LOAD-BEARING` means the pipeline status is explicitly discarded. `UNREACHABLE-AND-WHY` describes designed input, not a payload-size safety claim.
|
||||
|
||||
## Tranche 1 — runtime and general scripts
|
||||
|
||||
| Baseline site | Verdict | Construction / reason |
|
||||
| --- | --- | --- |
|
||||
| `tools/matrix-presence-harness/run.sh:38` | FIXED | nullglob array selects the first path; no pipeline |
|
||||
| `tools/e2e-install-test.sh:139` | FIXED | capture help completely, then grep via redirection |
|
||||
| `tools/install.sh:312` | FIXED | NUL `mapfile` reads all roots; count != 1 reaches the named malformed-archive diagnostic |
|
||||
| `scripts/analysis/reflect-board-history.sh:76` | FIXED | capture Git history completely, then grep via redirection |
|
||||
| `scripts/analysis/reflect-git-history.sh:67` | FIXED | grep reads from a here-string |
|
||||
| `scripts/analysis/reflect-git-history.sh:69` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/authentik/user-create.sh:72` | FIXED | jq `first(...)` reads the response directly |
|
||||
| `packages/mosaic/framework/tools/git/mutate-push-guard.sh:87` | FIXED | grep `-m1` reads the file directly; downstream `cut` consumes its complete scalar output |
|
||||
| `packages/mosaic/framework/tools/orchestrator/session-resume.sh:94` | FIXED | `mapfile` plus bounded indexed loop replaces `head` pipeline |
|
||||
| `packages/mosaic/framework/tools/prdy/prdy-status.sh:69` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:172` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:173` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:174` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:175` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:176` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:177` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh:178` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/qa/typecheck-hook.sh:16` | FIXED | Bash regex extracts the first field without a pipeline |
|
||||
| `packages/mosaic/framework/tools/qa/typecheck-hook.sh:56` | FIXED | grep and bounded sed each read from a here-string |
|
||||
| `packages/mosaic/framework/tools/tmux/send-message.sh:113` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/tmux/send-message.sh:124` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/wake/detector.sh:126` | FIXED | one awk reads the manifest directly and exits after the first exact key |
|
||||
| `packages/mosaic/framework/tools/wake/detector.sh:270` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/wake/detector.sh:278` | FIXED | grep reads from a here-string |
|
||||
| `packages/mosaic/framework/tools/wake/digest.sh:647` | FIXED | capture complete locator output, then select first line by parameter expansion |
|
||||
| `packages/mosaic/framework/tools/wake/reconcile.sh:149` | FIXED | one awk reads the manifest directly and exits after the first exact key |
|
||||
|
||||
## Explicit withdrawn / non-load-bearing sites
|
||||
|
||||
| Baseline site | Verdict | Reason |
|
||||
| --- | --- | --- |
|
||||
| `tools/install.sh:182` | NOT-LOAD-BEARING | `|| true` explicitly discards lookup status |
|
||||
| `tools/install.sh:356` | UNREACHABLE-AND-WHY | `pnpm pack` writes one matching CLI tarball into a fresh directory immediately before lookup; citation withdrawn in #1099 |
|
||||
| `tools/install.sh:357` | UNREACHABLE-AND-WHY | same fresh-directory invariant for gateway tarball; citation withdrawn in #1099 |
|
||||
| `tools/install.sh:627` | NOT-LOAD-BEARING | `|| true` explicitly discards lookup status |
|
||||
| `scripts/agent/session-start.sh:70` | NOT-LOAD-BEARING | optional scratchpad lookup has `|| true` |
|
||||
| `packages/mosaic/framework/templates/repo/scripts/agent/session-start.sh:58` | NOT-LOAD-BEARING | optional scratchpad lookup has `|| true` |
|
||||
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:25` | UNREACHABLE-AND-WHY | withdrawn in #1099 after designed-input reachability measurement; preserved without re-litigation |
|
||||
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:27` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
|
||||
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:30` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
|
||||
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:32` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
|
||||
| `packages/mosaic/framework/tools/qa/qa-hook-stdin.sh:34` | UNREACHABLE-AND-WHY | same withdrawn designed-input finding |
|
||||
|
||||
## Tranche 2 — non-wake test harnesses
|
||||
|
||||
All 22 baseline sites below are `FIXED`; the checked-in tranche fixture is passed through the same scanner and asserts all 22 occurrences and 21 normalized identities (the same response-split line occurs twice).
|
||||
|
||||
| Baseline site(s) | Verdict | Construction |
|
||||
| --- | --- | --- |
|
||||
| `systemd/user/test-fleet-units.sh:148` | FIXED | capture tmux output, then grep via redirection |
|
||||
| `git/test-issue-comment-readback.sh:283,302` | FIXED | parameter expansion splits status/body without `head` |
|
||||
| `git/test-pr-review-gitea-comment.sh:228` | FIXED | parameter expansion splits status/body |
|
||||
| `git/test-lane-brief-pr-linkage.sh:72` | FIXED | grep reads from a here-string |
|
||||
| `git/test-pr-review-repo-host-override.sh:225-226` | FIXED | grep reads from a here-string |
|
||||
| `orchestrator/smoke-test.sh:67,72` | FIXED | parameter expansion selects first line |
|
||||
| `orchestrator/test-board-roll.sh:99-100` | FIXED | grep reads from a here-string |
|
||||
| `quality/scripts/test-upgrade-durable-snapshot.sh:180` | FIXED | complete sorted output is read with `mapfile`, then indexed |
|
||||
| `quality/scripts/test-upgrade-rollback.sh:339,356` | FIXED | direct `grep -m1` file reads; cleanup captures before testing |
|
||||
| `tmux/test-send-message-socket.sh:37,38,44-46,68,72` | FIXED | capture commands complete before redirected grep assertions |
|
||||
| `tmux/test-send-message-verdict.sh:34` | FIXED | grep reads from a here-string |
|
||||
|
||||
## Tranche 3 — wake validation harnesses
|
||||
|
||||
All 26 baseline occurrences (25 normalized identities; one preimage selector occurs twice) are `FIXED` and mechanically bound through the wake fixture and shared scanner.
|
||||
|
||||
| Baseline site(s) | Verdict | Construction |
|
||||
| --- | --- | --- |
|
||||
| `wake/test-wake-digest-quarantine.sh:567` | FIXED | complete match populations are captured, then first line selected by parameter expansion |
|
||||
| `wake/test-wake-preimage.sh:182-183,346-347` | FIXED | jq `first(...)` reads each JSONL file directly |
|
||||
| `wake/validate-973/microtest-wake-assert.sh:153,170-171,176,204-209,233-234,251-252,286-287` | FIXED | scalar assertions use here-strings; diagnostics use non-early sed ranges; source line captured before matching |
|
||||
| `wake/validate-973/validate-973.sh:110,119,180,182,187` | FIXED | scalar assertions use here-strings; diagnostic truncation uses consuming sed ranges |
|
||||
|
||||
The scoped inventory is complete: 26 runtime/general + 22 non-wake tests + 26 wake tests fixed; 11 explicitly withdrawn or non-load-bearing sites retain their documented verdicts.
|
||||
@@ -1,229 +0,0 @@
|
||||
# #1043 — Fleet pane git-identity propagation
|
||||
|
||||
## Objective
|
||||
|
||||
Ensure a fleet seat's launched runtime process receives its roster-derived `MOSAIC_GIT_IDENTITY`, and lock the complete generated-environment propagation boundary with an enumerated set comparison.
|
||||
|
||||
## Tracking
|
||||
|
||||
- External issue: `mosaicstack/stack#1043`
|
||||
- Branch: `fix/1043-pane-git-identity`
|
||||
- Coordinator: `tl-mosaic`
|
||||
- `docs/TASKS.md`: read-only by project worker contract; not modified.
|
||||
|
||||
## Constraints
|
||||
|
||||
- RED-first bug reproducer is mandatory.
|
||||
- R7 delete-the-subject mutation must turn the behavioral test red.
|
||||
- Assert launched-process environment, not source text.
|
||||
- One push only; do not poll CI after push.
|
||||
- Run the CI queue guard immediately before push and report its `state=` line as state, not evidence.
|
||||
- Do not modify a live host launcher or obtain/copy another credential.
|
||||
- Self-post the PR, verify provider attribution, then stop.
|
||||
- Final status wording: `believed-fixed, pending jarvis validation`.
|
||||
|
||||
## Scope inventory
|
||||
|
||||
Re-derived against `origin/main` at `85d2108e`:
|
||||
|
||||
- Launch consumer: `packages/mosaic/framework/tools/fleet/start-agent-session.sh`
|
||||
- Behavioral launch test: `packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
|
||||
- Generated-environment contract/parser: `packages/mosaic/src/fleet/generated-env-boundary.ts`
|
||||
- Roster projection producers:
|
||||
- `packages/mosaic/src/commands/fleet.ts`
|
||||
- `packages/mosaic/src/fleet/fleet-reconciler.ts`
|
||||
- `packages/mosaic/src/fleet/fleet-agent-crud.ts`
|
||||
- `packages/mosaic/src/fleet/v1-v2-migration.ts`
|
||||
- Contract and producer tests discovered by repository search.
|
||||
- Generated-environment operator/developer docs and their executable documentation contract test.
|
||||
|
||||
Discrepancy sent to `tl-mosaic`: current main no longer contains the charter's `PANE_SHELL_SNIPPET`; #772 replaced it with an `/usr/bin/env -i` argv launch boundary, and current generated projections do not declare git identity. Code-read inventory is **NOT MEASURED** behavior.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add the process-environment set-comparison regression first and record RED.
|
||||
2. Add roster-derived `MOSAIC_GIT_IDENTITY=<agent name>` to the complete generated projection contract.
|
||||
3. Validate identity syntax and equality with `MOSAIC_AGENT_NAME`; pass it through the clean pane environment.
|
||||
4. Update affected projection tests and generated-environment docs.
|
||||
5. Run focused and baseline gates.
|
||||
6. Perform R7 by deleting the pane propagation entry, prove RED, restore, and prove GREEN.
|
||||
7. Run independent review, remediate, commit, queue guard, one push, self-post PR, verify provider attribution, and stop without CI polling.
|
||||
|
||||
## Budget
|
||||
|
||||
No explicit token cap was provided. Working cap: one narrow logical unit, no dependency installation unless existing tooling requires it, no unrelated refactor.
|
||||
|
||||
## Evidence log
|
||||
|
||||
### TDD and mutation evidence
|
||||
|
||||
- RED-first, repository launcher: `bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh` exited 64 on pre-fix source with `code=unknown-key key=MOSAIC_GIT_IDENTITY`. The generated seat could not launch with the required declared identity.
|
||||
- GREEN: the same repository launcher test emitted `ok - start-agent-session generated environment boundary`.
|
||||
- R7 delete-the-subject: removed only `"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"` from the repository launch array; the same test exited 1 with `FAIL: runtime pane omitted or changed generated environment keys: MOSAIC_GIT_IDENTITY`.
|
||||
- R7 restoration: restored that launch entry; the same test returned green.
|
||||
- Launcher under test is explicitly `packages/mosaic/framework/tools/fleet/start-agent-session.sh` through the test's `$START`, **not** the stale installed host copy.
|
||||
|
||||
### Situational and focused tests
|
||||
|
||||
- Repository launcher boundary: green, including set comparison of all nine generated projection entries and fail-before-tmux cases for missing, unsafe, mismatched, and local-shadow Git identity.
|
||||
- Fleet systemd launcher integration: `bash packages/mosaic/framework/systemd/user/test-fleet-units.sh` — green.
|
||||
- Focused Mosaic Vitest set: 6 files, 311 tests — green.
|
||||
- `bash -n` on changed shell files — green.
|
||||
- `git diff --check` — green.
|
||||
|
||||
### Baseline gates
|
||||
|
||||
- `pnpm typecheck` — 45/45 tasks green.
|
||||
- `pnpm lint` — 25/25 tasks green.
|
||||
- `pnpm format:check` — green.
|
||||
- `pnpm test:checkout` — green.
|
||||
- Repository-wide Vitest under a hermetic current-version npm prefix: Mosaic 81/81 files and 1510/1510 tests green; other workspace test tasks shown green before the framework-shell phase.
|
||||
- Canonical `pnpm test` is not fully green on this host for unrelated environment-sensitive gates:
|
||||
1. the first two runs exposed the globally installed Mosaic 0.0.48 update banner in three CLI smoke tests expecting empty stderr;
|
||||
2. after isolating that global-version input, the framework wake assertion aborted at the known `#973` Bash `BASH_LINENO` convention check (exit 97; observed `[3 5]`, expected `[3 4]`).
|
||||
No tests were weakened or bypassed; focused changed-surface tests are green. CI remains the canonical clean-environment result and is intentionally not polled after push per charter.
|
||||
|
||||
### Independent review
|
||||
|
||||
- Codex code review first pass: request changes for missing shell rejection-path coverage.
|
||||
- Remediation: added table-driven missing/unsafe/mismatch/local-shadow launcher cases, each asserting no tmux call.
|
||||
- Codex code re-review: **approve**, no findings, confidence 0.88.
|
||||
- Codex security review: risk `none`, no findings, confidence 0.97.
|
||||
|
||||
### Acceptance criteria mapping
|
||||
|
||||
| Acceptance criterion | Evidence |
|
||||
| --- | --- |
|
||||
| AC-FGI-01: launched process receives every generated key/value | Repository launcher process-environment `comm -23` set comparison; GREEN and R7 RED evidence above |
|
||||
| AC-FGI-02: missing, unsafe, or split identity fails before tmux | Table-driven shell cases plus TypeScript generated-boundary tests |
|
||||
| AC-FGI-03: focused/baseline/review evidence recorded | Commands and review outcomes above; host-sensitive full-suite limitations stated explicitly |
|
||||
|
||||
### Documentation checklist
|
||||
|
||||
- PRD updated with #1043 requirements and acceptance criteria.
|
||||
- Fleet launch runbook, generated-env concept, and generated-env reference updated.
|
||||
- No API/OpenAPI, sitemap, user publishing target, deployment, or external docs publication change applies.
|
||||
- `docs/TASKS.md` remains unmodified per its single-writer project contract.
|
||||
|
||||
## Round 2 — PR #1073 review 97 remediation
|
||||
|
||||
### Review blocker
|
||||
|
||||
The launched-process suite was signed-excluded from CI enumeration. Manual GREEN/R7 evidence therefore did not prove a PR workflow could detect regression.
|
||||
|
||||
### RED-first and canonical wiring
|
||||
|
||||
1. Removed the suite's signed exclusion before adding a CI execution path.
|
||||
2. `check-test-enumeration.sh` went RED with exact `UNENUMERATED` output for `test-start-agent-session.sh`: population 49, enumerated 30, excluded 18.
|
||||
3. Added both `framework/tools/fleet/test-start-agent-session.sh` and `framework/systemd/user/test-fleet-units.sh` to `@mosaicstack/mosaic`'s canonical `test:framework-shell` chain.
|
||||
4. The guard returned GREEN: population 49, enumerated 32, excluded 18, surfaces 45. The systemd suite is outside the guard's tools-only population but now has the same explicit canonical execution disposition.
|
||||
|
||||
### Workflow-level R7
|
||||
|
||||
- Deleted only the pane launch entry `"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"`.
|
||||
- Ran the exact `.woodpecker/ci.yml` test-step command, `pnpm test`, with only a temporary PATH-scoped npm shim reporting the checkout's current 0.0.49 version so the unrelated global 0.0.48 banner could not preempt the shell chain.
|
||||
- Result: exit 1 at `@mosaicstack/mosaic#test`, with the enumeration guard GREEN followed by `FAIL: runtime pane omitted or changed generated environment keys: MOSAIC_GIT_IDENTITY`.
|
||||
- Restored the launch entry. The canonical `test:framework-shell` chain then reached both newly wired suites and printed both GREEN markers before the known unrelated #973 host-only `BASH_LINENO` abort.
|
||||
- An actual provider PR workflow on the intentionally broken mutant is **NOT MEASURED**: the one-push constraint forbids pushing a red mutant and then a repaired head. Local execution proves the exact PR workflow command and dependency chain go RED on the subject deletion; CI on the repaired pushed head remains canonical.
|
||||
|
||||
### Workflow population
|
||||
|
||||
- **DEFINED:** 3 workflows (`ci.yml`, `ci-image.yml`, `publish.yml`).
|
||||
- **ELIGIBLE for `pull_request`:** 1/3 (`ci.yml`), based on top-level `when:` clauses.
|
||||
- **REPORTED:** Round-1 exact-head provider read reported 1/1 eligible context (`ci/woodpecker/pr/ci`). Post-remediation-head reported count is **NOT MEASURED** by this seat because CI polling is prohibited; workflow definitions and eligibility did not change.
|
||||
|
||||
### Independent remediation review
|
||||
|
||||
- First Round-2 review identified a CI-image blocker: the newly wired launcher suite used Perl, which the Alpine CI base does not install.
|
||||
- Replaced the suite's three Perl-only fixture mutations with POSIX/BusyBox-compatible `sed -i` substitutions; production behavior and assertions are unchanged.
|
||||
- Codex re-review: **APPROVE**, confidence 0.93, no findings.
|
||||
|
||||
### Vitest denominator reconciliation
|
||||
|
||||
The PR's `311/311` is correct for its explicitly named six-file command at both the original and remediation worktrees:
|
||||
|
||||
- generated environment boundary: 24
|
||||
- fleet documentation: 23
|
||||
- Tess service profile: 6
|
||||
- fleet regen command: 27
|
||||
- fleet agent CRUD command: 22
|
||||
- fleet command: 209
|
||||
- total: **311**
|
||||
|
||||
Review 97 reported 312/312 without naming its six files. That is a different or miscounted population and cannot replace the command-scoped 311 denominator; the PR follow-up will name the exact files and arithmetic.
|
||||
|
||||
## Round 3 — Alpine stale-marker portability
|
||||
|
||||
### Objective and plan
|
||||
|
||||
- Replace the GNU-only relative-date fixture with a deterministic POSIX/BusyBox timestamp while preserving the required stale-marker assertion.
|
||||
- Re-run the launcher suite in the canonical `ci-base:latest` Alpine image, then run applicable repository gates and independent review.
|
||||
- Update the PR body to name the repeated GNU-host/Alpine-CI portability pattern, run the mandatory queue guard, push once, verify provider attribution, and stop without CI polling.
|
||||
- Working budget: 8K tokens; scope is one fixture line plus delivery evidence. No production behavior changes.
|
||||
|
||||
### RED-first evidence
|
||||
|
||||
Before the fix, the canonical CI image command
|
||||
`docker run --rm -v "$PWD:/work" -w /work git.mosaicstack.dev/mosaicstack/stack/ci-base:latest bash packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
|
||||
exited 1 at the stale-marker setup with exact BusyBox output
|
||||
`touch: invalid date '10 seconds ago'`. The prior fresh-marker assertions had already executed, matching pipeline 2233's failure location.
|
||||
|
||||
### Root cause and fix
|
||||
|
||||
The test used GNU `touch -d` relative-date parsing although the PR workflow runs on Alpine/BusyBox. The fixture now uses POSIX `touch -t 200001010000.00`, a fixed timestamp that is unconditionally stale; the stale assertion remains mandatory and was not made tolerant of missing timestamp metadata.
|
||||
|
||||
### Structural pattern
|
||||
|
||||
This is the third GNU-host/Alpine-CI portability defect in the lane: GNU `grep` multi-match counting, Perl-only fixture mutation, and GNU `touch -d` date parsing. The repeated cause is shell suites authored on a GNU host but executed in an Alpine CI image; durable prevention belongs in CI-image execution or portability lint, not assertion weakening.
|
||||
|
||||
### GREEN and quality evidence
|
||||
|
||||
- Focused launcher suite in `ci-base:latest`: exit 0, `ok - start-agent-session generated environment boundary`.
|
||||
- Canonical test step in `ci-base:latest` with the pipeline's `pgvector/pgvector:pg17` service, readiness check, migration, and `pnpm test`: exit 0; 46/46 Turbo tasks; Mosaic 81/81 files and 1510/1510 tests; Gateway 57 passed/5 skipped files and 629 passed/11 skipped tests; enumeration 49 population / 32 enumerated / 18 signed exclusions / 45 named surfaces.
|
||||
- The first image-only `pnpm test` attempt lacked the pipeline PostgreSQL service and failed only on connection refusal after the launcher suite was GREEN. The rerun supplied the canonical service precondition and passed.
|
||||
- Canonical-image baseline: typecheck 45/45 tasks, lint 25/25 tasks, format check GREEN; `git diff --check` GREEN.
|
||||
- Independent Codex code review: APPROVE, confidence 0.96, 2/2 Round-3 files, no findings.
|
||||
- Independent Codex security review: risk none, confidence 0.99, 2/2 Round-3 files, no findings.
|
||||
|
||||
### Re-derived inventory and denominators
|
||||
|
||||
- Round-3 git delta: **2/2 files** — launcher suite and task scratchpad; 25 insertions / 1 deletion before evidence finalization.
|
||||
- Full PR path inventory against `origin/main` at `85d2108e`: **19/19 changed paths**; Round 3 adds no new PR path.
|
||||
- Workflow definition population: **1/3 pull-request-eligible** (`ci.yml` of `ci.yml`, `ci-image.yml`, `publish.yml`).
|
||||
- Do not re-litigate the settled 311/312 populations; both are valid for their separately named Tess6 and CRUD-core7 sets.
|
||||
|
||||
## Round 4 — bound stale-marker observation
|
||||
|
||||
### Objective and plan
|
||||
|
||||
- Make the heartbeat assertion discriminate an initially stale native marker from a fresh marker without changing the production staleness threshold or shortening the polling window.
|
||||
- Freeze only the sidecar's numeric observation clock during the stale-fixture arm so elapsed assertion time cannot turn a fresh mutant stale.
|
||||
- Prove two independent mutants RED: disable production stale-marker detection while retaining the stale fixture; replace the stale fixture with a fresh marker. Restore the tree and prove GREEN in the canonical Alpine image.
|
||||
- Re-derive the changed-path inventory, run applicable quality and independent review gates, commit with environment-only author/committer identity, queue-guard, push once, verify provider attribution using curl stdin config, and stop without CI polling.
|
||||
- Working budget: 8K tokens. Scope is the launcher test and its scratchpad evidence; production launcher behavior remains unchanged.
|
||||
|
||||
### Root cause and bounded observation
|
||||
|
||||
The 30 × 0.1-second assertion window overlaps the production `now - marker > interval * 2 + 1` threshold at interval 1. Depending on second boundaries and load, a fresh marker can age past the threshold before the assertion ends. A focused pre-fix fresh-mutant attempt returned RED while Review 101's full-suite run returned GREEN; the differing result is itself timing dependence, not a discriminating assertion.
|
||||
|
||||
The test now supplies a fixed numeric epoch only to the stale-fixture sidecar. Its real marker mtime is still read from the filesystem, but assertion runtime cannot advance `now`. Date formatting still delegates to the image's real `/bin/date`. Neither the production threshold nor the 30 × 0.1-second polling window changed.
|
||||
|
||||
### Two-mutant RED / restored GREEN
|
||||
|
||||
All three runs used `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest`:
|
||||
|
||||
1. **Stale-detection mutant RED:** replaced only the production stale-age predicate with `false` while retaining the fixed stale marker; suite exit 1 with `FAIL: heartbeat sidecar did not resume after native marker became stale or absent`.
|
||||
2. **Fresh-marker mutant RED:** replaced only `touch -t 200001010000.00` with fresh `touch`; suite exit 1 with the same failed stale-resumption assertion. The fixed observation epoch kept the mutant fresh throughout all 30 polls.
|
||||
3. **Restored tree GREEN:** suite exit 0 with `ok - start-agent-session generated environment boundary`.
|
||||
|
||||
### Re-derived inventory
|
||||
|
||||
- Round-4 delta: **2/2 files** — launcher test plus task scratchpad; production launcher delta is empty.
|
||||
- Full PR inventory against `origin/main`: **19/19 paths**; Round 4 adds no path.
|
||||
- Production stale threshold remains `now - marker > iv * 2 + 1`; assertion polling remains 30 × 0.1 seconds.
|
||||
- Review 101's confirmed enumeration/workflow/CI and attribution evidence is accepted without re-polling or re-derivation.
|
||||
|
||||
## Residual risk
|
||||
|
||||
- Landing on `main` does not update the currently installed host launcher. Host framework installation/reseed and Jarvis live-seat validation are separate downstream events.
|
||||
- Canonical CI result is pending and will not be polled by this seat.
|
||||
@@ -1,97 +0,0 @@
|
||||
# #1098 — Framework shell portability / red main
|
||||
|
||||
## Objective
|
||||
|
||||
Restore terminal-green `main` by making the `test-start-agent-session.sh` clean-environment assertion semantic and portable without removing either newly enumerated framework-shell suite.
|
||||
|
||||
## Scope
|
||||
|
||||
- Tracking issue: `mosaicstack/stack#1098`
|
||||
- Branch: `fix/framework-shell-portability`
|
||||
- Base: `origin/main` at `4fa2768962702d53e16e8b67ee6ad52ebcb0910e`
|
||||
- Primary file: `packages/mosaic/framework/tools/fleet/test-start-agent-session.sh`
|
||||
- Requirements source: `docs/PRD.md` § Framework shell assertion portability (#1098)
|
||||
- Out of scope: deployed files under `~/.config/mosaic`, pnpm-store cleanup, checkout deletion, and changes to the launcher’s `/usr/bin/env -i` behavior.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. The test inspects the captured NUL-delimited tmux argv semantically and accepts an adjacent `/usr/bin/env`, `-i` pair regardless of trailing payload size or pipe scheduling.
|
||||
2. Missing `/usr/bin/env`, missing `-i`, and non-adjacent `-i` remain failures.
|
||||
3. Failure output includes the observed argv records with stable indexes and shell escaping; it exposes no credentials because this fixture supplies only generated non-secret launch data.
|
||||
4. The focused suite passes on the dev host and in the repository CI image; the blocking PR/main pipeline returns terminal green.
|
||||
5. Independent review passes; PR is squash-merged and #1098 is closed only after merged-main CI is terminal green.
|
||||
|
||||
## Budget
|
||||
|
||||
- ASSUMPTION: 30K-token working budget; rationale: one shell-test defect plus full PR/CI lifecycle.
|
||||
- Auto-reduction: focused shell and package gates first; rely on canonical Woodpecker for the full monorepo suite rather than duplicating a dependency install under constrained `/home`.
|
||||
- Disk baseline before clone/build: `/home` 7.1G free (99% used), `/tmp` 2.4G free (92% used).
|
||||
|
||||
## Investigation
|
||||
|
||||
### First-hand CI evidence
|
||||
|
||||
- Public log: `GET https://ci.mosaicstack.dev/api/repos/47/logs/2269/53041`
|
||||
- Decoded 1,436 entries (11 null `data` entries treated as empty log rows), 190,756 bytes.
|
||||
- Failure: `FAIL: pane command did not clear its environment` immediately after the expected pane-PID warning.
|
||||
- BusyBox primitives, complete assertion pipeline, real CI image, stale/current image digests, Turbo cache masking, gateway failure, and heartbeat-sidecar concurrent writing were independently excluded.
|
||||
|
||||
### Root cause
|
||||
|
||||
The assertion ends in:
|
||||
|
||||
```bash
|
||||
printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i'
|
||||
```
|
||||
|
||||
The script has `set -o pipefail`. `grep -q` exits as soon as it finds the valid `-i` record. Upstream `tail`/`printf` can then receive SIGPIPE, making the aggregate pipeline nonzero even though grep returned 0 and the semantic property is true. This depends on payload size, pipe capacity, and scheduling, explaining a local/image pass with a CI failure.
|
||||
|
||||
Discriminating stress control with `/usr/bin/env` followed immediately by `-i`:
|
||||
|
||||
- 8,192-byte trailing payload: `printf=0 tail=0 grep=0`, aggregate 0.
|
||||
- 16,384-byte trailing payload: `printf=0 tail=141 grep=0`, aggregate 141.
|
||||
- 32,768+ bytes: `printf=141 tail=141 grep=0`, aggregate 141.
|
||||
- A full-reading `grep -xF` control remained 0 for every payload.
|
||||
|
||||
This is a third branch omitted by the earlier present-vs-corrupted split: the pair can be present and intact while `pipefail` reports an upstream SIGPIPE.
|
||||
|
||||
## TDD plan
|
||||
|
||||
1. RED: preserve the one-off stress reproducer above and add an automated large-argv semantic regression that fails under the current pipeline implementation.
|
||||
2. GREEN: parse the authoritative NUL-delimited capture into a Bash array and search for an adjacent `/usr/bin/env`, `-i` pair without a short-circuit pipeline.
|
||||
3. Add negative controls for missing, detached, and reversed tokens.
|
||||
4. On failure, print indexed `%q` argv records before returning nonzero.
|
||||
5. Run focused suite, mutation controls, shell syntax/format checks, then repository baseline gates feasible without dependency installation.
|
||||
6. Independent review, queue guard, push, PR, CI, coordinator merge authorization, squash merge, merged-main CI, issue close.
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] Checkout created and based on `origin/main` `4fa27689`.
|
||||
- [x] CI log decoded directly.
|
||||
- [x] Root-cause stress control reproduced semantic match + aggregate pipeline failure.
|
||||
- [x] RED evidence: intact `/usr/bin/env`, `-i` fixture produced component statuses `0/141/0` and aggregate 141 under the former `grep -q` pipeline; full-reading semantic control stayed 0.
|
||||
- [x] GREEN implementation: direct NUL-argv adjacency parser, indexed diagnostics, and full-reading scalar predicates replace all load-bearing early-exit pipelines in this test.
|
||||
- [x] Baseline/situational tests:
|
||||
- focused launcher suite: PASS on GNU host and cached Alpine CI image;
|
||||
- paired `test-fleet-units.sh`: PASS;
|
||||
- enumeration guard: PASS (`population=53`, `enumerated=36`, `excluded=18`), 14/14 mutation needles;
|
||||
- `bash -n`, ShellCheck, `git diff --check`: PASS;
|
||||
- static denominator after change: zero load-bearing `grep -q`/`head`/`-m1` pipeline candidates in `test-start-agent-session.sh`;
|
||||
- delete-the-subject mutation removing production `-i`: RED with 78 indexed argv records, byte count, and explicit boundary failure.
|
||||
- [x] Independent review:
|
||||
- first Codex review: request changes — negative fixtures did not each assert diagnostics;
|
||||
- remediation: centralized predicate + diagnostic wrapper and exercised all four negative fixtures;
|
||||
- second Codex review: APPROVE, 0 blockers/should-fix/suggestions;
|
||||
- Codex security review: risk none, 0 findings.
|
||||
- [ ] PR CI, formal fleet review, merge, merged-main CI, issue closure.
|
||||
|
||||
## Documentation disposition
|
||||
|
||||
- Updated canonical `docs/PRD.md` with FSP requirements and acceptance criteria.
|
||||
- This is an internal test/reliability change with no API, user workflow, deployment, navigation, or publishing-surface change; no user/admin/API/sitemap update is required.
|
||||
- `docs/TASKS.md` remains unchanged because the project contract makes it orchestrator-only.
|
||||
|
||||
## Risks
|
||||
|
||||
- The CI failure did not print its captured argv, so the exact CI payload is unavailable. The stress control proves the assertion is non-portable and can emit the exact false verdict; branch CI is the canonical confirmation that replacing it resolves pipeline 2269’s failure class.
|
||||
- Printing fixture argv is safe only while this test’s projection remains non-secret. The diagnostic must stay scoped to the test capture and shell-escaped.
|
||||
@@ -1,41 +0,0 @@
|
||||
# #1099 — pipefail + early-exit sweep
|
||||
|
||||
## Scope and decisions
|
||||
|
||||
- Baseline `df4c591ab42aa1ae62c12935fdc0e772684864a0`, after #1100 removed its 35 sites.
|
||||
- Split into review-sized non-closing tranches: runtime/general; tmux/git/quality tests; wake validation/tests.
|
||||
- Do not equate class membership with demonstrated risk. Do not use payload size or pipeline stage count as a safety proxy.
|
||||
- Preserve the issue's withdrawn findings for `qa-hook-stdin.sh` and the two fresh-directory `pnpm pack` lookups. Fix `install.sh:312` because malformed multi-root input must reach its named handler.
|
||||
|
||||
## Tranche 1 TDD
|
||||
|
||||
RED-first control: `node --test scripts/pipefail-early-exit.test.mjs` reported exactly 26 non-accepted runtime/general sites, including `install.sh:312`, and exited 1. A checked-in fixture generated from immutable baseline `df4c591a` records all 26 normalized sites; the control passes every fixture entry through the same scanner, asserts exact identity/count/uniqueness, and separately requires zero findings in the current tree. It also inventories accepted sites rather than silently excluding whole files.
|
||||
|
||||
Construction choices:
|
||||
|
||||
- here-string/file redirection for scalar grep assertions;
|
||||
- full capture then parameter expansion for first-line selection;
|
||||
- arrays/`mapfile` for complete populations;
|
||||
- direct jq/awk/grep selection where one tool can express the property;
|
||||
- no `|| true` added to a load-bearing assertion.
|
||||
|
||||
Site-by-site verdicts: `docs/reports/quality/1099-pipefail-sweep.md`.
|
||||
|
||||
## Tranche 2 TDD
|
||||
|
||||
Expanded the unconditional scanner over 11 non-wake test harnesses. RED named exactly 22 source lines; a second immutable-baseline fixture now asserts those 22 entries through the same scanner. Rewrites preserve command status by capturing producers before redirected assertions, use parameter expansion for line selection, and use complete `mapfile` populations where ordering matters. Current-tree finding count is zero for tranches 1 and 2.
|
||||
|
||||
## Tranche 3 TDD
|
||||
|
||||
Expanded the shared scanner over four wake validation harnesses. RED named 26 occurrences. The wake fixture asserts 26 occurrences / 25 normalized identities through the same scanner; all scalar assertions now use redirection, direct jq selection, complete capture, or consuming diagnostic ranges. Current-tree finding count is zero across the full scoped population.
|
||||
|
||||
## Verification so far
|
||||
|
||||
- `bash -n` on every changed shell script: pass.
|
||||
- structural Node control: pass.
|
||||
- `test-mutate-push-guard.sh`: 8/8 pass.
|
||||
- `test-send-message-verdict.sh`: 3/3 pass.
|
||||
- `test-send-message-socket.sh`: pass.
|
||||
- Independent review 143 found two semantic regressions: a help-probe `|| true` changed the failure truth table, and an unguarded Git capture changed non-Git data-dir behavior from rc 0 + JSON to silent rc 128. Both received RED-first regressions before correction; help status is now separate and required, and Git status remains condition-guarded.
|
||||
- Wake static inventory remains aligned at 261/261 after line-neutral rewrites; no static-set mismatch. Wake detector/reconcile/digest/preimage suites terminate at their existing fail-closed #973 `BASH_LINENO` environment probe (exit 97, observed `[3 5]`, expected `[3 4]`) before subject tests. No bypass or skip was used; canonical CI remains required.
|
||||
- ShellCheck reports only pre-existing source-following, unused-variable, and untouched `ls | head` findings; no new diagnostic was introduced.
|
||||
@@ -1,156 +0,0 @@
|
||||
# #1150 — Pi persistent goal extension
|
||||
|
||||
- **Task ID:** ISSUE-1150 (no `docs/TASKS.md` row; that file is orchestrator-only)
|
||||
- **Issue:** #1150 — `pi: add persistent /goal controller extension to Mosaic framework`
|
||||
- **Branch:** `feat/1150-pi-goal-extension`
|
||||
- **Mode:** Delivery
|
||||
- **Status:** in progress
|
||||
|
||||
## Objective
|
||||
|
||||
Build and locally validate a Mosaic-owned Pi `/goal` extension. Source must ship from
|
||||
`packages/mosaic/framework/runtime/pi/`, framework sync must deploy it under
|
||||
`~/.config/mosaic/runtime/pi/`, and no extension/configuration asset may be written into `~/.pi`.
|
||||
Pi's native session manager remains the owner of session entries.
|
||||
|
||||
## Scope and acceptance source
|
||||
|
||||
- Canonical requirements: `docs/PRD.md`, section **Pi Persistent Goal Loop (#1150)**.
|
||||
- User intent: continuous goal orientation and status checking after each Pi turn and compaction,
|
||||
tested locally before framework delivery.
|
||||
- Documentation target: canonical in-repo user/developer/runtime docs; no external publication.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- `ASSUMPTION:` Initial semantic verification uses two consecutive structured, evidence-bearing
|
||||
reports from the working agent rather than a second model request after every turn. This keeps the
|
||||
loop testable and avoids doubling model cost while making the limitation explicit.
|
||||
- `ASSUMPTION:` Default autonomous bounds are 40 turns and 6 repeated no-progress reports, with only
|
||||
bounded numeric environment overrides.
|
||||
- `ASSUMPTION:` A local smoke copy to `~/.config/mosaic/runtime/pi/goal-extension.ts` is authorized by
|
||||
the user's explicit request. Full framework reseed into the live home is not required for the smoke
|
||||
test and would touch unrelated framework-owned files.
|
||||
|
||||
## Budget
|
||||
|
||||
- Working estimate: 30K implementation/review tokens.
|
||||
- Hard user cap: none stated.
|
||||
- Cost control: deterministic fake-Pi tests; no nested evaluator calls; only bounded arithmetic/load
|
||||
smoke workflows against the installed runtime.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Update PRD and create tracking/scratchpad artifacts.
|
||||
2. Read launcher, installer ownership, Pi extension, and documentation surfaces.
|
||||
3. TDD: add fake-Pi behavior tests for commands, state restoration, turn checks, compaction, limits,
|
||||
verification, and continuation deduplication.
|
||||
4. Implement `runtime/pi/goal-extension.ts` and deterministic launcher discovery.
|
||||
5. Add framework-sync/deployment acceptance coverage.
|
||||
6. Update user, developer, runtime, framework README, and sitemap documentation.
|
||||
7. Run focused tests, local Mosaic-path smoke test, then baseline repository gates.
|
||||
8. Run independent review, remediate, commit, push/PR/CI/merge/issue closure per delivery gates.
|
||||
|
||||
## TDD decision
|
||||
|
||||
Applied. The continuation state machine and lifecycle scheduling are control-path logic where a race
|
||||
or false terminal state can cause unbounded work or premature completion.
|
||||
|
||||
## Progress checkpoints
|
||||
|
||||
- [x] Issue #1150 created through Mosaic wrapper.
|
||||
- [x] Isolated worktree created from `origin/main`.
|
||||
- [x] PRD requirements and acceptance criteria added.
|
||||
- [x] Task scratchpad created.
|
||||
- [x] RED controller and security-regression tests written and observed failing before implementation.
|
||||
- [x] Goal controller, launcher discovery, framework deployment coverage, and bounded state machine
|
||||
implemented.
|
||||
- [x] User, admin, developer, runtime, adapter, README, and sitemap documentation updated.
|
||||
- [x] Final source copied additively to `~/.config/mosaic/runtime/pi/goal-extension.ts`; source and
|
||||
deployed SHA-256 are identical.
|
||||
- [x] Live Pi RPC smoke from the exact Mosaic path reached `achieved` with two verification passes and
|
||||
no extension errors.
|
||||
- [x] Baseline and situational checks completed, except the explicitly documented unavailable
|
||||
PostgreSQL-only root integration case.
|
||||
- [x] Independent code and OWASP/security reviews completed; all findings remediated and re-reviewed.
|
||||
- [ ] Commit, push, PR, terminal-green CI, squash merge, and issue closure complete.
|
||||
|
||||
## Tests and evidence
|
||||
|
||||
### Situational
|
||||
|
||||
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/runtime/pi-goal-extension.spec.ts`
|
||||
- final: 25 passed.
|
||||
- Covers commands, per-turn checks, context injection, two-pass verification, mixed-report
|
||||
rejection, bounded limits, compaction, branch restore, stale timers, credential redaction,
|
||||
typed-field false-positive protection, and append-only legacy-state fail-closed behavior.
|
||||
- Final focused launcher/controller/file-adapter run: 3 files / 67 tests passed.
|
||||
- Final V8 coverage for `framework/runtime/pi/goal-extension.ts`:
|
||||
- 99.17% statements/lines, 93.78% branches, 100% functions.
|
||||
- Installer migration fixture: 24 passed and byte-compared the deployed framework asset.
|
||||
- Standalone extension TypeScript check against installed Pi 0.84.1 types passed:
|
||||
`pnpm --filter @mosaicstack/mosaic exec tsc --noEmit --pretty false --module NodeNext
|
||||
--moduleResolution NodeNext --target ES2022 --skipLibCheck framework/runtime/pi/goal-extension.ts`.
|
||||
- Live deployment/load evidence:
|
||||
- source/deployed SHA-256:
|
||||
`1f0a3806e0948ad5f49684273a7e535e9880c148f7fd16d13ee487fcd601f637`.
|
||||
- `get_commands` identified `/goal` as an extension command sourced from
|
||||
`~/.config/mosaic/runtime/pi/goal-extension.ts`; `/goal help` succeeded; zero extension errors.
|
||||
- live arithmetic goal ended `achieved`, verification `2/2`, with 3 goal reports / 3 agent starts
|
||||
and zero extension errors.
|
||||
- no goal extension exists under `~/.pi` extension paths.
|
||||
|
||||
### Baseline
|
||||
|
||||
- `pnpm build`: passed before the final framework-only redaction remediation; the extension is not a
|
||||
package build input and its final source passed the standalone Pi type check.
|
||||
- `pnpm typecheck`: 45/45 tasks passed.
|
||||
- `pnpm lint`: 25/25 tasks passed.
|
||||
- `pnpm format:check`: passed.
|
||||
- Final Mosaic package components:
|
||||
- Vitest: 82 files / 1,539 tests passed.
|
||||
- full `test:framework-shell` harness passed.
|
||||
- the discovered pre-existing tmux loader-marker race was reproduced with constructor PID
|
||||
evidence, fixed with a pane readiness/FIFO barrier, passed 3 consecutive focused runs, and passed
|
||||
in the full shell harness.
|
||||
- one combined rerun encountered the separate existing real-lease probe TOCTOU in
|
||||
`install-ordering-guard.spec.ts`; an earlier final Vitest run was fully green and the changed
|
||||
focused suites remained green.
|
||||
- Gateway safe baseline excluding the prohibited PostgreSQL-only fixture: 55 files / 600 tests passed
|
||||
(6 files / 12 tests skipped by their existing environment gates).
|
||||
- Root `pnpm test` reached 43 successful workspace tasks and all changed-package Vitest tests, but
|
||||
the unchanged `apps/gateway/src/__tests__/cross-user-isolation.test.ts` afterAll hook retried a
|
||||
PostgreSQL connection and failed authentication (`28P01`). This checkout explicitly forbids local
|
||||
PostgreSQL startup/access; the failure is unrelated to #1150 and cannot be remediated by starting
|
||||
the database. The gateway suite excluding that PostgreSQL-only file and required CI are used as
|
||||
the safe verification paths.
|
||||
|
||||
### Independent review
|
||||
|
||||
- Codex code review: approved, 0 findings across 15 files.
|
||||
- Initial Codex security review: one medium CWE-532/A09 finding for raw report persistence.
|
||||
- Remediation added central credential-pattern redaction, prompt/docs guidance, canary tests, typed
|
||||
field false-positive guards, and sticky fail-closed restore for credential-bearing append-only
|
||||
history.
|
||||
- Codex security re-review: risk `none`, 0 findings, confidence 0.87.
|
||||
- Focused remediation review findings were fixed; final focused re-review verdict: `APPROVE`.
|
||||
- Focused independent review of the tmux readiness barrier: `APPROVE`, no actionable findings.
|
||||
|
||||
## Risks and blockers
|
||||
|
||||
- Live `~/.config/mosaic` is shared by active Pi/fleet processes. Local deployment remained a single
|
||||
additive framework file and did not reload or restart unrelated sessions.
|
||||
- Completion verification is semantic, not mathematical: the active agent supplies structured
|
||||
evidence twice. Operators must still inspect consequential outcomes.
|
||||
- Credential redaction is pattern-based defense-in-depth, not a secret store. It covers
|
||||
controller-owned state/status/tool details, not Pi's separate model-message/tool-call history.
|
||||
Goals and reports must never contain real secrets or raw sensitive output. Because Pi session
|
||||
entries are append-only, a detected credential-bearing legacy branch fails closed and the affected
|
||||
session must be removed.
|
||||
- Current installed Pi is newer than the repository's historical gateway Pi dependency. The
|
||||
extension was checked and smoke-tested against installed Pi 0.84.1 using stable documented APIs.
|
||||
- Local root testing cannot safely execute the unchanged PostgreSQL-only integration fixture under
|
||||
the checkout's explicit database safety constraints. Terminal-green PR CI remains mandatory before
|
||||
merge.
|
||||
- The unchanged real-lease default-probe test can observe different broker availability across its two
|
||||
sequential probes; one combined package rerun hit that existing TOCTOU. The same final Vitest suite
|
||||
passed in a separate run, and CI remains the merge authority.
|
||||
@@ -1,89 +0,0 @@
|
||||
# #1174 — Wrapper guard rounds 10–11
|
||||
|
||||
## Objective
|
||||
|
||||
Make checkout enforcement judge Git placement operands rather than every HOME-shaped word in the command, without reopening `--separate-git-dir` placement under HOME.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Reproduce the four over-blocks and the placement-option control at head `20d86e39`.
|
||||
2. Add RED fixtures before production changes.
|
||||
3. Extract clone/worktree placement operands from the existing shell-aware normalized stream.
|
||||
4. Run the full guard corpus, historical-head discrimination, syntax/static checks, probes, review, and CI.
|
||||
|
||||
## Progress and evidence
|
||||
|
||||
- Reproduced: `NOTE=$HOME`, `--reference=$HOME`, `GIT_DIR=$HOME/x`, and `--template=$HOME/t` all blocked despite explicit `/src/wt` destinations.
|
||||
- RED at `20d86e39`: expanded suite had 8 failures, all HOME-valued non-placement cases.
|
||||
- GREEN: expanded suite passes 242/242.
|
||||
- Round-10 probes: 7/7 placement expectations and 4/4 placement-option controls pass.
|
||||
- Earlier path probes remain green: 60/60, 24/24, and 17/17.
|
||||
- Historical discrimination with the 242-fixture suite:
|
||||
- `3d0a882a`: 216 pass / 26 fail.
|
||||
- `4b8eba95`: 222 pass / 20 fail.
|
||||
- `20d86e39`: 234 pass / 8 fail.
|
||||
- `bash -n`, ShellCheck warning-or-higher, and `git diff --check`: pass.
|
||||
|
||||
## Residual / risk
|
||||
|
||||
- Relative destinations whose effective path depends on cwd are tracked separately by #1197 and remain out of scope.
|
||||
- Unknown future Git options with a separate following value fail closed when that value is HOME-shaped. This may require classification when Git adds an unrelated path-taking option, but prevents a new placement option from silently bypassing the guard.
|
||||
|
||||
## Round 11 objective and intake
|
||||
|
||||
- **Issue / PR:** #1174.
|
||||
- **Objective:** Remove the finite boolean-flag allowlists that turn accepted clone/worktree flags into fake placement operands, while preserving all real HOME placement blocks.
|
||||
- **Scope:** `wrapper-guard.sh`, its hermetic fixtures, and task documentation. Relative cwd-dependent destinations remain in #1197.
|
||||
- **Surfaces:** security-sensitive Bash hook behavior and shell/Git option grammar; no API, DB, UI, auth, deploy, or dependency changes.
|
||||
- **Budget assumption:** 25K working tokens; reduce exploratory matrices before reducing acceptance coverage.
|
||||
|
||||
### Round 11 plan
|
||||
|
||||
1. Use Git itself to classify accepted/rejected clone and worktree options, and Bash itself to resolve path-word expectations.
|
||||
2. Add RED fixtures for all six reported clone flags, generated negations, and equivalent worktree grammar.
|
||||
3. Replace the open-ended unknown-option fail-closed fallback with a parser based on the closed value-taking option surface; keep explicit placement options special.
|
||||
4. Run the full corpus, historical discrimination, shell/static checks, targeted probes, independent code/security review, one push, and exact-head CI.
|
||||
|
||||
### Root-cause evidence
|
||||
|
||||
- Git 2.39.5 accepts all six reported clone flags and the broader generated family measured in the brief: `--bare`, `--mirror`, `--ipv4`, `--ipv6`, `-4`, `-6`, `--no-local`, `--no-reject-shallow`, `--no-bare`, `--no-sparse`, `--no-dissociate`, `--no-shallow-submodules`, `--no-quiet`, `--no-progress`, and `--no-recurse-submodules`; it rejects `--relative-paths` as unknown.
|
||||
- Git 2.39.5 accepts worktree negations including `--no-force`, `--no-detach`, `--no-lock`, `--no-guess-remote`, and `--no-track`; the current finite worktree flag list does not describe that generated family.
|
||||
- `bash -c "printf '%s' <word>"` resolves `$HOME/source`, `${HOME}/source`, and `"$HOME"/source` under HOME while `/src/wt` remains outside it.
|
||||
- **Hypothesis:** only separate-value options need positive classification. Treat every other option token as a no-value flag unless it is the explicit placement option; this matches Git's non-enumerable boolean family and confines the residual to genuinely new future value-taking options.
|
||||
|
||||
### TDD and verification checkpoints
|
||||
|
||||
- RED against the unmodified `91cc37bc` guard: 253 pass / 22 fail in the initial expanded 275-fixture suite. Failures include all 15 accepted clone flags, accepted long abbreviations, short value-taking bundles, abbreviated placement, worktree metadata abbreviation, and both directions of bundled worktree branch parsing.
|
||||
- An exploratory fail-closed residual test drove emission of every worktree positional. Re-review correctly showed that this over-blocked HOME-shaped commit-ish metadata; a new commit-ish fixture failed RED against that intermediate implementation (278 pass / 2 fail, including one transient message assertion) and the parser was restored to emit only the actual path.
|
||||
- GREEN after remediation: 280/280.
|
||||
- Ultron's 13-shape option probe: 13/13 correct, including the six reported over-blocks, HOME destinations, end-of-options, worktree controls, and a later-command placement.
|
||||
- Round-10 probes remain green: 7/7 subject-placement expectations and 4/4 `--separate-git-dir` controls.
|
||||
- Earlier shell/path probes remain green: 60/60, 24/24, and 17/17.
|
||||
- `bash -n`, ShellCheck warning-or-higher, and `git diff --check`: pass.
|
||||
|
||||
### Deliberate residual
|
||||
|
||||
A future Git release could add a new separate-value option absent from the closed value grammar. It defaults to no-value flag parsing, which leaves the following word positional. For clone, this can fail open if that future option itself creates repository state at its value. For worktree, it can shift which word is read as the path. This hypothetical future ambiguity is accepted deliberately because failing closed on every unclassified option is proven to over-block Git's open-ended present-day boolean/`--no-*` family. Every value-taking and placement option Git currently supports is classified, including accepted abbreviations of `--separate-git-dir`. Relative cwd-dependent targets remain in #1197.
|
||||
|
||||
### Independent review checkpoint
|
||||
|
||||
- Initial Codex code/security review raised `--orphan` as value-taking. Upstream Git `master` contradicts that premise: the synopsis is `[--orphan] [(-b | -B) <new-branch>] <path> [<commit-ish>]`, and the prose derives the branch from the path when `-b`/`-B` is absent. `--orphan` is therefore correctly handled as a boolean flag.
|
||||
- The security review separately identified the generic future worktree shift residual. An attempted fail-closed remediation emitted every positional, but code re-review correctly rejected it because valid grammar has only one placement positional and an optional commit-ish. Final behavior checks only the path and documents the hypothetical future option shift deliberately; paired actual-grammar `--orphan` fixtures cover safe/HOME paths and `-b` metadata.
|
||||
- Security re-review initially had no findings. Code re-review's commit-ish blocker was remediated with a RED fixture and path-only restoration; final code re-review approved with no findings.
|
||||
- Final security review then found non-canonical absolute and symlink aliases. Eight lexical fixtures failed RED against the prior implementation, followed by three symlink fixtures failing RED. Remediation expands only shell-visible HOME tokens, resolves the longest existing directory prefix physically, and lexically normalizes the nonexistent suffix. The suite is now 292/292.
|
||||
- Inherent residual: a symlink can be replaced between pre-tool inspection and Git execution. Existing aliases are resolved; eliminating the race requires enforcement inside the filesystem mutation path rather than a text pre-hook. Security review classified this medium, and architectural closure is tracked in #1199.
|
||||
- Final independent code review: APPROVE, 0 findings. Final security review: no critical/high findings; the single medium TOCTOU residual is explicitly tracked in #1199.
|
||||
|
||||
### Final local evidence
|
||||
|
||||
- Final hermetic suite: 292/292; the same suite against `91cc37bc` discriminates at 256 pass / 36 fail.
|
||||
- Ultron option probe: 13/13; round-10 probes: 7/7 plus 4/4 controls; earlier shell/path probes: 60/60, 24/24, and 17/17.
|
||||
- `bash -n`, ShellCheck warning-or-higher, `git diff --check`, sanitization gate, and test-enumeration gate (population 55; 38 enumerated; 18 signed exclusions): pass.
|
||||
- Independent code review: APPROVE, 0 findings. Security review's remaining medium TOCTOU architecture residual is tracked in #1199; no critical/high findings remain.
|
||||
- Repository-wide TypeScript gates require dependencies absent from this worktree; the canonical Woodpecker pipeline will run them against the pushed exact head.
|
||||
|
||||
### Documentation checklist
|
||||
|
||||
- `docs/PRD.md` updated with WPG requirements, acceptance, canonicalization, and residual risk.
|
||||
- Task scratchpad updated in the same logical change set; `docs/TASKS.md` remains orchestrator-only.
|
||||
- No API, auth, UI, navigation, deployment, user-guide, or admin-guide surface changed; OpenAPI, endpoint index, sitemap, and publishing are not applicable.
|
||||
@@ -1,71 +0,0 @@
|
||||
# #1194 — Installed framework-tool drift detection and refresh analysis
|
||||
|
||||
## Decision
|
||||
|
||||
The reported queue-guard source defect was already fixed on `main` by `58b971ab`; the live failure came from a stale `~/.config/mosaic/tools/git/ci-queue-wait.sh`. The durable fix is therefore a detector, not a duplicate queue-guard patch.
|
||||
|
||||
`mosaic doctor` now compares the framework tools bundled with the executing Mosaic package against the deployed tools tree. Doctor is the selected visibility boundary because it is observational and operator-invoked: unlike session start, it does not add a repository/network scan to every seat launch, and it cannot silently replace identity or messaging tools while seats are active. It reports drift without changing files. `--fail-on-warn` converts detected drift into a non-zero doctor result.
|
||||
|
||||
## Classification
|
||||
|
||||
The existing `framework-manifest.txt` is authoritative. The detector invokes the canonical shared `tools/_lib/manifest.sh classify` implementation over the complete source census and refuses missing, unreadable, malformed, incomplete, or zero-framework ownership output. Policy is therefore read rather than duplicated:
|
||||
|
||||
- Current policy classifies source files under `tools/**` as framework-owned and required in the deployed tools tree.
|
||||
- Current policy explicitly classifies `tools/_lib/credentials.json` operator-owned and excludes it from byte comparison; future policy changes take effect without a detector edit.
|
||||
- A file present only in the deployed tools tree is operator-owned/unknown by the manifest's fail-safe default. The detector reports it as `INSTALLED_ONLY operator-or-unknown` under `--verbose` but does not fail or delete it.
|
||||
- Empty/partial source traversal, unreadable directories/files, symlinked census entries, root aliases, and descendant source aliases all return `CANNOT_ASSERT` rather than manufacturing agreement.
|
||||
|
||||
This means `NOT_INSTALLED` is not suppressed by filename guesses such as “test” or “README”: if it ships below source `tools/**`, the installer contract says it should be installed. Source-only implementation files outside `tools/**` are outside this detector population by construction.
|
||||
|
||||
## Current host analysis (observation only; no refresh performed)
|
||||
|
||||
A direct source-vs-installed census showed broad drift, including identity and messaging behavior:
|
||||
|
||||
- Identity/provider operations: stale `git/detect-platform.sh`, `issue-comment.sh`, `issue-create.sh`, `issue-close.sh`, `issue-view.sh`, `pr-create.sh`, `pr-merge.sh`, `pr-review.sh`, `pr-metadata.sh`; missing `pr-edit.sh` and several identity/read-back regression tools.
|
||||
- Messaging/session: stale `tmux/agent-send.sh`, `tmux/send-message.sh`, their regressions, and `fleet/start-agent-session.sh`.
|
||||
- Gate enforcement: stale `git/ci-queue-wait.sh`; missing the queue tri-state/process-level suites and terminal-green verifier.
|
||||
- Lease/QA behavior: stale lease-broker launch/mutation/receipt tools and QA hooks.
|
||||
|
||||
Counts vary with source head and installed local/operator files; the detector prints measured counts every run rather than baking this snapshot into policy.
|
||||
|
||||
## Reviewed refresh command — analyse only, do not run during active seats
|
||||
|
||||
Use the package/release updater's manifest-driven keep-mode sync during a quiet maintenance window:
|
||||
|
||||
```bash
|
||||
MOSAIC_SYNC_ONLY=1 \
|
||||
MOSAIC_INSTALL_MODE=keep \
|
||||
MOSAIC_HOME="$HOME/.config/mosaic" \
|
||||
bash /path/to/reviewed/@mosaicstack/mosaic/framework/install.sh
|
||||
```
|
||||
|
||||
For the globally installed package, resolve the reviewed installer rather than guessing its path:
|
||||
|
||||
```bash
|
||||
PACKAGE_ROOT="$(dirname "$(node -p "require.resolve('@mosaicstack/mosaic/package.json')")")"
|
||||
MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep MOSAIC_HOME="$HOME/.config/mosaic" \
|
||||
bash "$PACKAGE_ROOT/framework/install.sh"
|
||||
```
|
||||
|
||||
Do not run this while agent seats are active: the stale set includes identity selection, provider mutation, messaging, queue/merge guards, lease enforcement, and session launch. Syncing those files in place can change behavior between a seat's preflight and mutation.
|
||||
|
||||
## Post-refresh verification
|
||||
|
||||
1. Run `mosaic doctor --fail-on-warn`; require `stale=0 not-installed=0` from the framework drift summary (other unrelated doctor warnings must also be adjudicated).
|
||||
2. Re-run the constructed process-level queue probes against the **installed path**, not the source checkout. Use the source suite while overriding its subject path in a reviewed scratch copy, or reproduce these exact observations:
|
||||
- pending provider payload: guard must print `state=pending`, print the pending context, wait, and exit non-zero/timeout — never return immediately with rc 0;
|
||||
- malformed payload: guard must print `state=malformed` and exit non-zero;
|
||||
- unsupported but valid status vocabulary: guard must print `state=unknown` and exit non-zero.
|
||||
3. Run provider author read-back for one deliberately low-risk wrapper operation before resuming fleet mutation work; wrapper self-report is not identity evidence.
|
||||
4. Relaunch seats only after the quiet-window verification, because existing processes retain loaded environment/context.
|
||||
|
||||
## Probe evidence
|
||||
|
||||
The detector regression constructs a stale installed tool plus a missing shipped tool and observes rc 1 with distinct `STALE` and `NOT_INSTALLED` lines. That case would pass or be invisible before this change because no installed-vs-shipped comparison existed. Additional review-red controls prove:
|
||||
|
||||
- empty and unreadable source censuses return `CANNOT_ASSERT` (they returned clean rc 0 at the first PR head);
|
||||
- deleting the manifest returns `CANNOT_ASSERT`, while changing manifest ownership changes the verdict through the canonical resolver (the first head never opened the manifest);
|
||||
- root and descendant symlink/source aliases cannot return clean (the first head returned clean for a source-backed installed subtree);
|
||||
- a checker hung during doctor is terminated by a bounded watchdog, emits `CANNOT_ASSERT`, and doctor reaches its final warnings line (the first head hung and suppressed the remaining audit).
|
||||
|
||||
Controls retain byte-identical success, exact credential carve-out behavior, and installed-only preservation.
|
||||
@@ -0,0 +1,242 @@
|
||||
# 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,6 +34,7 @@ export default tseslint.config(
|
||||
'packages/storage/vitest.config.ts',
|
||||
'packages/mosaic/vitest.config.ts',
|
||||
'packages/mosaic/__tests__/*.ts',
|
||||
'packages/forge/__tests__/*.ts',
|
||||
'tools/federation-harness/*.ts',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"preflight": "node scripts/preflight.mjs",
|
||||
"clean:generated": "node scripts/clean-generated.mjs",
|
||||
"typecheck": "pnpm preflight && turbo run typecheck",
|
||||
"verify:release": "node scripts/verify-release.mjs",
|
||||
"test:checkout": "node --test scripts/*.test.mjs",
|
||||
"test": "pnpm test:checkout && turbo run test && pnpm run test:installer",
|
||||
"test:installer": "bash tools/install-next-lane.test.sh",
|
||||
|
||||
@@ -539,3 +539,43 @@ Not every brief needs full Board of Directors review. The classification system
|
||||
### 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`.
|
||||
|
||||
---
|
||||
|
||||
## 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`.
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
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,
|
||||
getPipelineStatus,
|
||||
} from '../src/pipeline-runner.js';
|
||||
import type { ForgeTask, RunManifest, TaskExecutor } from '../src/types.js';
|
||||
import type { TaskResult } from '@mosaicstack/macp';
|
||||
import type { ForgeTask, ForgeTaskResult, RunManifest, TaskExecutor } from '../src/types.js';
|
||||
import { gateLabel, isCommandGate } from '../src/outcomes.js';
|
||||
|
||||
/** Mock TaskExecutor that records submitted tasks and returns success. */
|
||||
/** Mock TaskExecutor that records submitted tasks and returns typed results. */
|
||||
function createMockExecutor(options?: {
|
||||
failStage?: string;
|
||||
}): TaskExecutor & { submittedTasks: ForgeTask[] } {
|
||||
@@ -25,7 +25,7 @@ function createMockExecutor(options?: {
|
||||
async submitTask(task: ForgeTask) {
|
||||
submittedTasks.push(task);
|
||||
},
|
||||
async waitForCompletion(taskId: string): Promise<TaskResult> {
|
||||
async waitForCompletion(taskId: string): Promise<ForgeTaskResult> {
|
||||
const failStage = options?.failStage;
|
||||
const task = submittedTasks.find((t) => t.id === taskId);
|
||||
const stageName = task?.metadata?.['stageName'] as string | undefined;
|
||||
@@ -33,7 +33,8 @@ function createMockExecutor(options?: {
|
||||
if (failStage && stageName === failStage) {
|
||||
return {
|
||||
task_id: taskId,
|
||||
status: 'failed',
|
||||
outcome: 'failed',
|
||||
reason: 'mock task failure',
|
||||
completed_at: new Date().toISOString(),
|
||||
exit_code: 1,
|
||||
gate_results: [],
|
||||
@@ -41,10 +42,17 @@ function createMockExecutor(options?: {
|
||||
}
|
||||
return {
|
||||
task_id: taskId,
|
||||
status: 'completed',
|
||||
outcome: 'passed',
|
||||
reason: 'mock verified',
|
||||
completed_at: new Date().toISOString(),
|
||||
exit_code: 0,
|
||||
gate_results: [],
|
||||
gate_results: (task?.qualityGates ?? [])
|
||||
.filter((gate) => isCommandGate(gate))
|
||||
.map((gate) => ({
|
||||
gate: gateLabel(gate),
|
||||
outcome: 'passed' as const,
|
||||
reason: 'mock verified',
|
||||
})),
|
||||
};
|
||||
},
|
||||
async getTaskStatus() {
|
||||
@@ -156,12 +164,13 @@ describe('runPipeline', () => {
|
||||
const executor = createMockExecutor();
|
||||
const result = await runPipeline(briefPath, tmpDir, {
|
||||
executor,
|
||||
stages: ['00-intake', '00b-discovery'],
|
||||
stages: ['00-intake', '05-coding'],
|
||||
});
|
||||
|
||||
expect(result.runId).toMatch(/^\d{8}-\d{6}$/);
|
||||
expect(result.stages).toEqual(['00-intake', '00b-discovery']);
|
||||
expect(result.stages).toEqual(['00-intake', '05-coding']);
|
||||
expect(result.manifest.status).toBe('completed');
|
||||
expect(result.manifest.mode).toBe('normal');
|
||||
expect(executor.submittedTasks).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -180,12 +189,17 @@ describe('runPipeline', () => {
|
||||
const executor = createMockExecutor();
|
||||
const result = await runPipeline(briefPath, tmpDir, {
|
||||
executor,
|
||||
stages: ['00-intake', '00b-discovery'],
|
||||
stages: ['00-intake', '05-coding'],
|
||||
});
|
||||
|
||||
const manifest = loadManifest(result.runDir);
|
||||
expect(manifest.stages['00-intake']?.status).toBe('passed');
|
||||
expect(manifest.stages['00b-discovery']?.status).toBe('passed');
|
||||
expect(manifest.stages['05-coding']?.status).toBe('passed');
|
||||
expect(manifest.stages['05-coding']?.gateResults?.map((g) => g.outcome)).toEqual([
|
||||
'passed',
|
||||
'passed',
|
||||
'passed',
|
||||
]);
|
||||
});
|
||||
|
||||
it('respects CLI class override', async () => {
|
||||
@@ -215,7 +229,7 @@ describe('runPipeline', () => {
|
||||
const executor = createMockExecutor();
|
||||
await runPipeline(briefPath, tmpDir, {
|
||||
executor,
|
||||
stages: ['00-intake', '00b-discovery', '02-planning-1'],
|
||||
stages: ['00-intake', '05-coding', '08-test'],
|
||||
});
|
||||
|
||||
expect(executor.submittedTasks[0]!.dependsOn).toBeUndefined();
|
||||
@@ -224,14 +238,14 @@ describe('runPipeline', () => {
|
||||
});
|
||||
|
||||
it('handles stage failure', async () => {
|
||||
const executor = createMockExecutor({ failStage: '00b-discovery' });
|
||||
const executor = createMockExecutor({ failStage: '05-coding' });
|
||||
|
||||
await expect(
|
||||
runPipeline(briefPath, tmpDir, {
|
||||
executor,
|
||||
stages: ['00-intake', '00b-discovery'],
|
||||
stages: ['00-intake', '05-coding'],
|
||||
}),
|
||||
).rejects.toThrow('Stage 00b-discovery failed');
|
||||
).rejects.toThrow('Stage 05-coding failed');
|
||||
});
|
||||
|
||||
it('marks manifest as failed on stage failure', async () => {
|
||||
@@ -270,30 +284,143 @@ describe('resumePipeline', () => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('resumes from first incomplete stage', async () => {
|
||||
// First run fails on discovery
|
||||
const executor1 = createMockExecutor({ failStage: '00b-discovery' });
|
||||
let runDir: string;
|
||||
it('resumes from first incomplete stage and fails closed at the next provider gate', async () => {
|
||||
// Simulate a run whose authority stages were approved out-of-band
|
||||
// (recorded as passed) and whose coding stage failed mechanically.
|
||||
const runId = '20260101-000000';
|
||||
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' },
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await runPipeline(briefPath, tmpDir, {
|
||||
executor: executor1,
|
||||
stages: ['00-intake', '00b-discovery', '02-planning-1'],
|
||||
});
|
||||
} catch {
|
||||
// expected
|
||||
// Resume re-runs 05-coding (the first non-passed stage), then fails
|
||||
// closed at 06-review because no reviewer provider is wired.
|
||||
const executor = createMockExecutor();
|
||||
await expect(resumePipeline(runDir, executor)).rejects.toMatchObject({
|
||||
name: 'ForgeCapabilityError',
|
||||
code: 'FORGE_NO_REVIEWER',
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
const runsDir = path.join(tmpDir, '.forge', 'runs');
|
||||
runDir = path.join(runsDir, fs.readdirSync(runsDir)[0]!);
|
||||
it('fails closed on resume when the next stage needs authority sign-off', async () => {
|
||||
const runId = '20260101-000001';
|
||||
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' },
|
||||
},
|
||||
});
|
||||
|
||||
// Resume should pick up from 00b-discovery
|
||||
const executor2 = createMockExecutor();
|
||||
const result = await resumePipeline(runDir, executor2);
|
||||
const executor = createMockExecutor();
|
||||
await expect(resumePipeline(runDir, executor)).rejects.toMatchObject({
|
||||
name: 'ForgeCapabilityError',
|
||||
code: 'FORGE_AUTHORITY_REQUIRED',
|
||||
});
|
||||
|
||||
expect(result.manifest.status).toBe('completed');
|
||||
// Should have re-run from 00b-discovery onward
|
||||
expect(result.stages[0]).toBe('00b-discovery');
|
||||
const manifest = loadManifest(runDir);
|
||||
expect(manifest.stages['00b-discovery']?.status).toBe('waiting-for-authority');
|
||||
expect(manifest.status).toBe('waiting-for-authority');
|
||||
});
|
||||
|
||||
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,7 +95,14 @@ export function generateBoardTasks(
|
||||
briefPath,
|
||||
resultPath: resultRelPath,
|
||||
timeoutSeconds: 120,
|
||||
qualityGates: ['true'],
|
||||
qualityGates: [
|
||||
{
|
||||
kind: 'authority',
|
||||
capability: 'board-approval',
|
||||
reason:
|
||||
'persona evaluation is judged by board synthesis (authority review); no mechanical gate exists',
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
personaName: persona.name,
|
||||
personaSlug: persona.slug,
|
||||
@@ -121,7 +128,13 @@ export function generateBoardTasks(
|
||||
timeoutSeconds: 120,
|
||||
dependsOn: personaTaskIds,
|
||||
dependsOnPolicy: 'all_terminal',
|
||||
qualityGates: ['true'],
|
||||
qualityGates: [
|
||||
{
|
||||
kind: 'authority',
|
||||
capability: 'board-approval',
|
||||
reason: 'board synthesis is an authority decision; no mechanical gate exists',
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
resultOutputPath: synthesisResult,
|
||||
inputResultPaths: personaResultPaths,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Command } from 'commander';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { registerForgeCommand } from './cli.js';
|
||||
import { loadManifest } from './pipeline-runner.js';
|
||||
|
||||
describe('registerForgeCommand', () => {
|
||||
it('registers a "forge" command on the parent program', () => {
|
||||
@@ -55,3 +59,94 @@ describe('registerForgeCommand', () => {
|
||||
}).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');
|
||||
});
|
||||
});
|
||||
|
||||
+122
-48
@@ -5,37 +5,47 @@ import type { Command } from 'commander';
|
||||
|
||||
import { classifyBrief } from './brief-classifier.js';
|
||||
import { STAGE_LABELS, STAGE_SEQUENCE } from './constants.js';
|
||||
import { ForgeCapabilityError } from './errors.js';
|
||||
import { getEffectivePersonas, loadBoardPersonas } from './persona-loader.js';
|
||||
import { generateRunId, getPipelineStatus, loadManifest, runPipeline } from './pipeline-runner.js';
|
||||
import type { PipelineOptions, RunManifest, TaskExecutor } 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;
|
||||
},
|
||||
};
|
||||
import { createSimulatedExecutor } from './simulated-executor.js';
|
||||
import type { PipelineOptions, RunManifest, RunMode } from './types.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 {
|
||||
if (!startedAt || !completedAt) return '-';
|
||||
const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime();
|
||||
@@ -44,19 +54,24 @@ function formatDuration(startedAt?: string, completedAt?: string): string {
|
||||
}
|
||||
|
||||
function printManifestTable(manifest: RunManifest): void {
|
||||
const mode = runModeOf(manifest);
|
||||
console.log(`\nRun ID : ${manifest.runId}`);
|
||||
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(`Class : ${manifest.briefClass} (${manifest.classSource})`);
|
||||
console.log(`Updated: ${manifest.updatedAt}`);
|
||||
console.log('');
|
||||
console.log('Stage'.padEnd(22) + 'Status'.padEnd(14) + 'Duration');
|
||||
console.log('-'.repeat(50));
|
||||
console.log('Stage'.padEnd(22) + 'Status'.padEnd(24) + 'Duration');
|
||||
console.log('-'.repeat(60));
|
||||
for (const stage of STAGE_SEQUENCE) {
|
||||
const s = manifest.stages[stage];
|
||||
if (!s) continue;
|
||||
const label = (STAGE_LABELS[stage] ?? stage).padEnd(22);
|
||||
const status = s.status.padEnd(14);
|
||||
const status = s.status.padEnd(24);
|
||||
const dur = formatDuration(s.startedAt, s.completedAt);
|
||||
console.log(`${label}${status}${dur}`);
|
||||
}
|
||||
@@ -90,23 +105,58 @@ function listRecentRuns(projectRoot?: string): void {
|
||||
}
|
||||
|
||||
console.log('\nRecent runs:');
|
||||
console.log('Run ID'.padEnd(22) + 'Status'.padEnd(14) + 'Brief');
|
||||
console.log('-'.repeat(70));
|
||||
console.log('Run ID'.padEnd(22) + 'Status'.padEnd(24) + 'Mode'.padEnd(12) + 'Brief');
|
||||
console.log('-'.repeat(80));
|
||||
|
||||
for (const runId of entries) {
|
||||
const runDir = path.join(runsDir, runId);
|
||||
try {
|
||||
const manifest = loadManifest(runDir);
|
||||
const status = manifest.status.padEnd(14);
|
||||
const status = manifest.status.padEnd(24);
|
||||
const mode = runModeOf(manifest).padEnd(12);
|
||||
const brief = path.basename(manifest.brief);
|
||||
console.log(`${runId.padEnd(22)}${status}${brief}`);
|
||||
console.log(`${runId.padEnd(22)}${status}${mode}${brief}`);
|
||||
} catch {
|
||||
console.log(`${runId.padEnd(22)}${'(unreadable)'.padEnd(14)}`);
|
||||
console.log(`${runId.padEnd(22)}${'(unreadable)'.padEnd(24)}`);
|
||||
}
|
||||
}
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -129,6 +179,11 @@ export function registerForgeCommand(parent: Command): void {
|
||||
.option('--config <path>', 'Path to forge config file (.forge/config.yaml)')
|
||||
.option('--codebase <path>', 'Codebase root to pass to the pipeline', process.cwd())
|
||||
.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(
|
||||
async (opts: {
|
||||
brief: string;
|
||||
@@ -137,6 +192,7 @@ export function registerForgeCommand(parent: Command): void {
|
||||
config?: string;
|
||||
codebase: string;
|
||||
dryRun: boolean;
|
||||
simulate: boolean;
|
||||
}) => {
|
||||
const briefPath = path.resolve(opts.brief);
|
||||
|
||||
@@ -149,14 +205,22 @@ export function registerForgeCommand(parent: Command): void {
|
||||
const briefContent = fs.readFileSync(briefPath, 'utf-8');
|
||||
const briefClass = classifyBrief(briefContent);
|
||||
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) {
|
||||
const runId = opts.runId ?? generateRunId();
|
||||
const runDir = resolveRunDir(runId, projectRoot);
|
||||
console.log(`[forge] resuming run: ${runId}`);
|
||||
const { resumePipeline } = await import('./pipeline-runner.js');
|
||||
const result = await resumePipeline(runDir, stubExecutor);
|
||||
console.log(`[forge] pipeline complete: ${result.runId}`);
|
||||
try {
|
||||
const { resumePipeline } = await import('./pipeline-runner.js');
|
||||
const result = await resumePipeline(runDir, executor, { simulate: opts.simulate });
|
||||
applyRunExitPolicy(result, opts.simulate);
|
||||
} catch (err) {
|
||||
handlePipelineError(err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -164,7 +228,8 @@ export function registerForgeCommand(parent: Command): void {
|
||||
briefClass,
|
||||
codebase: projectRoot,
|
||||
dryRun: opts.dryRun,
|
||||
executor: stubExecutor,
|
||||
executor,
|
||||
simulate: opts.simulate,
|
||||
};
|
||||
|
||||
if (opts.dryRun) {
|
||||
@@ -180,16 +245,15 @@ export function registerForgeCommand(parent: Command): void {
|
||||
|
||||
console.log(`[forge] starting pipeline for brief: ${briefPath}`);
|
||||
console.log(`[forge] classified as: ${briefClass}`);
|
||||
if (opts.simulate) {
|
||||
console.log('[forge] mode: SIMULATED (explicit --simulate)');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runPipeline(briefPath, projectRoot, pipelineOptions);
|
||||
console.log(`[forge] pipeline complete: ${result.runId}`);
|
||||
console.log(`[forge] run directory: ${result.runDir}`);
|
||||
applyRunExitPolicy(result, opts.simulate);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[forge] pipeline failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
handlePipelineError(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -224,7 +288,12 @@ export function registerForgeCommand(parent: Command): void {
|
||||
.command('resume <runId>')
|
||||
.description('Resume a stopped or failed pipeline run')
|
||||
.option('--project <path>', 'Project root (defaults to cwd)', process.cwd())
|
||||
.action(async (runId: string, opts: { project: string }) => {
|
||||
.option(
|
||||
'--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);
|
||||
|
||||
if (!fs.existsSync(runDir)) {
|
||||
@@ -234,15 +303,20 @@ export function registerForgeCommand(parent: Command): void {
|
||||
}
|
||||
|
||||
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 {
|
||||
const { resumePipeline } = await import('./pipeline-runner.js');
|
||||
const result = await resumePipeline(runDir, stubExecutor);
|
||||
console.log(`[forge] pipeline complete: ${result.runId}`);
|
||||
console.log(`[forge] run directory: ${result.runDir}`);
|
||||
const result = await resumePipeline(runDir, executor, { simulate: opts.simulate });
|
||||
applyRunExitPolicy(result, opts.simulate);
|
||||
} catch (err) {
|
||||
console.error(`[forge] resume failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exitCode = 1;
|
||||
handlePipelineError(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,16 @@ export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.
|
||||
/** Pipeline asset directory (stages, agents, rails, gates, templates). */
|
||||
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> = {
|
||||
'00-intake': {
|
||||
number: '00',
|
||||
@@ -27,7 +36,13 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
|
||||
type: 'research',
|
||||
gate: 'discovery-complete',
|
||||
promptFile: '00b-discovery.md',
|
||||
qualityGates: ['true'],
|
||||
qualityGates: [
|
||||
{
|
||||
kind: 'authority',
|
||||
capability: 'discovery-complete',
|
||||
reason: 'discovery completion is attested by an authority; no mechanical check exists',
|
||||
},
|
||||
],
|
||||
},
|
||||
'01-board': {
|
||||
number: '01',
|
||||
@@ -36,7 +51,13 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
|
||||
type: 'review',
|
||||
gate: 'board-approval',
|
||||
promptFile: '01-board.md',
|
||||
qualityGates: [{ type: 'ci-pipeline', command: 'board-approval (via board-tasks)' }],
|
||||
qualityGates: [
|
||||
{
|
||||
kind: 'authority',
|
||||
capability: 'board-approval',
|
||||
reason: 'board approval is a board/human decision; no mechanical gate exists',
|
||||
},
|
||||
],
|
||||
},
|
||||
'01b-brief-analyzer': {
|
||||
number: '01b',
|
||||
@@ -45,7 +66,13 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
|
||||
type: 'research',
|
||||
gate: 'brief-analysis-complete',
|
||||
promptFile: '01-board.md',
|
||||
qualityGates: ['true'],
|
||||
qualityGates: [
|
||||
{
|
||||
kind: 'authority',
|
||||
capability: 'brief-analysis-complete',
|
||||
reason: 'brief analysis completion is attested by an authority; no mechanical check exists',
|
||||
},
|
||||
],
|
||||
},
|
||||
'02-planning-1': {
|
||||
number: '02',
|
||||
@@ -54,7 +81,13 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
|
||||
type: 'research',
|
||||
gate: 'architecture-approval',
|
||||
promptFile: '02-planning-1-architecture.md',
|
||||
qualityGates: ['true'],
|
||||
qualityGates: [
|
||||
{
|
||||
kind: 'authority',
|
||||
capability: 'architecture-approval',
|
||||
reason: 'ADR approval requires authority sign-off; no mechanical check exists',
|
||||
},
|
||||
],
|
||||
},
|
||||
'03-planning-2': {
|
||||
number: '03',
|
||||
@@ -63,7 +96,14 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
|
||||
type: 'research',
|
||||
gate: 'implementation-approval',
|
||||
promptFile: '03-planning-2-implementation.md',
|
||||
qualityGates: ['true'],
|
||||
qualityGates: [
|
||||
{
|
||||
kind: 'authority',
|
||||
capability: 'implementation-approval',
|
||||
reason:
|
||||
'implementation spec approval requires authority sign-off; no mechanical check exists',
|
||||
},
|
||||
],
|
||||
},
|
||||
'04-planning-3': {
|
||||
number: '04',
|
||||
@@ -72,7 +112,14 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
|
||||
type: 'research',
|
||||
gate: 'decomposition-approval',
|
||||
promptFile: '04-planning-3-decomposition.md',
|
||||
qualityGates: ['true'],
|
||||
qualityGates: [
|
||||
{
|
||||
kind: 'authority',
|
||||
capability: 'decomposition-approval',
|
||||
reason:
|
||||
'task decomposition approval requires authority sign-off; no mechanical check exists',
|
||||
},
|
||||
],
|
||||
},
|
||||
'05-coding': {
|
||||
number: '05',
|
||||
@@ -92,9 +139,10 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
|
||||
promptFile: '06-review.md',
|
||||
qualityGates: [
|
||||
{
|
||||
type: 'ai-review',
|
||||
command:
|
||||
'echo \'{"summary":"review-pass","verdict":"approve","findings":[],"stats":{"blockers":0,"should_fix":0,"suggestions":0}}\'',
|
||||
kind: 'provider',
|
||||
capability: 'reviewer',
|
||||
reason:
|
||||
'review verdicts require a wired reviewer provider; synthetic approvals are not permitted',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -105,7 +153,13 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
|
||||
type: 'coding',
|
||||
gate: 're-review',
|
||||
promptFile: '07-remediate.md',
|
||||
qualityGates: ['true'],
|
||||
qualityGates: [
|
||||
{
|
||||
kind: 'authority',
|
||||
capability: 're-review',
|
||||
reason: 'remediation re-review is an approval-based gate; no mechanical check exists',
|
||||
},
|
||||
],
|
||||
},
|
||||
'08-test': {
|
||||
number: '08',
|
||||
@@ -123,7 +177,13 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
|
||||
type: 'deploy',
|
||||
gate: 'deploy-verification',
|
||||
promptFile: '09-deploy.md',
|
||||
qualityGates: [{ type: 'ci-pipeline', command: 'deploy-verification' }],
|
||||
qualityGates: [
|
||||
{
|
||||
kind: 'provider',
|
||||
capability: 'ci-pipeline',
|
||||
reason: 'deploy verification requires a wired CI pipeline provider',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 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,6 +5,13 @@ export type {
|
||||
StageSpec,
|
||||
BriefClass,
|
||||
ClassSource,
|
||||
ForgeOutcome,
|
||||
AuthorityGate,
|
||||
ProviderGate,
|
||||
ForgeGate,
|
||||
ForgeGateResult,
|
||||
ForgeTaskResult,
|
||||
RunMode,
|
||||
StageStatus,
|
||||
RunManifest,
|
||||
ForgeTaskStatus,
|
||||
@@ -81,5 +88,24 @@ export {
|
||||
getPipelineStatus,
|
||||
} 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
|
||||
export { registerForgeCommand } from './cli.js';
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
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,18 +1,33 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { STAGE_SEQUENCE } from './constants.js';
|
||||
import { STAGE_SEQUENCE, STAGE_SPECS } from './constants.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 { createSimulatedExecutor } from './simulated-executor.js';
|
||||
import type {
|
||||
ForgeTask,
|
||||
ForgeTaskResult,
|
||||
PipelineOptions,
|
||||
PipelineResult,
|
||||
RunManifest,
|
||||
RunMode,
|
||||
StageStatus,
|
||||
TaskExecutor,
|
||||
} 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.
|
||||
*/
|
||||
@@ -47,6 +62,7 @@ function createManifest(opts: {
|
||||
briefClass: RunManifest['briefClass'];
|
||||
classSource: RunManifest['classSource'];
|
||||
forceBoard: boolean;
|
||||
mode: RunMode;
|
||||
runDir: string;
|
||||
}): RunManifest {
|
||||
const ts = nowISO();
|
||||
@@ -57,6 +73,7 @@ function createManifest(opts: {
|
||||
briefClass: opts.briefClass,
|
||||
classSource: opts.classSource,
|
||||
forceBoard: opts.forceBoard,
|
||||
mode: opts.mode,
|
||||
createdAt: ts,
|
||||
updatedAt: ts,
|
||||
currentStage: '',
|
||||
@@ -108,20 +125,199 @@ export function selectStages(stages?: string[], skipTo?: string): string[] {
|
||||
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.
|
||||
*
|
||||
* 1. Classify the brief
|
||||
* 2. Generate a run ID and create run directory
|
||||
* 3. Map stages to tasks and submit to TaskExecutor
|
||||
* 4. Track manifest with stage statuses
|
||||
* 5. Return pipeline result
|
||||
* 1. Fail closed unless a real executor is wired or simulation is explicit
|
||||
* 2. Classify the brief
|
||||
* 3. Generate a run ID and create run directory
|
||||
* 4. Map stages to tasks and submit to TaskExecutor
|
||||
* 5. Track manifest with typed stage outcomes
|
||||
* 6. Return pipeline result
|
||||
*/
|
||||
export async function runPipeline(
|
||||
briefPath: string,
|
||||
projectRoot: string,
|
||||
options: PipelineOptions,
|
||||
): 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 resolvedBrief = path.resolve(briefPath);
|
||||
const briefContent = fs.readFileSync(resolvedBrief, 'utf-8');
|
||||
@@ -146,6 +342,7 @@ export async function runPipeline(
|
||||
briefClass,
|
||||
classSource,
|
||||
forceBoard: options.forceBoard ?? false,
|
||||
mode,
|
||||
runDir,
|
||||
});
|
||||
|
||||
@@ -172,54 +369,10 @@ export async function runPipeline(
|
||||
}
|
||||
|
||||
// Execute stages
|
||||
const { executor } = options;
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i]!;
|
||||
const stageName = selectedStages[i]!;
|
||||
await executeStages({ manifest, runDir, tasks, stageNames: selectedStages, executor, simulate });
|
||||
|
||||
// Update manifest: stage in progress
|
||||
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';
|
||||
// All stages reached a terminal state for this mode
|
||||
manifest.status = simulate ? 'simulated' : 'completed';
|
||||
saveManifest(runDir, manifest);
|
||||
|
||||
return {
|
||||
@@ -234,22 +387,30 @@ export async function runPipeline(
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a pipeline from the last incomplete stage.
|
||||
* Resume a pipeline from the last non-passed stage.
|
||||
*/
|
||||
export async function resumePipeline(
|
||||
runDir: string,
|
||||
executor: TaskExecutor,
|
||||
executor?: TaskExecutor,
|
||||
options?: { simulate?: boolean },
|
||||
): Promise<PipelineResult> {
|
||||
const simulate = options?.simulate ?? false;
|
||||
const wiredExecutor = requireExecutor(executor, simulate);
|
||||
const mode: RunMode = simulate ? 'simulated' : 'normal';
|
||||
|
||||
const manifest = loadManifest(runDir);
|
||||
const resolvedRoot = path.dirname(path.dirname(path.dirname(runDir))); // .forge/runs/{id} → project root
|
||||
|
||||
const briefContent = fs.readFileSync(manifest.brief, 'utf-8');
|
||||
const allStages = stagesForClass(manifest.briefClass, manifest.forceBoard);
|
||||
|
||||
// Find first non-passed stage
|
||||
manifest.mode = mode;
|
||||
|
||||
// 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');
|
||||
if (!resumeFrom) {
|
||||
manifest.status = 'completed';
|
||||
manifest.status = mode === 'simulated' ? 'simulated' : 'completed';
|
||||
saveManifest(runDir, manifest);
|
||||
return {
|
||||
runId: manifest.runId,
|
||||
@@ -284,49 +445,16 @@ export async function resumePipeline(
|
||||
tasks.push(task);
|
||||
}
|
||||
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i]!;
|
||||
const stageName = remainingStages[i]!;
|
||||
await executeStages({
|
||||
manifest,
|
||||
runDir,
|
||||
tasks,
|
||||
stageNames: remainingStages,
|
||||
executor: wiredExecutor,
|
||||
simulate,
|
||||
});
|
||||
|
||||
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';
|
||||
manifest.status = simulate ? 'simulated' : 'completed';
|
||||
saveManifest(runDir, manifest);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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, TaskResult } from '@mosaicstack/macp';
|
||||
import type { GateEntry } from '@mosaicstack/macp';
|
||||
|
||||
/** Stage dispatch mode. */
|
||||
export type StageDispatch = 'exec' | 'yolo' | 'pi';
|
||||
@@ -6,6 +6,58 @@ export type StageDispatch = 'exec' | 'yolo' | 'pi';
|
||||
/** Stage type — determines agent selection and gate requirements. */
|
||||
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. */
|
||||
export interface StageSpec {
|
||||
number: string;
|
||||
@@ -14,7 +66,7 @@ export interface StageSpec {
|
||||
type: StageType;
|
||||
gate: string;
|
||||
promptFile: string;
|
||||
qualityGates: (string | GateEntry)[];
|
||||
qualityGates: ForgeGate[];
|
||||
}
|
||||
|
||||
/** Brief classification. */
|
||||
@@ -25,11 +77,18 @@ export type ClassSource = 'cli' | 'frontmatter' | 'auto';
|
||||
|
||||
/** Per-stage status within a run manifest. */
|
||||
export interface StageStatus {
|
||||
status: 'pending' | 'in_progress' | 'passed' | 'failed';
|
||||
status: 'pending' | 'in_progress' | ForgeOutcome;
|
||||
/** Why the stage reached its current (terminal) outcome, when applicable. */
|
||||
reason?: string;
|
||||
startedAt?: 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. */
|
||||
export interface RunManifest {
|
||||
runId: string;
|
||||
@@ -38,10 +97,23 @@ export interface RunManifest {
|
||||
briefClass: BriefClass;
|
||||
classSource: ClassSource;
|
||||
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;
|
||||
updatedAt: string;
|
||||
currentStage: string;
|
||||
status: 'in_progress' | 'completed' | 'failed' | 'interrupted' | 'rejected';
|
||||
status:
|
||||
| 'in_progress'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'interrupted'
|
||||
| 'rejected'
|
||||
| 'simulated'
|
||||
| 'waiting-for-authority';
|
||||
stages: Record<string, StageStatus>;
|
||||
}
|
||||
|
||||
@@ -65,7 +137,7 @@ export interface ForgeTask {
|
||||
briefPath: string;
|
||||
resultPath: string;
|
||||
timeoutSeconds: number;
|
||||
qualityGates: (string | GateEntry)[];
|
||||
qualityGates: ForgeGate[];
|
||||
worktree?: string;
|
||||
command?: string;
|
||||
dependsOn?: string[];
|
||||
@@ -76,7 +148,7 @@ export interface ForgeTask {
|
||||
/** Abstract task executor — decouples from packages/coord. */
|
||||
export interface TaskExecutor {
|
||||
submitTask(task: ForgeTask): Promise<void>;
|
||||
waitForCompletion(taskId: string, timeoutMs: number): Promise<TaskResult>;
|
||||
waitForCompletion(taskId: string, timeoutMs: number): Promise<ForgeTaskResult>;
|
||||
getTaskStatus(taskId: string): Promise<ForgeTaskStatus>;
|
||||
}
|
||||
|
||||
@@ -122,7 +194,16 @@ export interface PipelineOptions {
|
||||
stages?: string[];
|
||||
skipTo?: string;
|
||||
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. */
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
import { mkdirSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { normalizeGate, countAIFindings, runGate, runGates } from '../src/gate-runner.js';
|
||||
|
||||
function makeTmpDir(): string {
|
||||
const dir = join(tmpdir(), `macp-gate-${randomUUID()}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('normalizeGate', () => {
|
||||
it('normalizes a string to mechanical gate', () => {
|
||||
expect(normalizeGate('echo test')).toEqual({
|
||||
command: 'echo test',
|
||||
type: 'mechanical',
|
||||
fail_on: 'blocker',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes an object gate with defaults', () => {
|
||||
expect(normalizeGate({ command: 'lint' })).toEqual({
|
||||
command: 'lint',
|
||||
type: 'mechanical',
|
||||
fail_on: 'blocker',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves explicit type and fail_on', () => {
|
||||
expect(normalizeGate({ command: 'review', type: 'ai-review', fail_on: 'any' })).toEqual({
|
||||
command: 'review',
|
||||
type: 'ai-review',
|
||||
fail_on: 'any',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles non-string/non-object input', () => {
|
||||
expect(normalizeGate(42)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' });
|
||||
expect(normalizeGate(null)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('countAIFindings', () => {
|
||||
it('returns zeros for non-object', () => {
|
||||
expect(countAIFindings(null)).toEqual({ blockers: 0, total: 0 });
|
||||
expect(countAIFindings('string')).toEqual({ blockers: 0, total: 0 });
|
||||
expect(countAIFindings([])).toEqual({ blockers: 0, total: 0 });
|
||||
});
|
||||
|
||||
it('counts from stats block', () => {
|
||||
const output = { stats: { blockers: 2, should_fix: 3, suggestions: 1 } };
|
||||
expect(countAIFindings(output)).toEqual({ blockers: 2, total: 6 });
|
||||
});
|
||||
|
||||
it('counts from findings array when stats has no blockers', () => {
|
||||
const output = {
|
||||
stats: { blockers: 0 },
|
||||
findings: [{ severity: 'blocker' }, { severity: 'warning' }, { severity: 'blocker' }],
|
||||
};
|
||||
expect(countAIFindings(output)).toEqual({ blockers: 2, total: 3 });
|
||||
});
|
||||
|
||||
it('uses stats blockers over findings array when stats has blockers', () => {
|
||||
const output = {
|
||||
stats: { blockers: 5 },
|
||||
findings: [{ severity: 'blocker' }, { severity: 'warning' }],
|
||||
};
|
||||
// stats.blockers = 5, total from stats = 5+0+0 = 5, findings not used for total since stats total is non-zero
|
||||
expect(countAIFindings(output)).toEqual({ blockers: 5, total: 5 });
|
||||
});
|
||||
|
||||
it('counts findings length as total when stats has zero total', () => {
|
||||
const output = {
|
||||
findings: [{ severity: 'warning' }, { severity: 'info' }],
|
||||
};
|
||||
expect(countAIFindings(output)).toEqual({ blockers: 0, total: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('runGate', () => {
|
||||
let tmp: string;
|
||||
let logPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = makeTmpDir();
|
||||
logPath = join(tmp, 'gate.log');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('passes mechanical gate on exit 0', () => {
|
||||
const result = runGate('echo hello', tmp, logPath, 30);
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.exit_code).toBe(0);
|
||||
expect(result.type).toBe('mechanical');
|
||||
expect(result.output).toContain('hello');
|
||||
});
|
||||
|
||||
it('fails mechanical gate on non-zero exit', () => {
|
||||
const result = runGate('exit 1', tmp, logPath, 30);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.exit_code).toBe(1);
|
||||
});
|
||||
|
||||
it('ci-pipeline always passes', () => {
|
||||
const result = runGate({ command: 'anything', type: 'ci-pipeline' }, tmp, logPath, 30);
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.type).toBe('ci-pipeline');
|
||||
expect(result.output).toBe('CI pipeline gate placeholder');
|
||||
});
|
||||
|
||||
it('empty command passes', () => {
|
||||
const result = runGate({ command: '' }, tmp, logPath, 30);
|
||||
expect(result.passed).toBe(true);
|
||||
});
|
||||
|
||||
it('ai-review gate parses JSON output', () => {
|
||||
const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } });
|
||||
const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30);
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.blockers).toBe(0);
|
||||
expect(result.findings).toBe(1);
|
||||
});
|
||||
|
||||
it('ai-review gate fails on blockers', () => {
|
||||
const json = JSON.stringify({ stats: { blockers: 2 } });
|
||||
const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.blockers).toBe(2);
|
||||
});
|
||||
|
||||
it('ai-review gate with fail_on=any fails on any findings', () => {
|
||||
const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } });
|
||||
const result = runGate(
|
||||
{ command: `echo '${json}'`, type: 'ai-review', fail_on: 'any' },
|
||||
tmp,
|
||||
logPath,
|
||||
30,
|
||||
);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.fail_on).toBe('any');
|
||||
});
|
||||
|
||||
it('ai-review gate fails on invalid JSON output', () => {
|
||||
const result = runGate({ command: 'echo "not json"', type: 'ai-review' }, tmp, logPath, 30);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.parse_error).toBeDefined();
|
||||
});
|
||||
|
||||
it('writes to log file', () => {
|
||||
runGate('echo logged', tmp, logPath, 30);
|
||||
const log = readFileSync(logPath, 'utf-8');
|
||||
expect(log).toContain('COMMAND: echo logged');
|
||||
expect(log).toContain('logged');
|
||||
expect(log).toContain('EXIT:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runGates', () => {
|
||||
let tmp: string;
|
||||
let logPath: string;
|
||||
let eventsPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = makeTmpDir();
|
||||
logPath = join(tmp, 'gates.log');
|
||||
eventsPath = join(tmp, 'events.ndjson');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('runs multiple gates and returns results', () => {
|
||||
const { allPassed, gateResults } = runGates(
|
||||
['echo one', 'echo two'],
|
||||
tmp,
|
||||
logPath,
|
||||
30,
|
||||
eventsPath,
|
||||
'task-1',
|
||||
);
|
||||
expect(allPassed).toBe(true);
|
||||
expect(gateResults).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('reports failure when any gate fails', () => {
|
||||
const { allPassed, gateResults } = runGates(
|
||||
['echo ok', 'exit 1'],
|
||||
tmp,
|
||||
logPath,
|
||||
30,
|
||||
eventsPath,
|
||||
'task-2',
|
||||
);
|
||||
expect(allPassed).toBe(false);
|
||||
expect(gateResults[0]!.passed).toBe(true);
|
||||
expect(gateResults[1]!.passed).toBe(false);
|
||||
});
|
||||
|
||||
it('emits events for each gate', () => {
|
||||
runGates(['echo test'], tmp, logPath, 30, eventsPath, 'task-3');
|
||||
const events = readFileSync(eventsPath, 'utf-8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((l) => JSON.parse(l));
|
||||
expect(events).toHaveLength(2); // started + passed
|
||||
expect(events[0].event_type).toBe('rail.check.started');
|
||||
expect(events[1].event_type).toBe('rail.check.passed');
|
||||
});
|
||||
|
||||
it('skips gates with empty command (non ci-pipeline)', () => {
|
||||
const { gateResults } = runGates(
|
||||
[{ command: '', type: 'mechanical' }, 'echo real'],
|
||||
tmp,
|
||||
logPath,
|
||||
30,
|
||||
eventsPath,
|
||||
'task-4',
|
||||
);
|
||||
expect(gateResults).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not skip ci-pipeline even with empty command', () => {
|
||||
const { gateResults } = runGates(
|
||||
[{ command: '', type: 'ci-pipeline' }],
|
||||
tmp,
|
||||
logPath,
|
||||
30,
|
||||
eventsPath,
|
||||
'task-5',
|
||||
);
|
||||
expect(gateResults).toHaveLength(1);
|
||||
expect(gateResults[0]!.passed).toBe(true);
|
||||
});
|
||||
|
||||
it('emits failed event with correct message', () => {
|
||||
runGates(['exit 42'], tmp, logPath, 30, eventsPath, 'task-6');
|
||||
const events = readFileSync(eventsPath, 'utf-8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((l) => JSON.parse(l));
|
||||
const failEvent = events.find(
|
||||
(e: Record<string, unknown>) => e.event_type === 'rail.check.failed',
|
||||
);
|
||||
expect(failEvent).toBeDefined();
|
||||
expect(failEvent.message).toContain('Gate failed (');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
|
||||
import { Command } from 'commander';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { registerMacpCommand } from './cli.js';
|
||||
|
||||
describe('registerMacpCommand', () => {
|
||||
@@ -75,3 +78,162 @@ describe('registerMacpCommand', () => {
|
||||
expect(topLevel).toContain('events');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* RI-N2 fail-closed CLI behavior: an unimplemented capability is a failure,
|
||||
* never a success. Every stub exits nonzero with a typed message, and the
|
||||
* implemented `macp gate` mirrors the typed gate-runner states.
|
||||
*/
|
||||
describe('registerMacpCommand fail-closed (RI-N2)', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
function buildProgram(): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.configureOutput({ writeErr: () => {} });
|
||||
registerMacpCommand(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'macp-cli-failclosed-'));
|
||||
process.exitCode = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.exitCode = 0;
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('macp tasks list exits nonzero (unimplemented capability)', async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(['macp', 'tasks', 'list'], { from: 'user' });
|
||||
expect(process.exitCode).not.toBe(0);
|
||||
});
|
||||
|
||||
it('macp submit exits nonzero with a typed MACP_NOT_IMPLEMENTED message', async () => {
|
||||
const program = buildProgram();
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
try {
|
||||
await program.parseAsync(['macp', 'submit', 'spec.json'], { from: 'user' });
|
||||
expect(process.exitCode).not.toBe(0);
|
||||
const errText = errSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
||||
expect(errText).toContain('MACP_NOT_IMPLEMENTED');
|
||||
} finally {
|
||||
errSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('macp events tail exits nonzero (unimplemented capability)', async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(['macp', 'events', 'tail'], { from: 'user' });
|
||||
expect(process.exitCode).not.toBe(0);
|
||||
});
|
||||
|
||||
it('macp gate runs a green inline command and exits 0', async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
[
|
||||
'macp',
|
||||
'gate',
|
||||
'exit 0',
|
||||
'--cwd',
|
||||
tmpDir,
|
||||
'--log',
|
||||
path.join(tmpDir, 'g.log'),
|
||||
'--timeout',
|
||||
'10',
|
||||
],
|
||||
{ from: 'user' },
|
||||
);
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('macp gate exits nonzero on a failing command', async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
[
|
||||
'macp',
|
||||
'gate',
|
||||
'exit 9',
|
||||
'--cwd',
|
||||
tmpDir,
|
||||
'--log',
|
||||
path.join(tmpDir, 'g.log'),
|
||||
'--timeout',
|
||||
'10',
|
||||
],
|
||||
{ from: 'user' },
|
||||
);
|
||||
expect(process.exitCode).not.toBe(0);
|
||||
});
|
||||
|
||||
it('macp gate with an unimplemented ci-pipeline capability exits nonzero', async () => {
|
||||
const program = buildProgram();
|
||||
const specPath = path.join(tmpDir, 'gates.json');
|
||||
fs.writeFileSync(specPath, JSON.stringify([{ type: 'ci-pipeline' }]));
|
||||
await program.parseAsync(
|
||||
[
|
||||
'macp',
|
||||
'gate',
|
||||
specPath,
|
||||
'--cwd',
|
||||
tmpDir,
|
||||
'--log',
|
||||
path.join(tmpDir, 'g.log'),
|
||||
'--timeout',
|
||||
'10',
|
||||
],
|
||||
{ from: 'user' },
|
||||
);
|
||||
expect(process.exitCode).not.toBe(0);
|
||||
});
|
||||
|
||||
it('macp gate --simulate completes (exit 0) but reports simulated results', async () => {
|
||||
const program = buildProgram();
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
try {
|
||||
await program.parseAsync(
|
||||
[
|
||||
'macp',
|
||||
'gate',
|
||||
'exit 0',
|
||||
'--simulate',
|
||||
'--cwd',
|
||||
tmpDir,
|
||||
'--log',
|
||||
path.join(tmpDir, 'g.log'),
|
||||
'--timeout',
|
||||
'10',
|
||||
],
|
||||
{ from: 'user' },
|
||||
);
|
||||
// completes only because the caller explicitly asked to simulate
|
||||
expect(process.exitCode).toBe(0);
|
||||
const outText = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
||||
expect(outText).toContain('simulated');
|
||||
expect(outText).toContain('SIMULATED');
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('macp gate with an empty spec exits nonzero with a typed error', async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
[
|
||||
'macp',
|
||||
'gate',
|
||||
' ',
|
||||
'--cwd',
|
||||
tmpDir,
|
||||
'--log',
|
||||
path.join(tmpDir, 'g.log'),
|
||||
'--timeout',
|
||||
'10',
|
||||
],
|
||||
{ from: 'user' },
|
||||
);
|
||||
expect(process.exitCode).not.toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
+129
-19
@@ -1,5 +1,73 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { runGates } from './gate-runner.js';
|
||||
import { MACPCapabilityError, type MacpErrorCode } from './errors.js';
|
||||
|
||||
/**
|
||||
* Load gates from a spec: an existing file (JSON gates array, a JSON object
|
||||
* with `quality_gates`, a JSON gate object, or one command per line) or an
|
||||
* inline command string. Fails closed with a typed capability error when the
|
||||
* spec contains no executable gate definition.
|
||||
*/
|
||||
function loadGateSpec(spec: string): unknown[] {
|
||||
if (existsSync(spec)) {
|
||||
const raw = readFileSync(spec, 'utf-8');
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (Array.isArray(parsed)) {
|
||||
if (parsed.length === 0) {
|
||||
throw new MACPCapabilityError(
|
||||
'MACP_NO_COMMAND',
|
||||
'gate-spec',
|
||||
`gate spec file '${spec}' contains an empty gates array`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (Array.isArray(obj['quality_gates'])) {
|
||||
return obj['quality_gates'];
|
||||
}
|
||||
return [parsed];
|
||||
}
|
||||
throw new MACPCapabilityError(
|
||||
'MACP_NO_COMMAND',
|
||||
'gate-spec',
|
||||
`gate spec file '${spec}' parsed to ${typeof parsed} — expected a gates array, a task with quality_gates, or a gate object`,
|
||||
);
|
||||
} catch (exc) {
|
||||
if (exc instanceof MACPCapabilityError) throw exc;
|
||||
// Not JSON — treat each non-empty line as a command gate.
|
||||
const lines = raw
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0);
|
||||
if (lines.length > 0) return lines;
|
||||
throw new MACPCapabilityError(
|
||||
'MACP_NO_COMMAND',
|
||||
'gate-spec',
|
||||
`gate spec file '${spec}' contains no gates`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (spec.trim().length > 0) return [spec];
|
||||
throw new MACPCapabilityError('MACP_NO_COMMAND', 'gate-spec', 'gate spec is empty');
|
||||
}
|
||||
|
||||
/** Print a typed not-implemented failure and exit nonzero (RI-N2 fail-closed). */
|
||||
function notImplemented(subcommand: string, capability: string, hint: string): void {
|
||||
const err = new MACPCapabilityError(
|
||||
'MACP_NOT_IMPLEMENTED',
|
||||
capability,
|
||||
`${subcommand} is not implemented in @mosaicstack/macp yet (${capability} capability absent) — ${hint}`,
|
||||
);
|
||||
console.error(`[macp] ${subcommand}: ${err.message} [${err.code}]`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register macp subcommands on an existing Commander program.
|
||||
* This avoids cross-package Commander version mismatches by using the
|
||||
@@ -24,15 +92,14 @@ export function registerMacpCommand(parent: Command): void {
|
||||
'Filter by task type (coding|deploy|research|review|documentation|infrastructure)',
|
||||
)
|
||||
.action((opts: { status?: string; type?: string }) => {
|
||||
// not yet wired — task persistence layer is not present in @mosaicstack/macp
|
||||
console.log('[macp] tasks list: not yet wired — use macp package programmatically');
|
||||
// unimplemented capability — a failure, never a success (RI-N2)
|
||||
if (opts.status) {
|
||||
console.log(` status filter: ${opts.status}`);
|
||||
}
|
||||
if (opts.type) {
|
||||
console.log(` type filter: ${opts.type}`);
|
||||
}
|
||||
process.exitCode = 0;
|
||||
notImplemented('tasks list', 'task-persistence', 'use the macp package programmatically');
|
||||
});
|
||||
|
||||
// ─── submit ──────────────────────────────────────────────────────────────
|
||||
@@ -41,12 +108,11 @@ export function registerMacpCommand(parent: Command): void {
|
||||
.command('submit <path>')
|
||||
.description('Submit a task from a JSON/YAML spec file')
|
||||
.action((specPath: string) => {
|
||||
// not yet wired — task submission requires a running MACP server
|
||||
console.log('[macp] submit: not yet wired — use macp package programmatically');
|
||||
// unimplemented capability — a failure, never a success (RI-N2)
|
||||
console.log(` spec path: ${specPath}`);
|
||||
console.log(' task id: (unavailable — no MACP server connected)');
|
||||
console.log(' status: (unavailable — no MACP server connected)');
|
||||
process.exitCode = 0;
|
||||
notImplemented('submit', 'macp-server', 'use the macp package programmatically');
|
||||
});
|
||||
|
||||
// ─── gate ────────────────────────────────────────────────────────────────
|
||||
@@ -58,16 +124,58 @@ export function registerMacpCommand(parent: Command): void {
|
||||
.option('--cwd <path>', 'Working directory for gate execution', process.cwd())
|
||||
.option('--log <path>', 'Path to write gate log output', '/tmp/macp-gate.log')
|
||||
.option('--timeout <seconds>', 'Gate timeout in seconds', '60')
|
||||
.action((spec: string, opts: { failOn: string; cwd: string; log: string; timeout: string }) => {
|
||||
// not yet wired — gate execution requires a task context and event sink
|
||||
console.log('[macp] gate: not yet wired — use macp package programmatically');
|
||||
console.log(` spec: ${spec}`);
|
||||
console.log(` fail-on: ${opts.failOn}`);
|
||||
console.log(` cwd: ${opts.cwd}`);
|
||||
console.log(` log: ${opts.log}`);
|
||||
console.log(` timeout: ${opts.timeout}s`);
|
||||
process.exitCode = 0;
|
||||
});
|
||||
.option(
|
||||
'--simulate',
|
||||
'Simulate gates instead of executing them; results are typed simulated and never satisfy a check',
|
||||
)
|
||||
.action(
|
||||
(
|
||||
spec: string,
|
||||
opts: { failOn: string; cwd: string; log: string; timeout: string; simulate?: boolean },
|
||||
) => {
|
||||
let gates: unknown[];
|
||||
try {
|
||||
gates = loadGateSpec(spec);
|
||||
} catch (exc) {
|
||||
if (exc instanceof MACPCapabilityError) {
|
||||
console.error(`[macp] gate: ${exc.message} [${exc.code}]`);
|
||||
} else {
|
||||
console.error(`[macp] gate: ${String(exc)}`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutSec = Number.parseInt(opts.timeout, 10) || 60;
|
||||
const eventsPath = `${opts.log}.events.ndjson`;
|
||||
const { state, gateResults } = runGates(
|
||||
gates,
|
||||
opts.cwd,
|
||||
opts.log,
|
||||
timeoutSec,
|
||||
eventsPath,
|
||||
'macp-cli-gate',
|
||||
{
|
||||
simulate: opts.simulate,
|
||||
},
|
||||
);
|
||||
|
||||
for (const r of gateResults) {
|
||||
const label = r.command || r.type;
|
||||
const reason = r.reason ? ` — ${r.reason}` : '';
|
||||
console.log(`[macp] gate ${r.status}: ${label}${reason}`);
|
||||
}
|
||||
if (opts.simulate) {
|
||||
console.log(
|
||||
'[macp] SIMULATED run — every result is typed simulated and can never satisfy a gate, dependency, or release check',
|
||||
);
|
||||
}
|
||||
|
||||
// Simulated runs may complete (exit 0) only because the caller
|
||||
// explicitly passed --simulate; the typed state stays 'simulated'.
|
||||
process.exitCode = state === 'passed' || state === 'simulated' ? 0 : 1;
|
||||
},
|
||||
);
|
||||
|
||||
// ─── events ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -79,14 +187,16 @@ export function registerMacpCommand(parent: Command): void {
|
||||
.option('--file <path>', 'Path to the MACP events NDJSON file')
|
||||
.option('--follow', 'Follow the file for new events (like tail -f)')
|
||||
.action((opts: { file?: string; follow?: boolean }) => {
|
||||
// not yet wired — event streaming requires a live event source
|
||||
console.log('[macp] events tail: not yet wired — use macp package programmatically');
|
||||
// unimplemented capability — a failure, never a success (RI-N2)
|
||||
if (opts.file) {
|
||||
console.log(` file: ${opts.file}`);
|
||||
}
|
||||
if (opts.follow) {
|
||||
console.log(' mode: follow');
|
||||
}
|
||||
process.exitCode = 0;
|
||||
notImplemented('events tail', 'event-source', 'use the macp package programmatically');
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export so CLI consumers can surface typed capability codes.
|
||||
export type { MacpErrorCode };
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/** Typed error code from the closed MACP_ERROR_CODES set. */
|
||||
export type MacpErrorCode = (typeof MACP_ERROR_CODES)[number];
|
||||
/**
|
||||
* Typed fail-closed capability errors (RI-N2, SDLC-D-035).
|
||||
*
|
||||
* MACP must fail closed when a required capability (executor, reviewer,
|
||||
* command, CI provider, human authority) is absent. These typed codes mirror
|
||||
* the Forge failure vocabulary (FORGE_NO_*) so both packages speak the same
|
||||
* language: an unimplemented capability is a failure, never a stub success.
|
||||
*/
|
||||
|
||||
/** Closed set of typed MACP capability error codes. */
|
||||
export const MACP_ERROR_CODES = [
|
||||
'MACP_NOT_IMPLEMENTED',
|
||||
'MACP_NO_COMMAND',
|
||||
'MACP_NO_REVIEWER',
|
||||
'MACP_NO_CI_PIPELINE',
|
||||
'MACP_NO_PROVIDER',
|
||||
'MACP_AUTHORITY_REQUIRED',
|
||||
] as const;
|
||||
|
||||
/** Raised when a required capability is missing and execution must fail closed. */
|
||||
export class MACPCapabilityError extends Error {
|
||||
/** Typed error code from the closed MACP_ERROR_CODES set. */
|
||||
readonly code: MacpErrorCode;
|
||||
/** The missing capability, e.g. `ci-provider`, `task-persistence`, `command`. */
|
||||
readonly capability: string;
|
||||
|
||||
constructor(code: MacpErrorCode, capability: string, message: string) {
|
||||
super(message);
|
||||
this.name = 'MACPCapabilityError';
|
||||
this.code = code;
|
||||
this.capability = capability;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { countAIFindings, normalizeGate, runGate, runGates } from './gate-runner.js';
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'macp-gate-'));
|
||||
}
|
||||
|
||||
describe('normalizeGate', () => {
|
||||
it('normalizes a string to mechanical gate', () => {
|
||||
expect(normalizeGate('echo test')).toEqual({
|
||||
command: 'echo test',
|
||||
type: 'mechanical',
|
||||
fail_on: 'blocker',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes an object gate with defaults', () => {
|
||||
expect(normalizeGate({ command: 'lint' })).toEqual({
|
||||
command: 'lint',
|
||||
type: 'mechanical',
|
||||
fail_on: 'blocker',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves explicit type and fail_on', () => {
|
||||
expect(normalizeGate({ command: 'review', type: 'ai-review', fail_on: 'any' })).toEqual({
|
||||
command: 'review',
|
||||
type: 'ai-review',
|
||||
fail_on: 'any',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles non-string/non-object input', () => {
|
||||
expect(normalizeGate(42)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' });
|
||||
expect(normalizeGate(null)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('countAIFindings', () => {
|
||||
it('returns zeros for non-object', () => {
|
||||
expect(countAIFindings(null)).toEqual({ blockers: 0, total: 0 });
|
||||
expect(countAIFindings('string')).toEqual({ blockers: 0, total: 0 });
|
||||
expect(countAIFindings([])).toEqual({ blockers: 0, total: 0 });
|
||||
});
|
||||
|
||||
it('counts from stats block', () => {
|
||||
const output = { stats: { blockers: 2, should_fix: 3, suggestions: 1 } };
|
||||
expect(countAIFindings(output)).toEqual({ blockers: 2, total: 6 });
|
||||
});
|
||||
|
||||
it('counts from findings array when stats has no blockers', () => {
|
||||
const output = {
|
||||
stats: { blockers: 0 },
|
||||
findings: [{ severity: 'blocker' }, { severity: 'warning' }, { severity: 'blocker' }],
|
||||
};
|
||||
expect(countAIFindings(output)).toEqual({ blockers: 2, total: 3 });
|
||||
});
|
||||
|
||||
it('uses stats blockers over findings array when stats has blockers', () => {
|
||||
const output = {
|
||||
stats: { blockers: 5 },
|
||||
findings: [{ severity: 'blocker' }, { severity: 'warning' }],
|
||||
};
|
||||
// stats.blockers = 5, total from stats = 5+0+0 = 5, findings not used for total since stats total is non-zero
|
||||
expect(countAIFindings(output)).toEqual({ blockers: 5, total: 5 });
|
||||
});
|
||||
|
||||
it('counts findings length as total when stats has zero total', () => {
|
||||
const output = {
|
||||
findings: [{ severity: 'warning' }, { severity: 'info' }],
|
||||
};
|
||||
expect(countAIFindings(output)).toEqual({ blockers: 0, total: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('runGate', () => {
|
||||
let tmp: string;
|
||||
let logPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = makeTmpDir();
|
||||
logPath = path.join(tmp, 'gate.log');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('passes mechanical gate on exit 0', () => {
|
||||
const result = runGate('echo hello', tmp, logPath, 30);
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.exit_code).toBe(0);
|
||||
expect(result.type).toBe('mechanical');
|
||||
expect(result.output).toContain('hello');
|
||||
});
|
||||
|
||||
it('fails mechanical gate on non-zero exit', () => {
|
||||
const result = runGate('exit 1', tmp, logPath, 30);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.exit_code).toBe(1);
|
||||
});
|
||||
|
||||
it('ci-pipeline fails closed without a CI provider (no placeholder pass)', () => {
|
||||
const result = runGate({ command: 'anything', type: 'ci-pipeline' }, tmp, logPath, 30);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.status).toBe('capability_failure');
|
||||
expect(result.capability_code).toBe('MACP_NO_CI_PIPELINE');
|
||||
expect(result.type).toBe('ci-pipeline');
|
||||
expect(result.output).not.toBe('CI pipeline gate placeholder');
|
||||
});
|
||||
|
||||
it('empty command is a typed capability failure, never a pass', () => {
|
||||
const result = runGate({ command: '' }, tmp, logPath, 30);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.status).toBe('capability_failure');
|
||||
expect(result.capability_code).toBe('MACP_NO_COMMAND');
|
||||
});
|
||||
|
||||
it('ai-review gate parses JSON output', () => {
|
||||
const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } });
|
||||
const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30);
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.blockers).toBe(0);
|
||||
expect(result.findings).toBe(1);
|
||||
});
|
||||
|
||||
it('ai-review gate fails on blockers', () => {
|
||||
const json = JSON.stringify({ stats: { blockers: 2 } });
|
||||
const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.blockers).toBe(2);
|
||||
});
|
||||
|
||||
it('ai-review gate with fail_on=any fails on any findings', () => {
|
||||
const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } });
|
||||
const result = runGate(
|
||||
{ command: `echo '${json}'`, type: 'ai-review', fail_on: 'any' },
|
||||
tmp,
|
||||
logPath,
|
||||
30,
|
||||
);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.fail_on).toBe('any');
|
||||
});
|
||||
|
||||
it('ai-review gate fails on invalid JSON output', () => {
|
||||
const result = runGate({ command: 'echo "not json"', type: 'ai-review' }, tmp, logPath, 30);
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.parse_error).toBeDefined();
|
||||
});
|
||||
|
||||
it('writes to log file', () => {
|
||||
runGate('echo logged', tmp, logPath, 30);
|
||||
const log = fs.readFileSync(logPath, 'utf-8');
|
||||
expect(log).toContain('COMMAND: echo logged');
|
||||
expect(log).toContain('logged');
|
||||
expect(log).toContain('EXIT:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runGates', () => {
|
||||
let tmp: string;
|
||||
let logPath: string;
|
||||
let eventsPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = makeTmpDir();
|
||||
logPath = path.join(tmp, 'gates.log');
|
||||
eventsPath = path.join(tmp, 'events.ndjson');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('runs multiple gates and returns results', () => {
|
||||
const { allPassed, gateResults } = runGates(
|
||||
['echo one', 'echo two'],
|
||||
tmp,
|
||||
logPath,
|
||||
30,
|
||||
eventsPath,
|
||||
'task-1',
|
||||
);
|
||||
expect(allPassed).toBe(true);
|
||||
expect(gateResults).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('reports failure when any gate fails', () => {
|
||||
const { allPassed, gateResults } = runGates(
|
||||
['echo ok', 'exit 1'],
|
||||
tmp,
|
||||
logPath,
|
||||
30,
|
||||
eventsPath,
|
||||
'task-2',
|
||||
);
|
||||
expect(allPassed).toBe(false);
|
||||
expect(gateResults[0]!.passed).toBe(true);
|
||||
expect(gateResults[1]!.passed).toBe(false);
|
||||
});
|
||||
|
||||
it('emits events for each gate', () => {
|
||||
runGates(['echo test'], tmp, logPath, 30, eventsPath, 'task-3');
|
||||
const events = fs
|
||||
.readFileSync(eventsPath, 'utf-8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((l) => JSON.parse(l));
|
||||
expect(events).toHaveLength(2); // started + passed
|
||||
expect(events[0].event_type).toBe('rail.check.started');
|
||||
expect(events[1].event_type).toBe('rail.check.passed');
|
||||
});
|
||||
|
||||
it('does not silently skip gates with empty command — they become capability failures', () => {
|
||||
const { gateResults, allPassed, state } = runGates(
|
||||
[{ command: '', type: 'mechanical' }, 'echo real'],
|
||||
tmp,
|
||||
logPath,
|
||||
30,
|
||||
eventsPath,
|
||||
'task-4',
|
||||
);
|
||||
expect(gateResults).toHaveLength(2);
|
||||
expect(gateResults[0]!.status).toBe('capability_failure');
|
||||
expect(gateResults[1]!.status).toBe('passed');
|
||||
expect(allPassed).toBe(false);
|
||||
expect(state).toBe('capability_failure');
|
||||
});
|
||||
|
||||
it('does not skip ci-pipeline even with empty command — typed capability failure', () => {
|
||||
const { gateResults, allPassed, state } = runGates(
|
||||
[{ command: '', type: 'ci-pipeline' }],
|
||||
tmp,
|
||||
logPath,
|
||||
30,
|
||||
eventsPath,
|
||||
'task-5',
|
||||
);
|
||||
expect(gateResults).toHaveLength(1);
|
||||
expect(gateResults[0]!.passed).toBe(false);
|
||||
expect(gateResults[0]!.status).toBe('capability_failure');
|
||||
expect(allPassed).toBe(false);
|
||||
expect(state).toBe('capability_failure');
|
||||
});
|
||||
|
||||
it('emits failed event with correct message', () => {
|
||||
runGates(['exit 42'], tmp, logPath, 30, eventsPath, 'task-6');
|
||||
const events = fs
|
||||
.readFileSync(eventsPath, 'utf-8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((l) => JSON.parse(l));
|
||||
const failEvent = events.find(
|
||||
(e: Record<string, unknown>) => e.event_type === 'rail.check.failed',
|
||||
);
|
||||
expect(failEvent).toBeDefined();
|
||||
expect(failEvent.message).toContain('Gate failed (');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* RI-N2 / SDLC-D-035 fail-closed controls for the MACP gate runner.
|
||||
*
|
||||
* Invariant under test: `passed: true` occurs ONLY when a gate really executed
|
||||
* and really exited green (`status === 'passed'`). Absent capabilities,
|
||||
* manual sign-offs, and simulated runs are typed distinctly and can never
|
||||
* make the aggregate `passed`.
|
||||
*/
|
||||
describe('gate-runner fail-closed (RI-N2)', () => {
|
||||
let tmpDir: string;
|
||||
let logPath: string;
|
||||
let eventsPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTmpDir();
|
||||
logPath = path.join(tmpDir, 'gate.log');
|
||||
eventsPath = path.join(tmpDir, 'events.ndjson');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function run(gates: unknown[], options?: { simulate?: boolean }) {
|
||||
return runGates(gates, tmpDir, logPath, 10, eventsPath, 'spec-task', options);
|
||||
}
|
||||
|
||||
// ─── positive controls ───────────────────────────────────────────────────
|
||||
|
||||
it('a really-executed green command gate still passes', () => {
|
||||
const result = run([{ command: 'exit 0', type: 'mechanical' }]);
|
||||
expect(result.gateResults[0]!.status).toBe('passed');
|
||||
expect(result.gateResults[0]!.passed).toBe(true);
|
||||
expect(result.allPassed).toBe(true);
|
||||
expect(result.state).toBe('passed');
|
||||
});
|
||||
|
||||
it('explicit simulate completes and types every result simulated', () => {
|
||||
const result = run([{ command: 'exit 0', type: 'mechanical' }, 'echo hello'], {
|
||||
simulate: true,
|
||||
});
|
||||
expect(result.gateResults).toHaveLength(2);
|
||||
for (const gate of result.gateResults) {
|
||||
expect(gate.status).toBe('simulated');
|
||||
expect(gate.passed).toBe(false);
|
||||
}
|
||||
expect(result.state).toBe('simulated');
|
||||
});
|
||||
|
||||
it('a really-executed red command gate fails with typed status failed', () => {
|
||||
const result = run([{ command: 'exit 3', type: 'mechanical' }]);
|
||||
expect(result.gateResults[0]!.status).toBe('failed');
|
||||
expect(result.gateResults[0]!.passed).toBe(false);
|
||||
expect(result.allPassed).toBe(false);
|
||||
expect(result.state).toBe('failed');
|
||||
});
|
||||
|
||||
// ─── negative controls — each asserts typed status AND aggregate not passed ──
|
||||
|
||||
it('an empty-command gate is a capability_failure, not skipped and not passed', () => {
|
||||
const result = run([{ command: '', type: 'mechanical' }]);
|
||||
// runGates must not silently skip it — it produces a typed result
|
||||
expect(result.gateResults).toHaveLength(1);
|
||||
const gate = result.gateResults[0]!;
|
||||
expect(gate.status).toBe('capability_failure');
|
||||
expect(gate.capability_code).toBe('MACP_NO_COMMAND');
|
||||
expect(gate.passed).toBe(false);
|
||||
// aggregate is not passed
|
||||
expect(result.allPassed).toBe(false);
|
||||
expect(result.state).toBe('capability_failure');
|
||||
expect(result.state).not.toBe('passed');
|
||||
});
|
||||
|
||||
it('a commandless ai-review gate is a typed MACP_NO_REVIEWER capability_failure', () => {
|
||||
const result = run([{ command: '', type: 'ai-review' }]);
|
||||
expect(result.gateResults[0]!.status).toBe('capability_failure');
|
||||
expect(result.gateResults[0]!.capability_code).toBe('MACP_NO_REVIEWER');
|
||||
expect(result.allPassed).toBe(false);
|
||||
expect(result.state).not.toBe('passed');
|
||||
});
|
||||
|
||||
it('a ci-pipeline gate without a provider implementation is a capability_failure, never a placeholder pass', () => {
|
||||
const result = run([{ command: '', type: 'ci-pipeline' }]);
|
||||
const gate = result.gateResults[0]!;
|
||||
expect(gate.status).toBe('capability_failure');
|
||||
expect(gate.capability_code).toBe('MACP_NO_CI_PIPELINE');
|
||||
expect(gate.passed).toBe(false);
|
||||
// the old false-success placeholder must be gone
|
||||
expect(gate.output).not.toBe('CI pipeline gate placeholder');
|
||||
expect(result.allPassed).toBe(false);
|
||||
expect(result.state).not.toBe('passed');
|
||||
});
|
||||
|
||||
it('a ci-pipeline gate fails closed even alongside an otherwise green run', () => {
|
||||
const result = run(['exit 0', { type: 'ci-pipeline', command: 'fake-ci' }]);
|
||||
expect(result.gateResults[1]!.status).toBe('capability_failure');
|
||||
expect(result.gateResults[0]!.status).toBe('passed');
|
||||
expect(result.allPassed).toBe(false);
|
||||
expect(result.state).toBe('capability_failure');
|
||||
});
|
||||
|
||||
it('a manual gate with no automation enters typed waiting — neither pass nor fail', () => {
|
||||
const result = run([{ type: 'manual' }]);
|
||||
const gate = result.gateResults[0]!;
|
||||
expect(gate.status).toBe('waiting');
|
||||
expect(gate.passed).toBe(false);
|
||||
expect(gate.exit_code).toBe(0);
|
||||
// aggregate is not passed while any gate is waiting
|
||||
expect(result.allPassed).toBe(false);
|
||||
expect(result.state).toBe('waiting');
|
||||
expect(result.state).not.toBe('passed');
|
||||
});
|
||||
|
||||
it('a simulated result can never make the aggregate passed', () => {
|
||||
const result = run(['exit 0', 'exit 0'], { simulate: true });
|
||||
expect(result.gateResults.every((g) => g.status === 'simulated')).toBe(true);
|
||||
expect(result.allPassed).toBe(false);
|
||||
expect(result.state).toBe('simulated');
|
||||
expect(result.state).not.toBe('passed');
|
||||
});
|
||||
|
||||
it('waiting dominates an otherwise green aggregate', () => {
|
||||
const result = run(['exit 0', { type: 'manual' }]);
|
||||
expect(result.allPassed).toBe(false);
|
||||
expect(result.state).toBe('waiting');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runGate fail-closed (RI-N2)', () => {
|
||||
let tmpDir: string;
|
||||
let logPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTmpDir();
|
||||
logPath = path.join(tmpDir, 'gate.log');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('simulate: true returns a typed simulated result without executing', () => {
|
||||
const result = runGate('this-command-does-not-exist-xyz', tmpDir, logPath, 10, {
|
||||
simulate: true,
|
||||
});
|
||||
expect(result.status).toBe('simulated');
|
||||
expect(result.passed).toBe(false);
|
||||
expect(result.exit_code).toBe(0);
|
||||
});
|
||||
|
||||
it('normal mode executes for real and types a green gate passed', () => {
|
||||
const result = runGate('echo ok', tmpDir, logPath, 10);
|
||||
expect(result.status).toBe('passed');
|
||||
expect(result.passed).toBe(true);
|
||||
expect(result.output).toContain('ok');
|
||||
});
|
||||
|
||||
it('a bare string gate normalizes to mechanical and executes', () => {
|
||||
const result = runGate('exit 7', tmpDir, logPath, 10);
|
||||
expect(result.type).toBe('mechanical');
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.passed).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,20 @@ import { dirname } from 'node:path';
|
||||
|
||||
import { emitEvent } from './event-emitter.js';
|
||||
import { nowISO } from './event-emitter.js';
|
||||
import type { GateResult } from './types.js';
|
||||
import type { GateResult, GateStatus, RunGatesResult } from './types.js';
|
||||
|
||||
/** Typed reason stamped on every simulated gate result. */
|
||||
export const SIMULATED_GATE_REASON =
|
||||
'simulated execution (explicit simulate opt-in): gate was not evaluated by a real implementation';
|
||||
|
||||
/** Options for gate execution (RI-N2 fail-closed / explicit simulation). */
|
||||
export interface RunGateOptions {
|
||||
/**
|
||||
* Explicit caller opt-in to simulation. Simulated gates are NOT executed;
|
||||
* every result is typed `simulated` and never satisfies anything.
|
||||
*/
|
||||
simulate?: boolean;
|
||||
}
|
||||
|
||||
export interface NormalizedGate {
|
||||
command: string;
|
||||
@@ -103,36 +116,91 @@ export function countAIFindings(parsedOutput: unknown): { blockers: number; tota
|
||||
return { blockers, total };
|
||||
}
|
||||
|
||||
function simulatedResult(gateEntry: NormalizedGate): GateResult {
|
||||
return {
|
||||
command: gateEntry.command,
|
||||
exit_code: 0,
|
||||
type: gateEntry.type,
|
||||
output: SIMULATED_GATE_REASON,
|
||||
timed_out: false,
|
||||
passed: false,
|
||||
status: 'simulated',
|
||||
reason: SIMULATED_GATE_REASON,
|
||||
};
|
||||
}
|
||||
|
||||
function capabilityFailureResult(
|
||||
gateEntry: NormalizedGate,
|
||||
code: GateResult['capability_code'],
|
||||
reason: string,
|
||||
): GateResult {
|
||||
return {
|
||||
command: gateEntry.command,
|
||||
exit_code: 1,
|
||||
type: gateEntry.type,
|
||||
output: '',
|
||||
timed_out: false,
|
||||
passed: false,
|
||||
status: 'capability_failure',
|
||||
capability_code: code,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
function waitingResult(gateEntry: NormalizedGate, reason: string): GateResult {
|
||||
return {
|
||||
command: gateEntry.command,
|
||||
exit_code: 0,
|
||||
type: gateEntry.type,
|
||||
output: '',
|
||||
timed_out: false,
|
||||
passed: false,
|
||||
status: 'waiting',
|
||||
capability_code: 'MACP_AUTHORITY_REQUIRED',
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
export function runGate(
|
||||
gate: unknown,
|
||||
cwd: string,
|
||||
logPath: string,
|
||||
timeoutSec: number,
|
||||
options: RunGateOptions = {},
|
||||
): GateResult {
|
||||
const gateEntry = normalizeGate(gate);
|
||||
const gateType = gateEntry.type;
|
||||
const command = gateEntry.command;
|
||||
|
||||
// Explicit simulation only: never executes, typed simulated, never satisfying.
|
||||
if (options.simulate) {
|
||||
return simulatedResult(gateEntry);
|
||||
}
|
||||
|
||||
// Fail closed: no CI provider implementation exists in @mosaicstack/macp,
|
||||
// so a ci-pipeline gate is an absent capability — never a placeholder pass.
|
||||
if (gateType === 'ci-pipeline') {
|
||||
return {
|
||||
command,
|
||||
exit_code: 0,
|
||||
type: gateType,
|
||||
output: 'CI pipeline gate placeholder',
|
||||
timed_out: false,
|
||||
passed: true,
|
||||
};
|
||||
return capabilityFailureResult(
|
||||
gateEntry,
|
||||
'MACP_NO_CI_PIPELINE',
|
||||
`ci-pipeline gate '${gateEntry.command || gateType}' has no CI provider implementation wired — refusing placeholder pass`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!command) {
|
||||
return {
|
||||
command: '',
|
||||
exit_code: 0,
|
||||
type: gateType,
|
||||
output: '',
|
||||
timed_out: false,
|
||||
passed: true,
|
||||
};
|
||||
// A manual gate with no automation waits for human sign-off: not pass, not fail.
|
||||
if (gateType === 'manual') {
|
||||
return waitingResult(
|
||||
gateEntry,
|
||||
`manual gate has no automation — waiting for human sign-off (type: ${gateType})`,
|
||||
);
|
||||
}
|
||||
// Any other commandless gate is an absent capability — never a vacuous pass.
|
||||
return capabilityFailureResult(
|
||||
gateEntry,
|
||||
gateType === 'ai-review' ? 'MACP_NO_REVIEWER' : 'MACP_NO_COMMAND',
|
||||
`gate of type '${gateType}' has no command to execute — refusing empty-command pass`,
|
||||
);
|
||||
}
|
||||
|
||||
const { exitCode, output, timedOut } = runShell(command, cwd, logPath, timeoutSec);
|
||||
@@ -143,10 +211,12 @@ export function runGate(
|
||||
output,
|
||||
timed_out: timedOut,
|
||||
passed: false,
|
||||
status: 'failed',
|
||||
};
|
||||
|
||||
if (gateType !== 'ai-review') {
|
||||
result.passed = exitCode === 0;
|
||||
result.status = result.passed ? 'passed' : 'failed';
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -170,6 +240,7 @@ export function runGate(
|
||||
} else {
|
||||
result.passed = exitCode === 0 && blockers === 0 && !timedOut && parseError === undefined;
|
||||
}
|
||||
result.status = result.passed ? 'passed' : 'failed';
|
||||
|
||||
result.fail_on = failOn;
|
||||
result.blockers = blockers;
|
||||
@@ -191,16 +262,19 @@ export function runGates(
|
||||
timeoutSec: number,
|
||||
eventsPath: string,
|
||||
taskId: string,
|
||||
): { allPassed: boolean; gateResults: GateResult[] } {
|
||||
let allPassed = true;
|
||||
options: RunGateOptions = {},
|
||||
): RunGatesResult {
|
||||
const gateResults: GateResult[] = [];
|
||||
let hasCapabilityFailure = false;
|
||||
let hasSimulated = false;
|
||||
let hasFailed = false;
|
||||
let hasWaiting = false;
|
||||
|
||||
for (const gate of gates) {
|
||||
const gateEntry = normalizeGate(gate);
|
||||
const gateCmd = gateEntry.command;
|
||||
if (!gateCmd && gateEntry.type !== 'ci-pipeline') continue;
|
||||
|
||||
const label = gateCmd || gateEntry.type;
|
||||
// NOTE: no silent skip — every gate produces a typed result (RI-N2).
|
||||
emitEvent(
|
||||
eventsPath,
|
||||
'rail.check.started',
|
||||
@@ -209,10 +283,10 @@ export function runGates(
|
||||
'quality-gate',
|
||||
`Running gate: ${label}`,
|
||||
);
|
||||
const result = runGate(gate, cwd, logPath, timeoutSec);
|
||||
const result = runGate(gate, cwd, logPath, timeoutSec, options);
|
||||
gateResults.push(result);
|
||||
|
||||
if (result.passed) {
|
||||
if (result.status === 'passed') {
|
||||
emitEvent(
|
||||
eventsPath,
|
||||
'rail.check.passed',
|
||||
@@ -224,7 +298,46 @@ export function runGates(
|
||||
continue;
|
||||
}
|
||||
|
||||
allPassed = false;
|
||||
if (result.status === 'waiting') {
|
||||
hasWaiting = true;
|
||||
emitEvent(
|
||||
eventsPath,
|
||||
'rail.check.waiting',
|
||||
taskId,
|
||||
'gated',
|
||||
'quality-gate',
|
||||
`Gate waiting: ${label} — ${result.reason ?? 'manual gate awaits sign-off'}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.status === 'simulated') {
|
||||
hasSimulated = true;
|
||||
emitEvent(
|
||||
eventsPath,
|
||||
'rail.check.simulated',
|
||||
taskId,
|
||||
'gated',
|
||||
'quality-gate',
|
||||
`Gate simulated (non-satisfying): ${label}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.status === 'capability_failure') {
|
||||
hasCapabilityFailure = true;
|
||||
emitEvent(
|
||||
eventsPath,
|
||||
'rail.check.failed',
|
||||
taskId,
|
||||
'gated',
|
||||
'quality-gate',
|
||||
`Gate capability failure (${result.capability_code ?? 'MACP_NO_PROVIDER'}): ${label} — ${result.reason ?? 'required capability is absent'}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
hasFailed = true;
|
||||
let message: string;
|
||||
if (result.timed_out) {
|
||||
message = `Gate timed out after ${timeoutSec}s: ${label}`;
|
||||
@@ -236,5 +349,15 @@ export function runGates(
|
||||
emitEvent(eventsPath, 'rail.check.failed', taskId, 'gated', 'quality-gate', message);
|
||||
}
|
||||
|
||||
return { allPassed, gateResults };
|
||||
const state: GateStatus = hasCapabilityFailure
|
||||
? 'capability_failure'
|
||||
: hasSimulated
|
||||
? 'simulated'
|
||||
: hasFailed
|
||||
? 'failed'
|
||||
: hasWaiting
|
||||
? 'waiting'
|
||||
: 'passed';
|
||||
|
||||
return { allPassed: state === 'passed', gateResults, state };
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@ export type {
|
||||
DependsOnPolicy,
|
||||
GateType,
|
||||
GateFailOn,
|
||||
GateStatus,
|
||||
GateEntry,
|
||||
Task,
|
||||
EventType,
|
||||
MACPEvent,
|
||||
GateResult,
|
||||
RunGatesResult,
|
||||
TaskResult,
|
||||
ProviderMeta,
|
||||
ProviderRegistry,
|
||||
@@ -18,6 +20,11 @@ export type {
|
||||
|
||||
export { CredentialError } from './types.js';
|
||||
|
||||
// Typed fail-closed capability errors (RI-N2, SDLC-D-035)
|
||||
export { MACP_ERROR_CODES, MACPCapabilityError } from './errors.js';
|
||||
|
||||
export type { MacpErrorCode } from './errors.js';
|
||||
|
||||
// Credential resolver
|
||||
export {
|
||||
DEFAULT_CREDENTIALS_DIR,
|
||||
@@ -35,9 +42,16 @@ export {
|
||||
export type { ResolveCredentialsOptions } from './credential-resolver.js';
|
||||
|
||||
// Gate runner
|
||||
export { normalizeGate, runShell, countAIFindings, runGate, runGates } from './gate-runner.js';
|
||||
export {
|
||||
normalizeGate,
|
||||
runShell,
|
||||
countAIFindings,
|
||||
runGate,
|
||||
runGates,
|
||||
SIMULATED_GATE_REASON,
|
||||
} from './gate-runner.js';
|
||||
|
||||
export type { NormalizedGate } from './gate-runner.js';
|
||||
export type { NormalizedGate, RunGateOptions } from './gate-runner.js';
|
||||
|
||||
// Risk-floor (agent reflection loop — diff review classifier)
|
||||
export { evaluateRiskFloor, DEFAULT_RISK_THRESHOLD } from './risk-floor.js';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { MacpErrorCode } from './errors.js';
|
||||
|
||||
/** Task status values. */
|
||||
export type TaskStatus = 'pending' | 'running' | 'gated' | 'completed' | 'failed' | 'escalated';
|
||||
|
||||
@@ -17,7 +19,17 @@ export type DispatchMode = 'yolo' | 'acp' | 'exec';
|
||||
export type DependsOnPolicy = 'all' | 'any' | 'all_terminal';
|
||||
|
||||
/** Quality gate type. */
|
||||
export type GateType = 'mechanical' | 'ai-review' | 'ci-pipeline';
|
||||
export type GateType = 'mechanical' | 'ai-review' | 'ci-pipeline' | 'manual';
|
||||
|
||||
/**
|
||||
* Typed execution state of a gate — closed set (RI-N2, SDLC-D-035).
|
||||
*
|
||||
* Only `passed` means "really executed and green". `simulated` is produced
|
||||
* exclusively under an explicit simulate opt-in and never satisfies anything.
|
||||
* `capability_failure` means a required executor/provider/command was absent.
|
||||
* `waiting` means a manual gate awaits human sign-off (neither pass nor fail).
|
||||
*/
|
||||
export type GateStatus = 'passed' | 'failed' | 'simulated' | 'waiting' | 'capability_failure';
|
||||
|
||||
/** Gate fail_on mode. */
|
||||
export type GateFailOn = 'blocker' | 'any';
|
||||
@@ -67,7 +79,9 @@ export type EventType =
|
||||
| 'task.retry.scheduled'
|
||||
| 'rail.check.started'
|
||||
| 'rail.check.passed'
|
||||
| 'rail.check.failed';
|
||||
| 'rail.check.failed'
|
||||
| 'rail.check.waiting'
|
||||
| 'rail.check.simulated';
|
||||
|
||||
/** Structured event record. */
|
||||
export interface MACPEvent {
|
||||
@@ -88,7 +102,14 @@ export interface GateResult {
|
||||
type: string;
|
||||
output: string;
|
||||
timed_out: boolean;
|
||||
/** Back-compat boolean view — true ONLY when `status === 'passed'`. */
|
||||
passed: boolean;
|
||||
/** Typed discriminator — the authoritative gate outcome (RI-N2). */
|
||||
status: GateStatus;
|
||||
/** Typed capability error code, set when `status === 'capability_failure'`. */
|
||||
capability_code?: MacpErrorCode;
|
||||
/** Why a non-executed state (simulated/waiting/capability_failure) was reached. */
|
||||
reason?: string;
|
||||
fail_on?: string;
|
||||
blockers?: number;
|
||||
findings?: number;
|
||||
@@ -96,6 +117,22 @@ export interface GateResult {
|
||||
parse_error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate outcome of `runGates` (RI-N2).
|
||||
*
|
||||
* `state` is the typed aggregate: it is `passed` only when every gate really
|
||||
* executed green. A `simulated` result makes the aggregate `simulated` (never
|
||||
* `passed`); a `waiting` manual gate keeps the aggregate `waiting`; a missing
|
||||
* capability makes it `capability_failure`. `allPassed` is exactly
|
||||
* `state === 'passed'`, so a simulated or waiting result can never satisfy a
|
||||
* dependency, acceptance criterion, gate, merge, or release check.
|
||||
*/
|
||||
export interface RunGatesResult {
|
||||
allPassed: boolean;
|
||||
gateResults: GateResult[];
|
||||
state: GateStatus;
|
||||
}
|
||||
|
||||
/** Result from a completed task. */
|
||||
export interface TaskResult {
|
||||
task_id: string;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// homedir/platform are read at call time, so they can be stubbed per case.
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:os')>();
|
||||
return {
|
||||
...actual,
|
||||
homedir: () => '/home/tester',
|
||||
platform: () => mockPlatform,
|
||||
};
|
||||
});
|
||||
|
||||
let mockPlatform: NodeJS.Platform = 'linux';
|
||||
|
||||
const { getShellProfilePath, detectShell } = await import('../../src/platform/detect.js');
|
||||
|
||||
describe('getShellProfilePath', () => {
|
||||
const originalShell = process.env['SHELL'];
|
||||
const originalZdotdir = process.env['ZDOTDIR'];
|
||||
|
||||
beforeEach(() => {
|
||||
mockPlatform = 'linux';
|
||||
delete process.env['ZDOTDIR'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalShell === undefined) delete process.env['SHELL'];
|
||||
else process.env['SHELL'] = originalShell;
|
||||
if (originalZdotdir === undefined) delete process.env['ZDOTDIR'];
|
||||
else process.env['ZDOTDIR'] = originalZdotdir;
|
||||
});
|
||||
|
||||
// The regression this guards: setupPath() in stages/finalize.ts appends the
|
||||
// PATH export to whatever this returns. A line written to ~/.bashrc is
|
||||
// unreachable to `bash -lc`, systemd units and agent seats, because Debian's
|
||||
// default .bashrc returns early for non-interactive shells — so an install
|
||||
// reported success and left `mosaic: command not found`. Same for .zshrc,
|
||||
// which zsh only reads for interactive shells.
|
||||
it('never targets an interactive-only rc file', () => {
|
||||
for (const shell of ['/bin/bash', '/usr/bin/zsh']) {
|
||||
process.env['SHELL'] = shell;
|
||||
const profile = getShellProfilePath();
|
||||
expect(profile).not.toMatch(/\.bashrc$/);
|
||||
expect(profile).not.toMatch(/\.zshrc$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses ~/.profile for bash', () => {
|
||||
process.env['SHELL'] = '/bin/bash';
|
||||
expect(getShellProfilePath()).toBe('/home/tester/.profile');
|
||||
});
|
||||
|
||||
it('uses ~/.zshenv for zsh', () => {
|
||||
process.env['SHELL'] = '/usr/bin/zsh';
|
||||
expect(getShellProfilePath()).toBe('/home/tester/.zshenv');
|
||||
});
|
||||
|
||||
it('honours ZDOTDIR for zsh', () => {
|
||||
process.env['SHELL'] = '/usr/bin/zsh';
|
||||
process.env['ZDOTDIR'] = '/custom/zdot';
|
||||
expect(getShellProfilePath()).toBe('/custom/zdot/.zshenv');
|
||||
});
|
||||
|
||||
it('falls back to ~/.profile for an unknown shell', () => {
|
||||
process.env['SHELL'] = '/bin/somethingelse';
|
||||
expect(detectShell()).toBe('unknown');
|
||||
expect(getShellProfilePath()).toBe('/home/tester/.profile');
|
||||
});
|
||||
|
||||
it('still routes fish to its own config', () => {
|
||||
process.env['SHELL'] = '/usr/bin/fish';
|
||||
expect(getShellProfilePath()).toBe('/home/tester/.config/fish/config.fish');
|
||||
});
|
||||
});
|
||||
@@ -13,8 +13,7 @@ Pi is the native Mosaic agent runtime. The `mosaic pi` launcher:
|
||||
|
||||
1. Injects the full runtime contract via `--append-system-prompt`
|
||||
2. Loads Mosaic skills via `--skill` flags
|
||||
3. Loads framework-owned `mosaic-extension.ts` and `goal-extension.ts` from
|
||||
`~/.config/mosaic/runtime/pi/` via ordered `--extension` flags
|
||||
3. Loads the Mosaic extension via `--extension` for lifecycle hooks
|
||||
4. Detects active missions and injects initial prompts
|
||||
|
||||
## Capabilities vs Other Runtimes
|
||||
@@ -23,7 +22,6 @@ Pi is the native Mosaic agent runtime. The `mosaic pi` launcher:
|
||||
- Native thinking levels replace sequential-thinking MCP
|
||||
- Native skill discovery compatible with Mosaic SKILL.md format
|
||||
- Native extension system for lifecycle hooks (TypeScript, not bash shims)
|
||||
- Bounded persistent `/goal` loop with per-turn, post-compaction, and two-pass evidence checks
|
||||
- Native session persistence and resume
|
||||
- Model-agnostic (Anthropic, OpenAI, Google, Ollama, custom providers)
|
||||
|
||||
|
||||
@@ -104,14 +104,7 @@ The launcher:
|
||||
1. Verifies `~/.config/mosaic` exists
|
||||
2. Verifies `SOUL.md` exists (auto-runs `mosaic init` if missing)
|
||||
3. Injects `AGENTS.md` into the runtime
|
||||
4. For Pi, loads the framework-owned core and persistent-goal extensions from
|
||||
`~/.config/mosaic/runtime/pi/`
|
||||
5. Forwards all arguments to the runtime CLI
|
||||
|
||||
Inside `mosaic pi`, `/goal set <statement>` starts a bounded persistent goal loop. Use `/goal status`,
|
||||
`/goal pause`, `/goal resume`, or `/goal cancel` to control it. The extension remains part of Mosaic
|
||||
under `~/.config/mosaic/runtime/pi/goal-extension.ts`; it is not installed in Pi's main extension
|
||||
directory.
|
||||
4. Forwards all arguments to the runtime CLI
|
||||
|
||||
You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtime adapters will tell the agent to read `~/.config/mosaic/AGENTS.md`.
|
||||
|
||||
@@ -131,7 +124,7 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim
|
||||
│ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks
|
||||
│ ├── codex/ ← instructions.md, RUNTIME.md
|
||||
│ ├── opencode/ ← AGENTS.md, RUNTIME.md
|
||||
│ ├── pi/ ← RUNTIME.md, mosaic-extension.ts, goal-extension.ts
|
||||
│ ├── pi/ ← RUNTIME.md, mosaic-extension.ts
|
||||
│ └── mcp/ ← MCP server configs
|
||||
├── skills/ ← Universal skills (synced from mosaic/agent-skills)
|
||||
├── skills-local/ ← Local cross-runtime skills
|
||||
@@ -143,7 +136,7 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim
|
||||
|
||||
| Launch method | Injection mechanism |
|
||||
| ------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `mosaic pi` | `--append-system-prompt` with composed runtime contract + skills + Mosaic extensions |
|
||||
| `mosaic pi` | `--append-system-prompt` with composed runtime contract + skills + extension |
|
||||
| `mosaic claude` | `--append-system-prompt` with composed runtime contract (`AGENTS.md` + runtime reference) |
|
||||
| `mosaic codex` | Writes composed runtime contract to `~/.codex/instructions.md` before launch |
|
||||
| `mosaic opencode` | Writes composed runtime contract to `~/.config/opencode/AGENTS.md` before launch |
|
||||
|
||||
@@ -60,52 +60,6 @@ If a repo does not expose these scripts, run equivalent local workflow commands
|
||||
- Do not auto-resolve data conflicts in shared state files.
|
||||
- Keep commits scoped to a single logical change set.
|
||||
|
||||
## Model Tiering
|
||||
|
||||
Model choice is a standard, not a preference. Delegating a mechanical grep to a
|
||||
frontier reasoning model wastes budget; sending a security review to a cheap tier
|
||||
produces a review that passes and proves nothing. Both are defects.
|
||||
|
||||
Tiers are named by **capability class**, so the standard survives a model
|
||||
generation. An operator binds each class to a concrete model id.
|
||||
|
||||
| Class | Use for |
|
||||
| ------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `search` | grep/glob, file location, status and health checks, one-line mechanical edits |
|
||||
| `build` | feature implementation, test writing, bugfixes, routine refactors |
|
||||
| `judge` | code review, planning, API/compat-sensitive changes |
|
||||
| `adversarial` | security review, ambiguous architecture, anything where a wrong "looks fine" is expensive |
|
||||
|
||||
Rules:
|
||||
|
||||
1. **Start at the cheapest class that can do the task; escalate on evidence, not
|
||||
on nerves.** Omitting a tier is not neutral — it inherits the caller's model,
|
||||
which is usually the most expensive one.
|
||||
2. **Compat-sensitive work escalates one class.** A change that must interoperate
|
||||
with an existing contract is judged, not just built.
|
||||
3. **A tier assignment is benchmarked, not asserted.** Move a task class to a
|
||||
cheaper tier only against a blind A/B on real work from this codebase, ranked
|
||||
by someone other than the author. "It seemed fine" is not evidence.
|
||||
4. **Reviewer independence beats reviewer size.** An `adversarial` verdict from
|
||||
the model that wrote the code is not a second opinion (see Constitution gate 16).
|
||||
|
||||
### Where the binding lives
|
||||
|
||||
The class→model map is operator configuration, never framework source: model
|
||||
availability, cost, and quotas differ per operator and per host.
|
||||
|
||||
Resolution order, first hit wins:
|
||||
|
||||
1. the config service (DB-backed, surfaced and editable in the Mosaic webUI)
|
||||
2. a local operator file (`STANDARDS.local.md`, or `policy/` where the runtime
|
||||
injects it)
|
||||
3. the framework default — the class names above, with no binding
|
||||
|
||||
Only layer 1 is auditable across a fleet, so it is the target end state; layers 2
|
||||
and 3 exist so a host with no config service still runs. A local override that
|
||||
silently disagrees with the config service is drift — the same failure class the
|
||||
tool-index gate exists to catch, and it belongs in `mosaic doctor`.
|
||||
|
||||
## Prompting Contract
|
||||
|
||||
All runtime adapters should inject:
|
||||
|
||||
@@ -12,6 +12,33 @@ 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
|
||||
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/minimal.yaml` starts one local canary slot.
|
||||
|
||||
@@ -11,106 +11,22 @@ All tool suites are located at `~/.config/mosaic/tools/`.
|
||||
|
||||
Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection and edge cases. Always use these before raw CLI commands.
|
||||
|
||||
This index is complete and is kept complete mechanically: `tools/quality/scripts/check-tools-index.sh`
|
||||
fails CI when a wrapper ships without an entry here, or when an entry here names a wrapper that no
|
||||
longer exists. A wrapper missing from this list is, from inside an agent session, indistinguishable
|
||||
from a wrapper that was never written — which is how the APPROVE/APPROVED incident below happened.
|
||||
|
||||
Every command takes `--help`. All of them accept `--login <account>` to pin the acting identity;
|
||||
supply it explicitly on any host where the provider CLI's default account is an admin.
|
||||
|
||||
| Issues | |
|
||||
| ------------------ | --------------------------------- |
|
||||
| `issue-create.sh` | Create an issue (Gitea or GitHub) |
|
||||
| `issue-view.sh` | Show one issue |
|
||||
| `issue-list.sh` | List issues |
|
||||
| `issue-edit.sh` | Edit title/body/labels/milestone |
|
||||
| `issue-comment.sh` | Add a comment |
|
||||
| `issue-assign.sh` | Assign or unassign |
|
||||
| `issue-close.sh` | Close an issue |
|
||||
| `issue-reopen.sh` | Reopen a closed issue |
|
||||
|
||||
| Pull requests | |
|
||||
| ---------------- | --------------------------------------------------------- |
|
||||
| `pr-create.sh` | Open a pull request |
|
||||
| `pr-edit.sh` | Edit PR title, body, base branch, or draft/ready state |
|
||||
| `pr-view.sh` | Show one PR |
|
||||
| `pr-list.sh` | List PRs |
|
||||
| `pr-diff.sh` | Fetch a PR's diff |
|
||||
| `pr-metadata.sh` | PR metadata as JSON (head SHA, base, state, mergeability) |
|
||||
| `pr-review.sh` | **Place a review verdict — see the dialect note below** |
|
||||
| `pr-ci-wait.sh` | Block until the PR's CI reaches a terminal state |
|
||||
| `pr-merge.sh` | Merge a PR |
|
||||
| `pr-close.sh` | Close a PR without merging |
|
||||
|
||||
| Milestones | |
|
||||
| --------------------- | ------------------ |
|
||||
| `milestone-create.sh` | Create a milestone |
|
||||
| `milestone-list.sh` | List milestones |
|
||||
| `milestone-close.sh` | Close a milestone |
|
||||
|
||||
| Gates and guards | |
|
||||
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `ci-queue-wait.sh` | CI queue guard — required before push/merge (see below) |
|
||||
| `push-guard.sh` | Refuse verifications that pass for the wrong reason (e.g. green against an unpushed tree) |
|
||||
| `mutate-push-guard.sh` | Regenerate the guard's mutation-coverage table from measurement, so the table cannot drift from the guard |
|
||||
| `verify-clean-clone.sh` | Prove the **committed** artifact runs, from a clean clone — not the working tree |
|
||||
|
||||
| Context | |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `detect-platform.sh` | Resolve the provider (Gitea vs GitHub) for the current repo; every other wrapper uses it |
|
||||
| `lane-brief.sh` | Live dispatch brief for a repo "lane" (milestone/label) straight from the provider |
|
||||
|
||||
| Workspace | |
|
||||
| -------------------- | ------------------------------------------------------------------------ |
|
||||
| `mosaic-worktree.sh` | Create/list/remove git worktrees — **the only supported way**; see below |
|
||||
| `wrapper-guard.sh` | PreToolUse hook that enforces the two rules above; not called by hand |
|
||||
|
||||
**Workspace placement is derived, not chosen.** `mosaic-worktree.sh new <branch>` takes a branch
|
||||
name and nothing else. Every path comes out of `git worktree list --porcelain` — main worktree,
|
||||
repo name, parent dir, then `<parent>/<repo>-worktrees/<branch-slug>`. There is no placement flag
|
||||
because a decision an agent has to make is a decision that drifts: the rule "big work goes on a work
|
||||
filesystem" already existed in prose and 255 GB accumulated in `$HOME` across 842 directories
|
||||
anyway, under five simultaneous conventions on a single host.
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/tools/git/mosaic-worktree.sh new <branch> [--from <base>]
|
||||
~/.config/mosaic/tools/git/mosaic-worktree.sh path <branch> # derived path, no side effect
|
||||
~/.config/mosaic/tools/git/mosaic-worktree.sh list # this repo's worktrees + state
|
||||
~/.config/mosaic/tools/git/mosaic-worktree.sh rm <branch> # removal is part of the task
|
||||
~/.config/mosaic/tools/git/mosaic-worktree.sh gc [--apply] # reclaim clean + fully-pushed ones
|
||||
```
|
||||
# Issues
|
||||
~/.config/mosaic/tools/git/issue-create.sh
|
||||
~/.config/mosaic/tools/git/issue-close.sh
|
||||
|
||||
Worktrees rather than clones, because `git worktree list` makes every checkout enumerable — a bare
|
||||
clone dropped somewhere on disk can never be safely reclaimed, so it is never reclaimed. `rm` and
|
||||
`gc` decide by **evidence, never by size or age**: a worktree is reclaimable only when
|
||||
`git status --porcelain` is empty _and_ `git rev-list --count HEAD --not --remotes` is 0. Anything
|
||||
else is preserved and reported. `--force` exists and is yours to type deliberately.
|
||||
# PRs
|
||||
~/.config/mosaic/tools/git/pr-create.sh
|
||||
~/.config/mosaic/tools/git/pr-merge.sh
|
||||
|
||||
`wrapper-guard.sh` is registered as a Claude Code `PreToolUse` hook on `Bash` (see
|
||||
`runtime/claude/settings.json`). It blocks exactly three things and lets everything else through:
|
||||
a `git clone`/`git worktree add` targeting `$HOME`; a raw provider-API **write** to an endpoint that
|
||||
already has a wrapper above (reads are untouched — they are how you gather evidence); and the
|
||||
literal `"event": "APPROVE"`. For a genuine gap no wrapper can express, prefix
|
||||
`MOSAIC_WRAPPER_OVERRIDE=1`. Reaching for the override twice for the same call means the wrapper has
|
||||
a missing flag — extend the wrapper.
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/tools/git/issue-create.sh --help
|
||||
~/.config/mosaic/tools/git/pr-review.sh --pr 42 --event APPROVED --body "..."
|
||||
# Milestones
|
||||
~/.config/mosaic/tools/git/milestone-create.sh
|
||||
|
||||
# CI queue guard (required before push/merge; defaults to the checked-out branch)
|
||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
|
||||
```
|
||||
|
||||
**Review dialect — the reason `pr-review.sh` is not optional.** Gitea's approve event is
|
||||
`APPROVED`; GitHub's is `APPROVE`. Send GitHub's spelling to a Gitea host and it answers **HTTP
|
||||
200**, files the review as PENDING, and then rejects the submit with `422 review stay pending` — the
|
||||
verdict looks placed and is not. (`REQUEST_CHANGES` is spelled identically on both, so only the
|
||||
approve path carries the trap.) `pr-review.sh` sends the correct token for the detected provider.
|
||||
Whatever you use, re-read `GET /pulls/{n}/reviews` and assert the state before reporting a verdict
|
||||
placed.
|
||||
|
||||
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
|
||||
|
||||
### Code Review (Codex)
|
||||
|
||||
@@ -35,6 +35,18 @@ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
||||
|
||||
# Normalize the ambient umask so directory modes are a property of the installer
|
||||
# and not of whatever shell invoked it (#1236). Debian/Ubuntu ship umask 002, so
|
||||
# every `mkdir -p` below yielded 0775 — and the fleet env boundary rejects any
|
||||
# managed directory with `mode & 0o022`, which made `mosaic fleet init --write`
|
||||
# impossible on a stock install of those distros. Fedora/RHEL ship 022 and did
|
||||
# not trip it, so the product worked or did not depending on the operator's
|
||||
# login shell. 022 is what this script already assumes it produces: see the
|
||||
# umask note in make_durable_snapshot, which restores to the ambient value
|
||||
# precisely so "every later sync copy and new framework dir" gets 0644/0755.
|
||||
# Now that value is 022 rather than whatever was inherited.
|
||||
umask 022
|
||||
|
||||
# Deliberately parsed from "$@" (a real, explicit, per-invocation argument) —
|
||||
# never an environment variable — so this opt-out can never sit silently
|
||||
# inherited in a shell profile. See #869 Point-1 C2.
|
||||
@@ -696,6 +708,52 @@ sync_framework
|
||||
mkdir -p "$TARGET_DIR/memory"
|
||||
mkdir -p "$TARGET_DIR/credentials"
|
||||
|
||||
# Three directories must be 0700, not merely not-group-writable (#1236).
|
||||
# The fleet code guards them with two different masks in two different
|
||||
# languages, and the strict one wins:
|
||||
#
|
||||
# assertPrivateManagedDirectory (fleet-reconciler.js, `mode & 0o077`)
|
||||
# -> MOSAIC_HOME and MOSAIC_HOME/fleet, checked before the roster lock is
|
||||
# taken, so every mutating `mosaic fleet` command dies at 0755.
|
||||
# assert_private_directory (tools/fleet/start-agent-session.sh, `mode & 077`)
|
||||
# -> MOSAIC_HOME/fleet/agents, checked before a pane is ever spawned.
|
||||
#
|
||||
# Their laxer siblings (`mode & 0o022`) accept 0755, which is why normalizing
|
||||
# the umask above is necessary and not sufficient — a correct umask-022 install
|
||||
# still produces 0755 and still cannot run `mosaic fleet init --write`. Say the
|
||||
# strict modes outright rather than inferring them from a umask.
|
||||
#
|
||||
# Only these. The rest of the tree is content, stays 0755, and is only ever
|
||||
# reached by the 0o022 checks, which 0755 satisfies.
|
||||
chmod 700 "$TARGET_DIR" 2>/dev/null || \
|
||||
warn "Could not set 0700 on $TARGET_DIR — 'mosaic fleet' mutations will fail as unsafe-permissions."
|
||||
if [[ -d "$TARGET_DIR/fleet" ]]; then
|
||||
chmod 700 "$TARGET_DIR/fleet" 2>/dev/null || \
|
||||
warn "Could not set 0700 on $TARGET_DIR/fleet — 'mosaic fleet' mutations will fail as unsafe-permissions."
|
||||
fi
|
||||
# fleet/agents does not exist on a first install — the CLI creates it 0700 on
|
||||
# demand. It is chmod'd here for the UPGRADE case: a tree built under umask 002
|
||||
# has it at 0775, and the repair sweep below cannot rescue it, because stripping
|
||||
# group/other write from 0755 leaves 0750 and `mode & 077` is still non-zero.
|
||||
if [[ -d "$TARGET_DIR/fleet/agents" ]]; then
|
||||
chmod 700 "$TARGET_DIR/fleet/agents" 2>/dev/null || \
|
||||
warn "Could not set 0700 on $TARGET_DIR/fleet/agents — agent sessions will fail to start as unsafe-permissions."
|
||||
fi
|
||||
# credentials/ holds secrets and was never meant to be group-readable either.
|
||||
# It is not on the fleet boundary, so a failure here breaks nothing — but it is
|
||||
# the one directory where a silently-failed chmod leaves secrets group-readable,
|
||||
# which is precisely the failure worth a line in the output.
|
||||
chmod 700 "$TARGET_DIR/credentials" 2>/dev/null || \
|
||||
warn "Could not set 0700 on $TARGET_DIR/credentials — stored secrets may be readable by other users on this host."
|
||||
|
||||
# Repair an existing tree. The umask above only governs directories this run
|
||||
# creates, so a host installed under umask 002 before this fix keeps its 0775
|
||||
# dirs through every upgrade and stays broken. Strips group/other WRITE only —
|
||||
# never read or execute — so it can repair the boundary violation without
|
||||
# changing who can traverse or read anything. Scoped to directories: file modes
|
||||
# are the manifest's business, not this fix's.
|
||||
find "$TARGET_DIR" -type d -perm /022 -exec chmod go-w {} + 2>/dev/null || true
|
||||
|
||||
# Reconcile contract files from defaults/ into the framework root: framework-owned
|
||||
# files (CONSTITUTION/AGENTS/STANDARDS) are overwritten every upgrade (a divergent
|
||||
# copy is backed up once); user-seeded files (TOOLS) are written on first install only.
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreCompact": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason pre-compact"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "compact",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-compact"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "resume|clear",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-rollover --bump-generation"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "^/mosaic-promote$",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.config/mosaic/tools/qa/prevent-memory-write.sh",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude --recovery-command ~/.config/mosaic/tools/lease-broker/recover-context.py",
|
||||
"timeout": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,60 @@
|
||||
{
|
||||
"model": "opus",
|
||||
"hooks": {
|
||||
"PreCompact": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason pre-compact"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "compact",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-compact"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "resume|clear",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 \"$HOME/.config/mosaic/tools/lease-broker/revoke-lease.py\" --runtime claude --reason session-start-rollover --bump-generation"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "^/mosaic-promote$",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/promote-begin.py",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": ".*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude --recovery-command ~/.config/mosaic/tools/lease-broker/recover-context.py",
|
||||
"timeout": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
@@ -11,16 +64,6 @@
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.config/mosaic/tools/git/wrapper-guard.sh",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
@@ -48,6 +91,11 @@
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude --latest-entry; observer_status=$?; python3 ~/.config/mosaic/tools/lease-broker/promote-complete.py; exit $observer_status",
|
||||
"timeout": 15
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh",
|
||||
@@ -267,11 +315,5 @@
|
||||
"cpan",
|
||||
"nohup"
|
||||
],
|
||||
"enableAllMcpTools": true,
|
||||
"mcpServers": {
|
||||
"sequential-thinking": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"]
|
||||
}
|
||||
}
|
||||
"enableAllMcpTools": true
|
||||
}
|
||||
|
||||
@@ -51,26 +51,12 @@ Skills are discovered from:
|
||||
|
||||
### Extensions
|
||||
|
||||
`mosaic pi` loads framework-owned extensions directly from `~/.config/mosaic/runtime/pi/` in this
|
||||
order:
|
||||
The Mosaic Pi extension (`~/.config/mosaic/runtime/pi/mosaic-extension.ts`) handles:
|
||||
|
||||
1. `mosaic-extension.ts` — session lifecycle, mission context, memory routing, lease/mutator gates,
|
||||
and fleet heartbeat reporting.
|
||||
2. `goal-extension.ts` — optional persistent `/goal` controller with per-turn and post-compaction
|
||||
checks.
|
||||
|
||||
The goal extension is deployed by Mosaic and MUST NOT be copied into `~/.pi/agent/extensions/`.
|
||||
Use `/goal set <statement>` (or `/goal <statement>`) to start, then `/goal status`, `/goal pause`,
|
||||
`/goal resume`, or `/goal cancel` to control it. An active goal is injected before every model
|
||||
request, restored from branch-specific session entries, and considered achieved only after two
|
||||
consecutive evidence-bearing reports. Common credential shapes are redacted before controller-owned
|
||||
goal-state entries are persisted or
|
||||
displayed; Pi's own model/tool-call history is separate. Goals and reports must contain references
|
||||
and pass/fail summaries rather than secrets or raw sensitive output.
|
||||
|
||||
- `MOSAIC_GOAL_MAX_TURNS` — autonomous turn limit, default `40`, accepted range `1..500`.
|
||||
- `MOSAIC_GOAL_MAX_NO_PROGRESS` — identical no-progress report limit, default `6`, accepted range
|
||||
`1..100`.
|
||||
- Session start/end lifecycle hooks
|
||||
- Active mission detection and context injection
|
||||
- Memory routing to `~/.config/mosaic/memory/`
|
||||
- MACP queue status reporting
|
||||
|
||||
### Sessions
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,14 @@ Documentation=https://git.mosaicstack.dev/mosaicstack/stack
|
||||
Requires=mosaic-tmux-holder.service
|
||||
After=mosaic-tmux-holder.service
|
||||
PartOf=mosaic-tmux-holder.service
|
||||
# Do not attempt a seat before its generated env exists. `install` enables this
|
||||
# unit (WantedBy=default.target) but on a roster-v2 fleet the reconciler owns the
|
||||
# generated env, so between `install` and the first `apply`/`regen --write` there
|
||||
# is a boot window where ExecStart would run against an absent env file and the
|
||||
# launcher would fail the unit. A skipped unit is the honest state for "enabled
|
||||
# but not yet configured"; systemd re-evaluates the condition on every start, so
|
||||
# the seat comes up on the next start once the reconciler has written env.
|
||||
ConditionPathExists=%h/.config/mosaic/fleet/agents/%i.env.generated
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
|
||||
@@ -66,10 +66,7 @@ if command -v tmux >/dev/null 2>&1 && command -v cc >/dev/null 2>&1; then
|
||||
trap 'tmux -L "$TEST_SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$TEST_ROOT"' EXIT
|
||||
MARKER="$TEST_ROOT/loader-marker"
|
||||
LIBRARY="$TEST_ROOT/marker.so"
|
||||
FIXTURE_READY="$TEST_ROOT/loader-ready"
|
||||
FIXTURE_FIFO="$TEST_ROOT/loader-block"
|
||||
HOLDER_HOME="$TEST_ROOT/holder-home"
|
||||
mkfifo "$FIXTURE_FIFO"
|
||||
mkdir -p "$HOLDER_HOME/.config/mosaic/fleet/run"
|
||||
chmod 700 "$HOLDER_HOME/.config" "$HOLDER_HOME/.config/mosaic" \
|
||||
"$HOLDER_HOME/.config/mosaic/fleet" "$HOLDER_HOME/.config/mosaic/fleet/run"
|
||||
@@ -90,17 +87,7 @@ __attribute__((constructor)) static void mark_loader(void) {
|
||||
EOF
|
||||
cc -shared -fPIC -o "$LIBRARY" "$TEST_ROOT/marker.c"
|
||||
MOSAIC_LOADER_MARKER="$MARKER" LD_PRELOAD="$LIBRARY" \
|
||||
tmux -L "$TEST_SOCKET" new-session -d -s _holder \
|
||||
"touch '$FIXTURE_READY'; read _ < '$FIXTURE_FIFO'"
|
||||
# tmux starts the pane asynchronously. Wait until its contaminated shell has
|
||||
# loaded the constructor and reached a builtin-only FIFO barrier before
|
||||
# clearing the marker; otherwise that expected constructor can race with the
|
||||
# clean holder assertion below and create a false failure.
|
||||
for _attempt in {1..100}; do
|
||||
[ -e "$FIXTURE_READY" ] && break
|
||||
sleep 0.01
|
||||
done
|
||||
[ -e "$FIXTURE_READY" ] || fail "contaminated fixture pane did not become ready"
|
||||
tmux -L "$TEST_SOCKET" new-session -d -s _holder 'sleep 60'
|
||||
[ -s "$MARKER" ] || fail "contaminated fixture did not execute loader constructor"
|
||||
server_pid=$(tmux -L "$TEST_SOCKET" display-message -p '#{pid}')
|
||||
: > "$MARKER"
|
||||
@@ -125,7 +112,6 @@ EOF
|
||||
chmod 700 "$AGENT_HOME/fleet/agents"
|
||||
cat > "$AGENT_HOME/fleet/agents/$AGENT_NAME.env.generated" <<EOF
|
||||
MOSAIC_AGENT_NAME=$AGENT_NAME
|
||||
MOSAIC_GIT_IDENTITY=$AGENT_NAME
|
||||
MOSAIC_AGENT_CLASS=code
|
||||
MOSAIC_AGENT_RUNTIME=pi
|
||||
MOSAIC_AGENT_MODEL=
|
||||
@@ -142,6 +128,14 @@ EOF
|
||||
sleep 30
|
||||
EOF
|
||||
chmod 700 "$AGENT_BIN/mosaic"
|
||||
# The launcher resolves the roster's runtime against PANE_PATH before it
|
||||
# spawns anything (#1241), so the runtime this projection names has to be
|
||||
# present here even though the fake `mosaic` above never execs it.
|
||||
cat > "$AGENT_BIN/pi" <<'EOF'
|
||||
#!/bin/sh
|
||||
sleep 30
|
||||
EOF
|
||||
chmod 700 "$AGENT_BIN/pi"
|
||||
server_environment_before=$(tmux -L "$TEST_SOCKET" show-environment -g | sort)
|
||||
server_sessions_before=$(tmux -L "$TEST_SOCKET" list-sessions | sort)
|
||||
if /usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \
|
||||
@@ -158,8 +152,7 @@ EOF
|
||||
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin \
|
||||
MOSAIC_TMUX_SOCKET="$TEST_SOCKET" MOSAIC_TMUX_HOLDER=_holder "$HOLDER_START"
|
||||
tmux -L "$TEST_SOCKET" has-session -t '=_holder:0.0' || fail "fresh holder was not created"
|
||||
ld_preload_env="$(tmux -L "$TEST_SOCKET" show-environment -g LD_PRELOAD 2>/dev/null)" || true
|
||||
if grep -q '^LD_PRELOAD=' <<<"$ld_preload_env"; then
|
||||
if tmux -L "$TEST_SOCKET" show-environment -g LD_PRELOAD 2>/dev/null | grep -q '^LD_PRELOAD='; then
|
||||
fail "fresh holder retained LD_PRELOAD"
|
||||
fi
|
||||
/usr/bin/env -i HOME="$HOLDER_HOME" PATH=/usr/bin:/bin MOSAIC_HOME="$AGENT_HOME" \
|
||||
|
||||
@@ -69,7 +69,7 @@ _manifest_glob_to_ere() {
|
||||
out="$out.*"
|
||||
fi
|
||||
else
|
||||
out="${out}[^/]*"
|
||||
out="$out[^/]*"
|
||||
fi
|
||||
else
|
||||
case "$c" in
|
||||
@@ -87,8 +87,7 @@ _manifest_compile_one() {
|
||||
local norm; norm="$(_manifest_norm "$1")"
|
||||
[[ -n "$norm" ]] || return 0
|
||||
if [[ "$norm" == *"*"* ]]; then
|
||||
local re
|
||||
re="^$(_manifest_glob_to_ere "$norm")\$"
|
||||
local re="^$(_manifest_glob_to_ere "$norm")\$"
|
||||
if [[ "$2" == F ]]; then
|
||||
_MF_KIND+=(re); _MF_EXACT+=(""); _MF_RE+=("$re")
|
||||
else
|
||||
@@ -184,10 +183,7 @@ _mo_matches() {
|
||||
for (( i = 0; i < n; i++ )); do
|
||||
if [[ "${_MO_KIND[i]}" == exact ]]; then
|
||||
pat="${_MO_EXACT[i]}"
|
||||
# Operator exact entries are file carve-outs, not implicit directory
|
||||
# prefixes. Subtree ownership must be declared explicitly as `dir/**`;
|
||||
# otherwise one bare directory entry can hide all drift beneath it.
|
||||
[[ "$path" == "$pat" ]] && return 0
|
||||
[[ "$path" == "$pat" || "$path" == "$pat/"* ]] && return 0
|
||||
else
|
||||
re="${_MO_RE[i]}"
|
||||
[[ "$path" =~ $re ]] && return 0
|
||||
|
||||
@@ -51,6 +51,8 @@ fix() { fix_count=$((fix_count + 1)); echo "[FIX] $*"; }
|
||||
|
||||
warn_count=0
|
||||
warn() { warn_count=$((warn_count + 1)); echo "[WARN] $*"; }
|
||||
note() { echo "[NOTE] $*"; return 0; }
|
||||
|
||||
pass() {
|
||||
if [[ $VERBOSE -eq 1 ]]; then
|
||||
echo "[OK] $*"
|
||||
@@ -153,38 +155,6 @@ warn_if_symlink_tree_present() {
|
||||
|
||||
echo "[mosaic-doctor] Mosaic home: $MOSAIC_HOME"
|
||||
|
||||
# Compare the framework tools that this CLI/package ships with the deployed
|
||||
# ~/.config copy that direct wrappers and systemd units actually execute. Doctor
|
||||
# is the right boundary: observational, operator-invoked, and already designed
|
||||
# to report drift without mutating live tooling or restarting active seats.
|
||||
framework_drift_checker="$(cd -- "$(dirname -- "$0")/../quality/scripts" && pwd)/framework-drift-check.py"
|
||||
if [[ -f "$framework_drift_checker" ]]; then
|
||||
echo "[mosaic-doctor] Checking installed framework-tool drift..."
|
||||
drift_timeout="${MOSAIC_DOCTOR_DRIFT_TIMEOUT_SEC:-15}"
|
||||
if ! [[ "$drift_timeout" =~ ^[1-9][0-9]*$ ]]; then
|
||||
warn "Invalid MOSAIC_DOCTOR_DRIFT_TIMEOUT_SEC='$drift_timeout' (expected positive integer); using 15s"
|
||||
drift_timeout=15
|
||||
fi
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
set +e
|
||||
timeout -s TERM -k 2 "${drift_timeout}s" \
|
||||
python3 "$framework_drift_checker" --installed-root "$MOSAIC_HOME/tools"
|
||||
drift_rc=$?
|
||||
set -e
|
||||
if [[ "$drift_rc" -eq 0 ]]; then
|
||||
pass "Installed framework tools match shipped source"
|
||||
elif [[ "$drift_rc" -eq 124 || "$drift_rc" -eq 137 || "$drift_rc" -eq 143 ]]; then
|
||||
warn "CANNOT_ASSERT framework drift checker timed out after ${drift_timeout}s; continuing remaining doctor checks"
|
||||
else
|
||||
warn "Installed framework-tool drift detected (checker exit $drift_rc; no files changed)"
|
||||
fi
|
||||
else
|
||||
warn "CANNOT_ASSERT timeout utility unavailable; refusing unbounded framework drift check and continuing remaining doctor checks"
|
||||
fi
|
||||
else
|
||||
warn "Framework drift checker is absent from the shipped tools tree"
|
||||
fi
|
||||
|
||||
# Canonical Mosaic checks
|
||||
expect_file "$MOSAIC_HOME/STANDARDS.md"
|
||||
expect_file "$MOSAIC_HOME/USER.md"
|
||||
@@ -257,6 +227,130 @@ else
|
||||
warn "mosaic-ensure-sequential-thinking helper missing"
|
||||
fi
|
||||
|
||||
# Fleet transport binary (#1240).
|
||||
#
|
||||
# `mosaic fleet --help` reads "Manage the local Mosaic tmux fleet" and every
|
||||
# roster the CLI scaffolds sets `transport: tmux`, but nothing in the install
|
||||
# path provides tmux and, until now, nothing here noticed it was absent. On a
|
||||
# greenfield host that produced a fleet which installed clean, started clean,
|
||||
# and had no live seat; `mosaic fleet ps` was the operator's first and only
|
||||
# signal that anything was wrong.
|
||||
#
|
||||
# The roster's own `transport:` is read rather than assumed, so a host that
|
||||
# declares something other than tmux is told about the binary it actually
|
||||
# needs. Absent a roster the check still runs — `mosaic fleet init` will
|
||||
# scaffold a tmux fleet on this host, and finding out beforehand is the point.
|
||||
#
|
||||
# `tools/install.sh` carries a deliberately parallel check at the end of its
|
||||
# summary. The two are separate because the installer must be able to say this
|
||||
# before the framework's own scripts are guaranteed to be on disk; keep their
|
||||
# wording in step.
|
||||
fleet_declared_transport() {
|
||||
local roster="$MOSAIC_HOME/fleet/roster.yaml"
|
||||
local declared=""
|
||||
|
||||
if [[ -f "$roster" ]]; then
|
||||
declared="$(sed -n 's/^[[:space:]]*transport:[[:space:]]*//p' "$roster" | head -1 |
|
||||
tr -d '"'\''' | tr -d '\r' | awk '{print $1}')"
|
||||
fi
|
||||
|
||||
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
|
||||
# Implicit-path greenfield case (#1288 comment 23133, fred's trace): nothing
|
||||
# in product code creates ~/.mosaic/fleet/agents — the first fleet write
|
||||
# resolves legacy (generated-env-boundary resolves before creating) and
|
||||
# then manufactures the evidence that keeps the host legacy. On a host with
|
||||
# ~/.mosaic but no fleet/agents, the three operator checks all agree and all
|
||||
# point the wrong way; this doctor is the only one that can disagree, so it
|
||||
# must say it — as a note, not a warn: nothing is broken yet.
|
||||
if [[ "$(cd "$MOSAIC_HOME" 2>/dev/null && pwd -P)" == "$HOME/.config/mosaic" \
|
||||
&& -d "$HOME/.mosaic" && ! -d "$HOME/.mosaic/fleet/agents" ]]; then
|
||||
note "Fleet state home: $MOSAIC_HOME (legacy). NOTE: ~/.mosaic exists but carries no fleet/agents — the first 'mosaic fleet regen' on this host locks in the legacy tree. Create ~/.mosaic/fleet/agents first to adopt the brain."
|
||||
return
|
||||
fi
|
||||
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() {
|
||||
local transport
|
||||
transport="$(fleet_declared_transport)"
|
||||
|
||||
if command -v "$transport" >/dev/null 2>&1; then
|
||||
pass "Fleet transport available: $transport"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -f "$MOSAIC_HOME/fleet/roster.yaml" ]]; then
|
||||
warn "Fleet transport '$transport' is not installed — this host has a roster and no seat can launch. Install it (e.g. sudo apt-get install -y $transport), then 'mosaic fleet start'."
|
||||
else
|
||||
warn "Fleet transport '$transport' is not installed — 'mosaic fleet' cannot run seats here. Install it (e.g. sudo apt-get install -y $transport) before 'mosaic fleet init'."
|
||||
fi
|
||||
}
|
||||
|
||||
check_fleet_transport
|
||||
|
||||
check_brain_home
|
||||
|
||||
# Legacy migration surfaces should no longer contain symlink trees.
|
||||
legacy_paths=(
|
||||
"$HOME/.claude/agent-guides"
|
||||
|
||||
@@ -5,7 +5,6 @@ MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
MODE="apply"
|
||||
RUNTIME="all"
|
||||
STRICT_CHECK=0
|
||||
CLAUDE_CONFIG_DIR=""
|
||||
|
||||
PKG="@modelcontextprotocol/server-sequential-thinking"
|
||||
|
||||
@@ -30,14 +29,6 @@ while [[ $# -gt 0 ]]; do
|
||||
STRICT_CHECK=1
|
||||
shift
|
||||
;;
|
||||
--claude-config-dir)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
err "--claude-config-dir requires an absolute seat config directory"
|
||||
exit 2
|
||||
fi
|
||||
CLAUDE_CONFIG_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
err "Unknown argument: $1"
|
||||
exit 2
|
||||
@@ -76,19 +67,11 @@ warm_package() {
|
||||
}
|
||||
|
||||
check_claude_config() {
|
||||
CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" python3 - <<'PY'
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
# Claude reads MCP definitions from .claude.json, not settings.json. The
|
||||
# settings.json fallback preserves legacy operator flows until their config is migrated.
|
||||
config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
|
||||
p = Path(config_dir) / ".claude.json" if config_dir else Path.home() / ".claude.json"
|
||||
if not p.exists() and not config_dir:
|
||||
p = Path.home() / ".claude" / "settings.json"
|
||||
# Only explicit fleet seats require a private, non-symlink config. Operator
|
||||
# config remains compatible with pre-existing permission conventions.
|
||||
if not p.exists() or p.is_symlink() or (config_dir and (p.stat().st_mode & 0o077) != 0):
|
||||
p = Path.home() / ".claude" / "settings.json"
|
||||
if not p.exists():
|
||||
raise SystemExit(1)
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
@@ -109,15 +92,10 @@ PY
|
||||
}
|
||||
|
||||
apply_claude_config() {
|
||||
CLAUDE_CONFIG_DIR="$CLAUDE_CONFIG_DIR" python3 - <<'PY'
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
# Claude reads MCP definitions from .claude.json for both operator and
|
||||
# explicitly isolated fleet config dirs. The checker retains a settings.json
|
||||
# fallback only to avoid breaking legacy operator configurations.
|
||||
config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
|
||||
p = Path(config_dir) / ".claude.json" if config_dir else Path.home() / ".claude.json"
|
||||
p = Path.home() / ".claude" / "settings.json"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
if p.exists():
|
||||
try:
|
||||
@@ -139,7 +117,7 @@ PY
|
||||
}
|
||||
|
||||
check_codex_config() {
|
||||
local cfg="${CODEX_HOME:-$HOME/.codex}/config.toml"
|
||||
local cfg="$HOME/.codex/config.toml"
|
||||
[[ -f "$cfg" ]] || return 1
|
||||
grep -Eq '^\[mcp_servers\.(sequential-thinking|sequential_thinking)\]' "$cfg" && \
|
||||
grep -q '^command = "npx"' "$cfg" && \
|
||||
@@ -147,7 +125,7 @@ check_codex_config() {
|
||||
}
|
||||
|
||||
apply_codex_config() {
|
||||
local cfg="${CODEX_HOME:-$HOME/.codex}/config.toml"
|
||||
local cfg="$HOME/.codex/config.toml"
|
||||
mkdir -p "$(dirname "$cfg")"
|
||||
[[ -f "$cfg" ]] || touch "$cfg"
|
||||
|
||||
@@ -170,11 +148,10 @@ apply_codex_config() {
|
||||
}
|
||||
|
||||
check_opencode_config() {
|
||||
XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-}" python3 - <<'PY'
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
p = Path(os.environ["XDG_CONFIG_HOME"]) / "opencode" / "config.json" if os.environ.get("XDG_CONFIG_HOME") else Path.home() / ".config" / "opencode" / "config.json"
|
||||
p = Path.home() / ".config" / "opencode" / "config.json"
|
||||
if not p.exists():
|
||||
raise SystemExit(1)
|
||||
try:
|
||||
@@ -197,11 +174,10 @@ PY
|
||||
}
|
||||
|
||||
apply_opencode_config() {
|
||||
XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-}" python3 - <<'PY'
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
p = Path(os.environ["XDG_CONFIG_HOME"]) / "opencode" / "config.json" if os.environ.get("XDG_CONFIG_HOME") else Path.home() / ".config" / "opencode" / "config.json"
|
||||
p = Path.home() / ".config" / "opencode" / "config.json"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
if p.exists():
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/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
|
||||
|
||||
# note output is neither [OK] nor [WARN] — assert it directly in the case below.
|
||||
run_case() {
|
||||
# label, expect (ok|warn|note), 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 notes
|
||||
out=$(env "$@" bash -c "warn() { echo \"[WARN] \$*\"; }; note() { echo \"[NOTE] \$*\"; return 0; }; 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)
|
||||
notes=$(printf '%s\n' "$out" | grep -c '^\[NOTE\]' || true)
|
||||
if [[ "$expect" == ok && "$warns" -eq 0 && "$notes" -eq 0 ]]; then
|
||||
echo "ok - $label"
|
||||
elif [[ "$expect" == warn && "$warns" -gt 0 ]]; then
|
||||
echo "ok - $label (warned)"
|
||||
elif [[ "$expect" == note && "$notes" -gt 0 ]]; then
|
||||
echo "ok - $label (noted)"
|
||||
else
|
||||
echo "output: $out" >&2
|
||||
fail "$label: expected $expect (warns=$warns notes=$notes)"
|
||||
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"
|
||||
|
||||
# ── greenfield brain-without-agents at the default home → note (#1288) ─────
|
||||
mkdir -p "$ROOT/gf-home/.config/mosaic/fleet" "$ROOT/gf-home/.mosaic"
|
||||
run_case "~/.mosaic without fleet/agents at default home notes the lock-in" note \
|
||||
MOSAIC_HOME="$ROOT/gf-home/.config/mosaic" HOME="$ROOT/gf-home"
|
||||
|
||||
# ── no ~/.mosaic at all at the default home → clean pass ─────────────────
|
||||
mkdir -p "$ROOT/plain-home/.config/mosaic/fleet"
|
||||
run_case "no ~/.mosaic at default home passes silently" ok \
|
||||
MOSAIC_HOME="$ROOT/plain-home/.config/mosaic" HOME="$ROOT/plain-home"
|
||||
|
||||
# ── custom (non-default) home with a stray ~/.mosaic → still silent ──────
|
||||
mkdir -p "$ROOT/custom-home/fleet/agents" "$ROOT/custom-home/.mosaic"
|
||||
run_case "custom home with stray ~/.mosaic stays silent" ok \
|
||||
MOSAIC_HOME="$ROOT/custom-home" HOME="$ROOT/custom-home"
|
||||
|
||||
echo "ok - mosaic-doctor brain-home check"
|
||||
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env bash
|
||||
# Covers the #1240 fleet-transport checks in `mosaic-doctor` and in
|
||||
# `tools/install.sh`.
|
||||
#
|
||||
# Both checks answer the same question — "can a seat actually launch on this
|
||||
# host?" — from two different places, because the installer has to be able to
|
||||
# answer it before the framework's own scripts are guaranteed to be on disk.
|
||||
# Two implementations of one rule is exactly the shape that drifts, so this
|
||||
# harness drives BOTH, in one file, from the same table of cases.
|
||||
#
|
||||
# The functions are extracted from the shipped scripts rather than copied here.
|
||||
# A test that carries its own copy of the logic is a test that keeps passing
|
||||
# after the shipped copy changes — the failure mode this whole change is about.
|
||||
# Extraction is by exact function header and a closing brace in column one; if
|
||||
# either script is reshaped so that stops matching, the extraction yields
|
||||
# nothing and this fails loudly instead of silently measuring an empty string.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
DOCTOR="$SCRIPT_DIR/mosaic-doctor"
|
||||
# framework/tools/_scripts -> framework/tools -> framework -> mosaic -> packages -> repo
|
||||
INSTALLER=$(cd -- "$SCRIPT_DIR/../../../../.." && pwd)/tools/install.sh
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ -f "$DOCTOR" ] || fail "missing mosaic-doctor at $DOCTOR"
|
||||
[ -f "$INSTALLER" ] || fail "missing install.sh at $INSTALLER"
|
||||
|
||||
ROOT=$(mktemp -d)
|
||||
trap 'rm -rf "$ROOT"' EXIT
|
||||
|
||||
# The cases below run with PATH set to a directory that deliberately does not
|
||||
# contain a shell, and a PATH assignment on a command also governs how that
|
||||
# command is looked up — so bash has to be named absolutely or it becomes the
|
||||
# thing that is missing.
|
||||
BASH_BIN=$(command -v bash) || fail "host is missing 'bash'"
|
||||
|
||||
# A PATH containing exactly the utilities these functions use and nothing else.
|
||||
# The absent-transport cases are only meaningful on a PATH where the transport
|
||||
# is genuinely unresolvable, and this host (like most) has tmux in /usr/bin —
|
||||
# so the system path cannot be part of the path under test.
|
||||
FAKE_BIN="$ROOT/bin"
|
||||
mkdir -p "$FAKE_BIN"
|
||||
for utility in sed head tr awk; do
|
||||
utility_path=$(command -v "$utility") || fail "host is missing '$utility'"
|
||||
ln -s "$utility_path" "$FAKE_BIN/$utility"
|
||||
done
|
||||
|
||||
if PATH="$FAKE_BIN" command -v tmux >/dev/null 2>&1; then
|
||||
fail "'tmux' is resolvable on the minimal test path; absent-transport cases are not measurable"
|
||||
fi
|
||||
|
||||
# Extract a function by its exact header, up to a closing brace in column one.
|
||||
extract_function() {
|
||||
local source_file="$1"
|
||||
local function_name="$2"
|
||||
local destination="$3"
|
||||
|
||||
awk -v name="$function_name" '
|
||||
$0 == name "() {" { collecting = 1 }
|
||||
collecting { print }
|
||||
collecting && $0 == "}" { exit }
|
||||
' "$source_file" > "$destination"
|
||||
|
||||
grep -qF "$function_name() {" "$destination" ||
|
||||
fail "could not extract '$function_name' from $source_file — has it been renamed or reshaped?"
|
||||
# An unterminated extraction would be a syntax error the moment it is sourced,
|
||||
# but saying so here names the cause instead of leaving a bash parse error.
|
||||
bash -n "$destination" ||
|
||||
fail "extracted '$function_name' does not parse; the closing brace was probably not found"
|
||||
}
|
||||
|
||||
extract_function "$DOCTOR" fleet_declared_transport "$ROOT/doctor-declared.sh"
|
||||
extract_function "$DOCTOR" check_fleet_transport "$ROOT/doctor-check.sh"
|
||||
extract_function "$INSTALLER" check_fleet_transport "$ROOT/installer-check.sh"
|
||||
|
||||
# Build a MOSAIC_HOME, optionally with a roster declaring a transport.
|
||||
make_home() {
|
||||
local home="$ROOT/$1"
|
||||
local declared="${2-}"
|
||||
|
||||
rm -rf "$home"
|
||||
mkdir -p "$home"
|
||||
if [ -n "$declared" ]; then
|
||||
mkdir -p "$home/fleet"
|
||||
cat > "$home/fleet/roster.yaml" <<EOF
|
||||
version: 2
|
||||
generation: 1
|
||||
transport: $declared
|
||||
agents: []
|
||||
EOF
|
||||
fi
|
||||
printf '%s\n' "$home"
|
||||
}
|
||||
|
||||
# Run the doctor's check against a given home and path, capturing which
|
||||
# reporter the check chose. The real `pass` prints only under `--verbose` and
|
||||
# the real `warn` always prints; these stubs make both unconditional on
|
||||
# purpose, because what is under test is the severity the check selects, not
|
||||
# whether the default verbosity happens to show it. A check that warned where
|
||||
# it should pass would otherwise be invisible here.
|
||||
run_doctor_check() {
|
||||
local home="$1"
|
||||
local path="$2"
|
||||
|
||||
MOSAIC_HOME="$home" PATH="$path" "$BASH_BIN" --noprofile --norc -c '
|
||||
set -euo pipefail
|
||||
warn() { echo "[WARN] $*"; }
|
||||
pass() { echo "[OK] $*"; }
|
||||
MOSAIC_HOME="$1"
|
||||
source "$2"
|
||||
source "$3"
|
||||
check_fleet_transport
|
||||
' _ "$home" "$ROOT/doctor-declared.sh" "$ROOT/doctor-check.sh" 2>&1
|
||||
}
|
||||
|
||||
run_installer_check() {
|
||||
local home="$1"
|
||||
local path="$2"
|
||||
|
||||
MOSAIC_HOME="$home" PATH="$path" "$BASH_BIN" --noprofile --norc -c '
|
||||
set -euo pipefail
|
||||
warn() { echo "[WARN] $*"; }
|
||||
C="" RESET=""
|
||||
MOSAIC_HOME="$1"
|
||||
source "$2"
|
||||
check_fleet_transport
|
||||
' _ "$home" "$ROOT/installer-check.sh" 2>&1
|
||||
}
|
||||
|
||||
# A transport that exists. Named tmux because that is what the default roster
|
||||
# declares; the binary never runs, it only has to resolve.
|
||||
PRESENT_BIN="$ROOT/present-bin"
|
||||
mkdir -p "$PRESENT_BIN"
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$PRESENT_BIN/tmux"
|
||||
chmod +x "$PRESENT_BIN/tmux"
|
||||
PATH_WITH_TMUX="$PRESENT_BIN:$FAKE_BIN"
|
||||
|
||||
# ── absent, no roster ────────────────────────────────────────────────────────
|
||||
# Nothing has been configured yet, so the honest thing to point at is `init`.
|
||||
home=$(make_home no-roster)
|
||||
|
||||
output=$(run_doctor_check "$home" "$FAKE_BIN")
|
||||
echo "$output" | grep -qF '[WARN]' || fail "doctor did not warn when tmux was absent"
|
||||
echo "$output" | grep -qF 'tmux' || fail "doctor warning did not name the transport"
|
||||
echo "$output" | grep -qF 'mosaic fleet init' || fail "doctor did not point a rosterless host at init"
|
||||
|
||||
output=$(run_installer_check "$home" "$FAKE_BIN")
|
||||
echo "$output" | grep -qF '[WARN]' || fail "installer did not warn when tmux was absent"
|
||||
echo "$output" | grep -qF 'reports success and no seat comes up' ||
|
||||
fail "installer warning did not say what the missing transport actually breaks"
|
||||
|
||||
# ── absent, roster present ───────────────────────────────────────────────────
|
||||
# A configured fleet that cannot launch is a stronger statement than a
|
||||
# hypothetical one, and the message says so.
|
||||
home=$(make_home with-roster tmux)
|
||||
|
||||
output=$(run_doctor_check "$home" "$FAKE_BIN")
|
||||
echo "$output" | grep -qF '[WARN]' || fail "doctor did not warn with a roster present and tmux absent"
|
||||
echo "$output" | grep -qF 'roster' || fail "doctor did not mention the roster it found"
|
||||
echo "$output" | grep -qF 'mosaic fleet start' || fail "doctor did not point a configured host at start"
|
||||
|
||||
# ── present ──────────────────────────────────────────────────────────────────
|
||||
# Silence from the installer, and a pass (not a warning) from the audit.
|
||||
for home_name in no-roster with-roster; do
|
||||
home="$ROOT/$home_name"
|
||||
|
||||
output=$(run_doctor_check "$home" "$PATH_WITH_TMUX")
|
||||
if echo "$output" | grep -qF '[WARN]'; then
|
||||
fail "doctor warned about the transport while tmux was present ($home_name)"
|
||||
fi
|
||||
echo "$output" | grep -qF '[OK]' || fail "doctor did not record a pass with tmux present ($home_name)"
|
||||
|
||||
output=$(run_installer_check "$home" "$PATH_WITH_TMUX")
|
||||
if [ -n "$output" ]; then
|
||||
fail "installer was not silent with tmux present ($home_name): $output"
|
||||
fi
|
||||
done
|
||||
|
||||
# ── the roster declares something other than tmux ────────────────────────────
|
||||
# The roster is read, not assumed. A host that declares a different transport
|
||||
# is told about the binary it actually needs, and never about tmux — being sent
|
||||
# to install the wrong package is worse than no advice at all.
|
||||
home=$(make_home other-transport zellij)
|
||||
|
||||
output=$(run_doctor_check "$home" "$PATH_WITH_TMUX")
|
||||
echo "$output" | grep -qF 'zellij' || fail "doctor ignored the roster's declared transport"
|
||||
if echo "$output" | grep -qF 'tmux'; then
|
||||
fail "doctor named tmux for a host whose roster declares zellij"
|
||||
fi
|
||||
|
||||
output=$(run_installer_check "$home" "$PATH_WITH_TMUX")
|
||||
echo "$output" | grep -qF 'zellij' || fail "installer ignored the roster's declared transport"
|
||||
if echo "$output" | grep -qF 'tmux'; then
|
||||
fail "installer named tmux for a host whose roster declares zellij"
|
||||
fi
|
||||
|
||||
# ── a quoted or trailing-comment transport value ─────────────────────────────
|
||||
# YAML permits both and neither is exotic; a check that installs `tmux"` or
|
||||
# reads `tmux # default` as a binary name would send the operator nowhere.
|
||||
home=$(make_home quoted-transport '"tmux" # the only transport today')
|
||||
|
||||
output=$(run_doctor_check "$home" "$PATH_WITH_TMUX")
|
||||
echo "$output" | grep -qF '[OK] Fleet transport available: tmux' ||
|
||||
fail "doctor did not parse a quoted/commented transport value: $output"
|
||||
|
||||
output=$(run_installer_check "$home" "$PATH_WITH_TMUX")
|
||||
if [ -n "$output" ]; then
|
||||
fail "installer did not parse a quoted/commented transport value: $output"
|
||||
fi
|
||||
|
||||
echo "ok - fleet transport checks (mosaic-doctor + install.sh)"
|
||||
@@ -69,7 +69,7 @@ if [[ -n "$GROUP" ]]; then
|
||||
group_response=$(curl -sk \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
"${AUTHENTIK_URL}/api/v3/core/groups/?search=${GROUP}")
|
||||
group_pk=$(jq -r "first(.results[] | select(.name == \"$GROUP\") | .pk) // empty" <<<"$group_response")
|
||||
group_pk=$(echo "$group_response" | jq -r ".results[] | select(.name == \"$GROUP\") | .pk" | head -1)
|
||||
if [[ -n "$group_pk" ]]; then
|
||||
payload=$(echo "$payload" | jq --arg gk "$group_pk" '. + {groups: [$gk]}')
|
||||
else
|
||||
|
||||
@@ -80,6 +80,26 @@ safe_path "$MOSAIC_HOME" || fail_env unsafe-path MOSAIC_HOME "$MOSAIC_HOME"
|
||||
|
||||
FLEET_DIR="$MOSAIC_HOME/fleet"
|
||||
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 "$FLEET_DIR"
|
||||
assert_private_directory "$AGENT_ENV_DIR"
|
||||
@@ -97,7 +117,7 @@ is_sensitive_key() {
|
||||
|
||||
is_generated_key() {
|
||||
case "$1" in
|
||||
MOSAIC_AGENT_NAME|MOSAIC_GIT_IDENTITY|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;;
|
||||
MOSAIC_AGENT_NAME|MOSAIC_AGENT_CLASS|MOSAIC_AGENT_RUNTIME|MOSAIC_AGENT_MODEL|MOSAIC_AGENT_REASONING|MOSAIC_AGENT_TOOL_POLICY|MOSAIC_AGENT_WORKDIR|MOSAIC_TMUX_SOCKET) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
@@ -114,7 +134,6 @@ validate_generated_value() {
|
||||
local value="$2"
|
||||
case "$key" in
|
||||
MOSAIC_AGENT_NAME) safe_agent_name "$value" || fail_env unsafe-agent-name "$key" "$value" ;;
|
||||
MOSAIC_GIT_IDENTITY) safe_agent_name "$value" || fail_env unsafe-git-identity "$key" "$value" ;;
|
||||
MOSAIC_AGENT_CLASS) safe_policy_name "$value" || fail_env unsafe-class "$key" "$value" ;;
|
||||
MOSAIC_AGENT_RUNTIME)
|
||||
case "$value" in claude|codex|opencode|pi) ;; *) fail_env unsupported-runtime "$key" "$value" ;; esac
|
||||
@@ -176,7 +195,7 @@ load_environment_file() {
|
||||
|
||||
load_environment_file "$GENERATED_ENV" generated
|
||||
for required_key in \
|
||||
MOSAIC_AGENT_NAME MOSAIC_GIT_IDENTITY MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \
|
||||
MOSAIC_AGENT_NAME MOSAIC_AGENT_CLASS MOSAIC_AGENT_RUNTIME MOSAIC_AGENT_MODEL \
|
||||
MOSAIC_AGENT_REASONING MOSAIC_AGENT_TOOL_POLICY MOSAIC_AGENT_WORKDIR MOSAIC_TMUX_SOCKET; do
|
||||
[ -n "${GENERATED_VALUES[$required_key]+set}" ] || fail_env missing-key "$required_key" ''
|
||||
done
|
||||
@@ -184,15 +203,12 @@ load_environment_file "$LOCAL_ENV" local
|
||||
|
||||
[ "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}" = "$AGENT_NAME" ] || \
|
||||
fail_env agent-name-mismatch MOSAIC_AGENT_NAME "${GENERATED_VALUES[MOSAIC_AGENT_NAME]}"
|
||||
[ "${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}" = "$AGENT_NAME" ] || \
|
||||
fail_env git-identity-mismatch MOSAIC_GIT_IDENTITY "${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}"
|
||||
|
||||
MOSAIC_TMUX_SOCKET=${GENERATED_VALUES[MOSAIC_TMUX_SOCKET]}
|
||||
MOSAIC_AGENT_RUNTIME=${GENERATED_VALUES[MOSAIC_AGENT_RUNTIME]}
|
||||
MOSAIC_AGENT_MODEL=${GENERATED_VALUES[MOSAIC_AGENT_MODEL]}
|
||||
MOSAIC_AGENT_REASONING=${GENERATED_VALUES[MOSAIC_AGENT_REASONING]}
|
||||
MOSAIC_AGENT_WORKDIR=${GENERATED_VALUES[MOSAIC_AGENT_WORKDIR]}
|
||||
MOSAIC_GIT_IDENTITY=${GENERATED_VALUES[MOSAIC_GIT_IDENTITY]}
|
||||
MOSAIC_AGENT_CLASS=${GENERATED_VALUES[MOSAIC_AGENT_CLASS]}
|
||||
MOSAIC_AGENT_TOOL_POLICY=${GENERATED_VALUES[MOSAIC_AGENT_TOOL_POLICY]}
|
||||
MOSAIC_RUNTIME_BIN=${LOCAL_VALUES[MOSAIC_RUNTIME_BIN]:-}
|
||||
@@ -290,24 +306,42 @@ _build_runtime_bin_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
|
||||
|
||||
# A seat scaffolded by `mosaic fleet agent new` owns its harness home, settings
|
||||
# overlay and auth bundle; launching it through `mosaic fleet launch` is what makes
|
||||
# ~/.mosaic real for a roster-started pane instead of a directory nothing reads.
|
||||
# Detection uses $PANE_HOME/.mosaic because the pane environment is cleared below,
|
||||
# so `mosaic fleet launch` resolves the same root from HOME and the two agree.
|
||||
FLEET_SEAT_DIR="$PANE_HOME/.mosaic/fleet/agents/$AGENT_NAME"
|
||||
FLEET_SEAT=0
|
||||
[ -f "$FLEET_SEAT_DIR/profile.json" ] && FLEET_SEAT=1
|
||||
# #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() {
|
||||
PATH="$PANE_PATH" command -v -- "$1" 2>/dev/null
|
||||
}
|
||||
|
||||
# Exit 69 (EX_UNAVAILABLE): the seat cannot be provided. Distinguished from the
|
||||
# 64 (EX_USAGE) rejections above, which mean the projection itself was bad —
|
||||
# here the data is fine and the host is not ready. Callers tell the individual
|
||||
# cases apart by `code=`, the same way fail_env's many codes share exit 64.
|
||||
fail_launch() {
|
||||
local code="$1"
|
||||
shift
|
||||
echo "ERROR: agent launch aborted: code=${code} agent=${AGENT_NAME} $*" >&2
|
||||
exit 69
|
||||
}
|
||||
|
||||
for required_binary in mosaic "$MOSAIC_AGENT_RUNTIME"; do
|
||||
_resolve_in_pane_path "$required_binary" >/dev/null ||
|
||||
fail_launch missing-binary "'${required_binary}' is not on the pane PATH (${PANE_PATH})"
|
||||
done
|
||||
|
||||
_ensure_claude_workdir_trusted() {
|
||||
local workdir="$1"
|
||||
local claude_json="$2"
|
||||
local resolved
|
||||
resolved=$(cd "$workdir" 2>/dev/null && pwd -P) || resolved="$workdir"
|
||||
if [ -z "$claude_json" ]; then
|
||||
claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}"
|
||||
claude_json="${claude_json:-$HOME/.claude.json}"
|
||||
fi
|
||||
local claude_json="${MOSAIC_CLAUDE_JSON:-${CLAUDE_CONFIG_DIR:+$CLAUDE_CONFIG_DIR/.claude.json}}"
|
||||
claude_json="${claude_json:-$HOME/.claude.json}"
|
||||
command -v python3 >/dev/null 2>&1 || return 1
|
||||
MOSAIC_CJ="$claude_json" MOSAIC_TRUST_DIR="$resolved" python3 - <<'PY'
|
||||
import json, os, sys, tempfile
|
||||
@@ -341,23 +375,11 @@ PY
|
||||
}
|
||||
|
||||
if [ "$MOSAIC_AGENT_RUNTIME" = claude ]; then
|
||||
# Trust belongs to the home the seat will actually run in. Writing it to the
|
||||
# operator's ~/.claude.json would leave the seat prompting on its first turn.
|
||||
SEAT_CLAUDE_JSON=""
|
||||
if [ "$FLEET_SEAT" = 1 ] && [ -d "$FLEET_SEAT_DIR/.claude" ]; then
|
||||
SEAT_CLAUDE_JSON="$FLEET_SEAT_DIR/.claude/.claude.json"
|
||||
fi
|
||||
_ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" "$SEAT_CLAUDE_JSON" || \
|
||||
_ensure_claude_workdir_trusted "$MOSAIC_AGENT_WORKDIR" || \
|
||||
echo "WARNING: could not pre-trust workdir for claude agent $AGENT_NAME" >&2
|
||||
fi
|
||||
|
||||
if [ "$FLEET_SEAT" = 1 ]; then
|
||||
# --dangerous keeps the seat on the same permissions footing `mosaic yolo` gave it;
|
||||
# the composition, not the roster, decides harness home, bundle and settings.
|
||||
LAUNCH_COMMAND=(mosaic fleet launch "$AGENT_NAME" --dangerous)
|
||||
else
|
||||
LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME")
|
||||
fi
|
||||
LAUNCH_COMMAND=(mosaic yolo "$MOSAIC_AGENT_RUNTIME")
|
||||
if [ -n "$MOSAIC_AGENT_MODEL" ]; then LAUNCH_COMMAND+=(--model "$MOSAIC_AGENT_MODEL"); fi
|
||||
if [ -n "$MOSAIC_AGENT_REASONING" ]; then LAUNCH_COMMAND+=(--thinking "$MOSAIC_AGENT_REASONING"); fi
|
||||
|
||||
@@ -371,7 +393,6 @@ LAUNCH_ENV=(
|
||||
"PATH=$PANE_PATH"
|
||||
"MOSAIC_HOME=$MOSAIC_HOME"
|
||||
"MOSAIC_AGENT_NAME=$AGENT_NAME"
|
||||
"MOSAIC_GIT_IDENTITY=$MOSAIC_GIT_IDENTITY"
|
||||
"MOSAIC_AGENT_CLASS=$MOSAIC_AGENT_CLASS"
|
||||
"MOSAIC_AGENT_RUNTIME=$MOSAIC_AGENT_RUNTIME"
|
||||
"MOSAIC_AGENT_MODEL=$MOSAIC_AGENT_MODEL"
|
||||
@@ -413,6 +434,19 @@ if [ -n "$PANE_PID" ]; then
|
||||
_start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \
|
||||
"$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \
|
||||
echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2
|
||||
elif _tmux has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
|
||||
# #1241. Session present, no pane PID after a second of retries. Whatever this
|
||||
# is, it is not a seat an operator can use, so it is not a success either.
|
||||
fail_launch pane-pid-unresolved \
|
||||
"tmux reports the session but no pane PID after 5 attempts"
|
||||
else
|
||||
echo "WARNING: could not resolve pane PID for $AGENT_NAME — heartbeat sidecar not started" >&2
|
||||
# #1241. This branch used to print a WARNING about the heartbeat sidecar and
|
||||
# exit 0. It is not a heartbeat problem: tmux destroys a session when its pane
|
||||
# command exits, so an absent session one second after new-session means the
|
||||
# runtime died on startup. Reporting it as success is what let `fleet start`
|
||||
# return 0 over three dead panes — the launcher knew, and said the wrong thing
|
||||
# at the wrong severity to the wrong layer.
|
||||
fail_launch pane-did-not-survive \
|
||||
"the pane exited immediately and tmux destroyed the session;" \
|
||||
"run 'mosaic yolo ${MOSAIC_AGENT_RUNTIME}' in ${MOSAIC_AGENT_WORKDIR} to see why"
|
||||
fi
|
||||
|
||||
@@ -14,82 +14,6 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
pane_command_clears_environment() {
|
||||
local calls_file="$1"
|
||||
local -a argv=()
|
||||
local index
|
||||
mapfile -d '' -t argv < "$calls_file"
|
||||
for ((index = 0; index + 1 < ${#argv[@]}; index++)); do
|
||||
if [ "${argv[$index]}" = /usr/bin/env ] && [ "${argv[$((index + 1))]}" = -i ]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
print_pane_argv() {
|
||||
local calls_file="$1"
|
||||
local -a argv=()
|
||||
local bytes index
|
||||
mapfile -d '' -t argv < "$calls_file"
|
||||
bytes=$(wc -c < "$calls_file")
|
||||
printf 'observed pane argv: records=%s bytes=%s\n' "${#argv[@]}" "$bytes" >&2
|
||||
for ((index = 0; index < ${#argv[@]}; index++)); do
|
||||
printf ' [%03d] %q\n' "$index" "${argv[$index]}" >&2
|
||||
done
|
||||
}
|
||||
|
||||
check_pane_environment_boundary() {
|
||||
local calls_file="$1"
|
||||
if pane_command_clears_environment "$calls_file"; then
|
||||
return 0
|
||||
fi
|
||||
print_pane_argv "$calls_file"
|
||||
return 1
|
||||
}
|
||||
|
||||
contains_literal() {
|
||||
grep -F -- "$2" <<< "$1" >/dev/null
|
||||
}
|
||||
|
||||
contains_line() {
|
||||
grep -xF -- "$2" <<< "$1" >/dev/null
|
||||
}
|
||||
|
||||
# Portability regression: inspect the authoritative NUL-delimited argv instead
|
||||
# of piping a newline reconstruction through `grep -q` under pipefail. The old
|
||||
# pipeline could report failure after a successful match when an upstream
|
||||
# producer received SIGPIPE. A large trailing argument keeps that failure class
|
||||
# covered without making stream size part of the semantic contract.
|
||||
PORTABILITY_CALLS="$ROOT/portability-calls"
|
||||
printf -v PORTABILITY_PADDING '%*s' 32768 ''
|
||||
PORTABILITY_PADDING=${PORTABILITY_PADDING// /x}
|
||||
printf '%s\0' /usr/bin/env -i "$PORTABILITY_PADDING" > "$PORTABILITY_CALLS"
|
||||
pane_command_clears_environment "$PORTABILITY_CALLS" || \
|
||||
fail "valid large pane argv was rejected by the environment-boundary assertion"
|
||||
|
||||
assert_pane_boundary_rejected() {
|
||||
local case_name="$1"
|
||||
local expected_records="$2"
|
||||
local diagnostic
|
||||
if diagnostic=$(check_pane_environment_boundary "$PORTABILITY_CALLS" 2>&1); then
|
||||
fail "pane boundary accepted invalid $case_name fixture"
|
||||
fi
|
||||
contains_literal "$diagnostic" "records=$expected_records bytes=" || \
|
||||
fail "pane argv diagnostic omitted counts for $case_name fixture"
|
||||
contains_literal "$diagnostic" '[000]' || \
|
||||
fail "pane argv diagnostic omitted indexed arguments for $case_name fixture"
|
||||
}
|
||||
|
||||
printf '%s\0' tmux -i > "$PORTABILITY_CALLS"
|
||||
assert_pane_boundary_rejected missing-env 2
|
||||
printf '%s\0' /usr/bin/env HOME=/untrusted > "$PORTABILITY_CALLS"
|
||||
assert_pane_boundary_rejected missing-i 2
|
||||
printf '%s\0' /usr/bin/env HOME=/untrusted -i > "$PORTABILITY_CALLS"
|
||||
assert_pane_boundary_rejected non-adjacent-i 3
|
||||
printf '%s\0' -i /usr/bin/env > "$PORTABILITY_CALLS"
|
||||
assert_pane_boundary_rejected reversed-boundary 2
|
||||
|
||||
cat > "$FAKE_BIN/tmux" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
@@ -99,8 +23,26 @@ index=0
|
||||
if [ "${args[0]:-}" = -L ]; then index=2; fi
|
||||
case "${args[$index]:-}" in
|
||||
has-session)
|
||||
# The holder always answers. MOSAIC_TEST_HELD_SESSIONS lets a case add
|
||||
# other targets that should answer too — without it there is no way to
|
||||
# model "tmux still reports the session" for a non-holder agent, and the
|
||||
# launcher's pane-pid-unresolved branch is unreachable from this harness.
|
||||
#
|
||||
# A listed target answers only AFTER new-session, because the launcher asks
|
||||
# this question twice about the same name: once before launching, where a
|
||||
# yes means "already running, nothing to do, exit 0", and once after, where
|
||||
# a yes means "the session survived". A shim that answered yes to both
|
||||
# would short-circuit at the first and never reach the branch under test —
|
||||
# it would look like coverage and measure the idempotency path instead.
|
||||
for argument in "${args[@]}"; do
|
||||
[ "$argument" = '=_holder:0.0' ] && exit 0
|
||||
case " ${MOSAIC_TEST_HELD_SESSIONS:-} " in
|
||||
*" $argument "*)
|
||||
if tr '\0' '\n' < "${MOSAIC_TEST_TMUX_CALLS:?}" | grep -qxF new-session; then
|
||||
exit 0
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
exit 1
|
||||
;;
|
||||
@@ -138,18 +80,29 @@ env -0 > "${MOSAIC_HOME:?}/fleet/pane-environment"
|
||||
SHIM
|
||||
chmod +x "$FAKE_BIN/mosaic"
|
||||
|
||||
# Freeze numeric epoch reads only when a test arm supplies an observation bound.
|
||||
# Formatting reads still use the real BusyBox/POSIX date implementation.
|
||||
cat > "$FAKE_BIN/date" <<'SHIM'
|
||||
# The runtime the rosters below name. The launcher resolves it against PANE_PATH
|
||||
# before spawning (#1241), so it has to exist somewhere the pane would find it —
|
||||
# not merely on the launcher's own PATH.
|
||||
printf '#!/usr/bin/env bash\nexit 0\n' > "$FAKE_BIN/pi"
|
||||
chmod +x "$FAKE_BIN/pi"
|
||||
|
||||
# PANE_PATH is derived partly from `npm config get prefix`. Left to the real npm
|
||||
# it would splice whatever the host has installed into the path under test, and
|
||||
# the missing-binary cases below would pass or fail by accident of the machine.
|
||||
cat > "$FAKE_BIN/npm" <<'SHIM'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [ -n "${MOSAIC_TEST_FIXED_EPOCH:-}" ] && [ "${1:-}" = '+%s' ]; then
|
||||
printf '%s\n' "$MOSAIC_TEST_FIXED_EPOCH"
|
||||
exit 0
|
||||
fi
|
||||
exec /bin/date "$@"
|
||||
printf '%s\n' "${MOSAIC_TEST_NPM_PREFIX:-/nonexistent}"
|
||||
SHIM
|
||||
chmod +x "$FAKE_BIN/date"
|
||||
chmod +x "$FAKE_BIN/npm"
|
||||
|
||||
# PANE_PATH always ends in the system path. A host that installs these there can
|
||||
# not measure the missing-binary cases at all, and a green run would mean
|
||||
# nothing — so say so instead of passing.
|
||||
for host_binary in mosaic pi; do
|
||||
if PATH=/usr/local/bin:/usr/bin:/bin command -v "$host_binary" >/dev/null 2>&1; then
|
||||
fail "host provides '$host_binary' in the system path; missing-binary cases are not measurable here"
|
||||
fi
|
||||
done
|
||||
|
||||
write_generated() {
|
||||
local home="$1"
|
||||
@@ -160,7 +113,6 @@ write_generated() {
|
||||
chmod 600 "$home/fleet/run/holder-owner"
|
||||
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
||||
MOSAIC_AGENT_NAME=$agent
|
||||
MOSAIC_GIT_IDENTITY=$agent
|
||||
MOSAIC_AGENT_CLASS=code
|
||||
MOSAIC_AGENT_RUNTIME=pi
|
||||
MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol
|
||||
@@ -171,6 +123,19 @@ MOSAIC_TMUX_SOCKET=mosaic-test
|
||||
EOF
|
||||
chmod 600 "$home/fleet/agents/$agent.env.generated"
|
||||
mkdir -p "$home/work"
|
||||
install_pane_binaries "$home"
|
||||
}
|
||||
|
||||
# `$PANE_HOME/.npm-global/bin` is one of the prefixes the launcher folds into
|
||||
# PANE_PATH, so this is the pane's own view of "installed", distinct from the
|
||||
# launcher's PATH. Tests that need a binary *absent* remove it from here.
|
||||
install_pane_binaries() {
|
||||
local pane_home="$1"
|
||||
mkdir -p "$pane_home/.npm-global/bin"
|
||||
local binary
|
||||
for binary in mosaic pi; do
|
||||
ln -sf "$FAKE_BIN/$binary" "$pane_home/.npm-global/bin/$binary"
|
||||
done
|
||||
}
|
||||
|
||||
run_start() {
|
||||
@@ -178,7 +143,7 @@ run_start() {
|
||||
local agent="$2"
|
||||
HOME="$home" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
||||
MOSAIC_TEST_PANE_PID="${MOSAIC_TEST_PANE_PID:-}" \
|
||||
MOSAIC_TEST_FIXED_EPOCH="${MOSAIC_TEST_FIXED_EPOCH:-}" \
|
||||
MOSAIC_TEST_HELD_SESSIONS="${MOSAIC_TEST_HELD_SESSIONS:-}" \
|
||||
MOSAIC_TEST_HOME="$home" \
|
||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||
MOSAIC_HOME="$home" "$START" "$agent"
|
||||
@@ -189,57 +154,72 @@ run_start() {
|
||||
HOME_VALID="$ROOT/valid"
|
||||
AGENT_VALID="coder0"
|
||||
write_generated "$HOME_VALID" "$AGENT_VALID"
|
||||
run_start "$HOME_VALID" "$AGENT_VALID"
|
||||
# A live pane PID is part of what "valid launch" means. Until #1241 this case
|
||||
# ran with none, so the suite's one success path was itself a dead pane the
|
||||
# launcher reported as fine.
|
||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_VALID" "$AGENT_VALID"
|
||||
valid_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
contains_literal "$valid_args" new-session || fail "valid generated projection did not reach tmux"
|
||||
contains_literal "$valid_args" mosaic || fail "fixed mosaic launcher command missing"
|
||||
contains_literal "$valid_args" yolo || fail "fixed yolo launcher command missing"
|
||||
contains_literal "$valid_args" pi || fail "roster runtime missing"
|
||||
if contains_literal "$valid_args" 'bash -c'; then
|
||||
echo "$valid_args" | grep -qF new-session || fail "valid generated projection did not reach tmux"
|
||||
echo "$valid_args" | grep -qF 'mosaic' || fail "fixed mosaic launcher command missing"
|
||||
echo "$valid_args" | grep -qF 'yolo' || fail "fixed yolo launcher command missing"
|
||||
echo "$valid_args" | grep -qF 'pi' || fail "roster runtime missing"
|
||||
if echo "$valid_args" | grep -qF 'bash -c'; then
|
||||
fail "launcher constructed a shell command payload"
|
||||
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
|
||||
# runtime command remains an argv vector, but no holder/session environment
|
||||
# control variable can pass through the pane command.
|
||||
check_pane_environment_boundary "$TMUX_CALLS" || \
|
||||
fail "pane command did not use an adjacent /usr/bin/env -i boundary"
|
||||
|
||||
# Git identity is generated authority, not an optional or independently mutable
|
||||
# local value. Each invalid form must fail before fake tmux receives a call.
|
||||
assert_git_identity_rejected() {
|
||||
local case_name="$1"
|
||||
local expected_code="$2"
|
||||
local home="$ROOT/git-identity-$case_name"
|
||||
local agent="coder-git-identity-$case_name"
|
||||
local generated="$home/fleet/agents/$agent.env.generated"
|
||||
write_generated "$home" "$agent"
|
||||
|
||||
case "$case_name" in
|
||||
missing) grep -v '^MOSAIC_GIT_IDENTITY=' "$generated" > "$generated.next" && mv "$generated.next" "$generated" ;;
|
||||
unsafe) sed -i 's|^MOSAIC_GIT_IDENTITY=.*$|MOSAIC_GIT_IDENTITY=bad/identity|' "$generated" ;;
|
||||
mismatch) sed -i 's|^MOSAIC_GIT_IDENTITY=.*$|MOSAIC_GIT_IDENTITY=other-agent|' "$generated" ;;
|
||||
local-shadow)
|
||||
printf 'MOSAIC_GIT_IDENTITY=%s\n' "$agent" > "$home/fleet/agents/$agent.env.local"
|
||||
chmod 600 "$home/fleet/agents/$agent.env.local"
|
||||
;;
|
||||
*) fail "unknown Git identity rejection case: $case_name" ;;
|
||||
esac
|
||||
chmod 600 "$generated"
|
||||
|
||||
: > "$TMUX_CALLS"
|
||||
if output=$(run_start "$home" "$agent" 2>&1); then
|
||||
fail "Git identity case $case_name was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before Git identity $case_name rejection"
|
||||
contains_literal "$output" "code=$expected_code" || \
|
||||
fail "Git identity $case_name diagnostic omitted code $expected_code"
|
||||
}
|
||||
|
||||
assert_git_identity_rejected missing missing-key
|
||||
assert_git_identity_rejected unsafe unsafe-git-identity
|
||||
assert_git_identity_rejected mismatch git-identity-mismatch
|
||||
assert_git_identity_rejected local-shadow generated-key-shadow
|
||||
echo "$valid_args" | grep -qxF '/usr/bin/env' || fail "pane does not use absolute env"
|
||||
echo "$valid_args" | grep -qxF -- '-i' || fail "pane environment is not cleared"
|
||||
|
||||
# The generated-file parent is a security boundary too: even a private regular
|
||||
# file is untrusted if its parent can be replaced or written by another user.
|
||||
@@ -252,7 +232,7 @@ if output=$(run_start "$HOME_UNSAFE_PARENT" coder-parent 2>&1); then
|
||||
fail "generated file under a world-writable parent was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before unsafe parent rejection"
|
||||
contains_literal "$output" 'code=unsafe-permissions' || fail "unsafe parent diagnostic missing"
|
||||
echo "$output" | grep -qF 'code=unsafe-permissions' || fail "unsafe parent diagnostic missing"
|
||||
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_SYMLINK_PARENT="$ROOT/symlink-parent"
|
||||
@@ -263,7 +243,7 @@ if output=$(run_start "$HOME_SYMLINK_PARENT" coder-symlink-parent 2>&1); then
|
||||
fail "generated file under a symlinked parent was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before symlinked parent rejection"
|
||||
contains_literal "$output" 'code=unsafe-directory' || fail "symlinked parent diagnostic missing"
|
||||
echo "$output" | grep -qF 'code=unsafe-directory' || fail "symlinked parent diagnostic missing"
|
||||
|
||||
# Every managed ancestor is a boundary: MOSAIC_HOME, fleet, and agents. A
|
||||
# symlink or group/world-writable ancestor must fail before environment parsing,
|
||||
@@ -301,8 +281,8 @@ assert_managed_ancestor_rejected() {
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before $hazard $ancestor rejection"
|
||||
[ ! -e "$home/work" ] || fail "workdir was created before $hazard $ancestor rejection"
|
||||
contains_literal "$output" 'code=unsafe-' || fail "managed ancestor diagnostic missing"
|
||||
if contains_literal "$output" 'key=MOSAIC_AGENT_COMMAND'; then
|
||||
echo "$output" | grep -qF "code=unsafe-" || fail "managed ancestor diagnostic missing"
|
||||
if echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND'; then
|
||||
fail "environment parsing ran before $hazard $ancestor rejection"
|
||||
fi
|
||||
}
|
||||
@@ -323,9 +303,9 @@ if output=$(run_start "$HOME_SHADOW" coder1 2>&1); then
|
||||
fail "generated-key shadow was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before generated-key shadow rejection"
|
||||
contains_literal "$output" 'key=MOSAIC_AGENT_RUNTIME' || fail "shadow diagnostic omitted key"
|
||||
contains_literal "$output" 'sha256=' || fail "shadow diagnostic omitted hash"
|
||||
if contains_literal "$output" codex; then
|
||||
echo "$output" | grep -qF 'key=MOSAIC_AGENT_RUNTIME' || fail "shadow diagnostic omitted key"
|
||||
echo "$output" | grep -qF 'sha256=' || fail "shadow diagnostic omitted hash"
|
||||
if echo "$output" | grep -qF 'codex'; then
|
||||
fail "shadow diagnostic leaked value"
|
||||
fi
|
||||
|
||||
@@ -341,9 +321,9 @@ if output=$(run_start "$HOME_COMMAND" coder2 2>&1); then
|
||||
fail "arbitrary command override was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before command rejection"
|
||||
contains_literal "$output" 'key=MOSAIC_AGENT_COMMAND' || fail "command diagnostic omitted key"
|
||||
contains_literal "$output" 'sha256=' || fail "command diagnostic omitted hash"
|
||||
if contains_literal "$output" "$COMMAND_VALUE"; then
|
||||
echo "$output" | grep -qF 'key=MOSAIC_AGENT_COMMAND' || fail "command diagnostic omitted key"
|
||||
echo "$output" | grep -qF 'sha256=' || fail "command diagnostic omitted hash"
|
||||
if echo "$output" | grep -qF "$COMMAND_VALUE"; then
|
||||
fail "command diagnostic leaked command value"
|
||||
fi
|
||||
|
||||
@@ -357,7 +337,7 @@ if output=$(run_start "$HOME_PERMS" coder3 2>&1); then
|
||||
fail "world-readable local input was accepted"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before permissions rejection"
|
||||
contains_literal "$output" 'code=unsafe-permissions' || fail "permission diagnostic missing"
|
||||
echo "$output" | grep -qF 'code=unsafe-permissions' || fail "permission diagnostic missing"
|
||||
|
||||
# A unit/holder-like clean bootstrap must yield a pane with trusted HOME and
|
||||
# computed PATH only. The pane command itself must not carry loader, shell
|
||||
@@ -372,6 +352,13 @@ PANE_BASH_ENV="$ROOT/pane-boundary.bash-env"
|
||||
printf 'MOSAIC_RUNTIME_BIN=%s\n' "$FAKE_BIN" > \
|
||||
"$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local"
|
||||
chmod 600 "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.local"
|
||||
# This case does not go through run_start, so its pane binaries come from
|
||||
# MOSAIC_RUNTIME_BIN=$FAKE_BIN in the env.local written above — not from the
|
||||
# symlinks install_pane_binaries planted under the generated home, which this
|
||||
# launcher never consults because HOME here is the trusted parent. That is a
|
||||
# legitimate resolution path, but it means dropping MOSAIC_RUNTIME_BIN from
|
||||
# this case on the belief that the symlinks cover it would break the #1241
|
||||
# binary check rather than exercise it.
|
||||
LD_PRELOAD='/not/loaded/by-clean-bootstrap.so' \
|
||||
BASH_ENV="$PANE_BASH_ENV" \
|
||||
MOSAIC_UNTRUSTED_SENTINEL='must-not-reach-pane' \
|
||||
@@ -385,37 +372,28 @@ PATH="$PANE_STALE_PATH" \
|
||||
"MOSAIC_TEST_HOME=$PANE_TRUSTED_HOME" \
|
||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||
MOSAIC_TEST_EXECUTE_PANE=1 \
|
||||
"MOSAIC_TEST_PANE_PID=$$" \
|
||||
"$START" coder-pane-boundary
|
||||
pane_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
contains_line "$pane_args" "HOME=$PANE_TRUSTED_HOME" || \
|
||||
echo "$pane_args" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
|
||||
fail "pane did not restore trusted HOME"
|
||||
contains_literal "$pane_args" "HOME=$PANE_STALE_HOME" && \
|
||||
echo "$pane_args" | grep -qF "HOME=$PANE_STALE_HOME" && \
|
||||
fail "pane inherited stale HOME"
|
||||
contains_literal "$pane_args" "$PANE_STALE_PATH" && fail "pane inherited stale PATH"
|
||||
echo "$pane_args" | grep -qF "$PANE_STALE_PATH" && fail "pane inherited stale PATH"
|
||||
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
|
||||
contains_literal "$pane_args" "$blocked" && fail "pane inherited $blocked"
|
||||
echo "$pane_args" | grep -qF "$blocked" && fail "pane inherited $blocked"
|
||||
done
|
||||
|
||||
check_pane_environment_boundary "$TMUX_CALLS" || \
|
||||
fail "pane command did not use an adjacent /usr/bin/env -i boundary"
|
||||
after_pane_env=$(printf '%s\n' "$pane_args" | grep -n -m1 -F '/usr/bin/env' | cut -d: -f1)
|
||||
[ -n "$after_pane_env" ] || fail "pane command did not use absolute env"
|
||||
printf '%s\n' "$pane_args" | tail -n +"$after_pane_env" | grep -qxF -- '-i' || \
|
||||
fail "pane command did not clear its environment"
|
||||
pane_environment=$(tr '\0' '\n' < "$HOME_PANE_BOUNDARY/fleet/pane-environment")
|
||||
# Exercise the repository launcher at $START, not the independently installed
|
||||
# host copy. Set-compare every declared generated projection entry with the
|
||||
# launched process environment so a newly declared identity cannot be omitted
|
||||
# by a hand-maintained per-variable assertion.
|
||||
declared_generated_environment=$(sort "$HOME_PANE_BOUNDARY/fleet/agents/coder-pane-boundary.env.generated")
|
||||
missing_or_changed_generated_environment=$(comm -23 \
|
||||
<(printf '%s\n' "$declared_generated_environment") \
|
||||
<(printf '%s\n' "$pane_environment" | sort))
|
||||
if [ -n "$missing_or_changed_generated_environment" ]; then
|
||||
missing_or_changed_keys=$(printf '%s\n' "$missing_or_changed_generated_environment" | cut -d= -f1 | paste -sd, -)
|
||||
fail "runtime pane omitted or changed generated environment keys: $missing_or_changed_keys"
|
||||
fi
|
||||
contains_line "$pane_environment" "HOME=$PANE_TRUSTED_HOME" || \
|
||||
echo "$pane_environment" | grep -qxF "HOME=$PANE_TRUSTED_HOME" || \
|
||||
fail "runtime pane did not receive trusted HOME"
|
||||
contains_literal "$pane_environment" "$PANE_STALE_PATH" && fail "runtime pane received stale PATH"
|
||||
echo "$pane_environment" | grep -qF "$PANE_STALE_PATH" && fail "runtime pane received stale PATH"
|
||||
for blocked in LD_PRELOAD= BASH_ENV= MOSAIC_UNTRUSTED_SENTINEL=; do
|
||||
contains_literal "$pane_environment" "$blocked" && fail "runtime pane received $blocked"
|
||||
echo "$pane_environment" | grep -qF "$blocked" && fail "runtime pane received $blocked"
|
||||
done
|
||||
|
||||
write_interaction_generated() {
|
||||
@@ -427,7 +405,6 @@ write_interaction_generated() {
|
||||
chmod 600 "$home/fleet/run/holder-owner"
|
||||
cat > "$home/fleet/agents/$agent.env.generated" <<EOF
|
||||
MOSAIC_AGENT_NAME=$agent
|
||||
MOSAIC_GIT_IDENTITY=$agent
|
||||
MOSAIC_AGENT_CLASS=operator-interaction
|
||||
MOSAIC_AGENT_RUNTIME=pi
|
||||
MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol
|
||||
@@ -490,12 +467,8 @@ write_generated "$HOME_NATIVE_STALE" "coder-native-stale"
|
||||
write_heartbeat_local "$HOME_NATIVE_STALE" "coder-native-stale"
|
||||
STALE_HB="$HOME_NATIVE_STALE/run/coder-native-stale.hb"
|
||||
printf 'ts=native\npid=1\nstatus=busy\nmodel=stale-model\n' > "$STALE_HB"
|
||||
touch -t 200001010000.00 "$STALE_HB.native"
|
||||
# Hold the sidecar's observation epoch constant: assertion runtime must not age
|
||||
# a fresh-marker mutant into the stale state that this fixture must distinguish.
|
||||
STALE_OBSERVATION_EPOCH=$(date +%s)
|
||||
MOSAIC_TEST_FIXED_EPOCH="$STALE_OBSERVATION_EPOCH" \
|
||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale
|
||||
touch -d '10 seconds ago' "$STALE_HB.native"
|
||||
MOSAIC_TEST_PANE_PID=$$ run_start "$HOME_NATIVE_STALE" coder-native-stale
|
||||
wait_for_sidecar_status "$STALE_HB"
|
||||
|
||||
HOME_NATIVE_ABSENT="$ROOT/native-absent"
|
||||
@@ -516,24 +489,93 @@ if output=$(run_interaction "$HOME_INTERACTION_MALFORMED" interaction-malformed
|
||||
fail "interaction wrapper accepted malformed generated data"
|
||||
fi
|
||||
[ ! -s "$TMUX_CALLS" ] || fail "tmux ran before interaction strict-parser rejection"
|
||||
contains_literal "$output" 'code=unknown-key' || fail "interaction did not use shared strict parser first"
|
||||
echo "$output" | grep -qF 'code=unknown-key' || fail "interaction did not use shared strict parser first"
|
||||
|
||||
# A syntactically valid but policy-incompatible projection reaches the pinned
|
||||
# interaction policy check only after strict parsing and never starts tmux.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_INTERACTION_POLICY="$ROOT/interaction-policy"
|
||||
write_interaction_generated "$HOME_INTERACTION_POLICY" "interaction-policy"
|
||||
sed -i 's|^MOSAIC_AGENT_RUNTIME=pi$|MOSAIC_AGENT_RUNTIME=codex|' \
|
||||
perl -0pi -e 's/MOSAIC_AGENT_RUNTIME=pi/MOSAIC_AGENT_RUNTIME=codex/' \
|
||||
"$HOME_INTERACTION_POLICY/fleet/agents/interaction-policy.env.generated"
|
||||
if output=$(run_interaction "$HOME_INTERACTION_POLICY" interaction-policy 2>&1); then
|
||||
fail "interaction wrapper accepted a policy-incompatible projection"
|
||||
fi
|
||||
interaction_policy_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
contains_literal "$interaction_policy_args" new-session && \
|
||||
echo "$interaction_policy_args" | grep -qF 'new-session' && \
|
||||
fail "interaction pinned-policy rejection created a tmux session"
|
||||
contains_literal "$output" 'operator interaction service requires runtime pi' || \
|
||||
echo "$output" | grep -qF 'operator interaction service requires runtime pi' || \
|
||||
fail "interaction pinned-policy check did not follow strict parsing"
|
||||
|
||||
# #1241. The pane runs `mosaic yolo <runtime>` against PANE_PATH. A binary
|
||||
# missing from that path is a launch failure, and it has to be named before the
|
||||
# session is created — after it, the diagnostic dies with the pane.
|
||||
assert_missing_pane_binary_rejected() {
|
||||
local binary="$1"
|
||||
local home="$ROOT/missing-$binary"
|
||||
local agent="coder-missing-$binary"
|
||||
write_generated "$home" "$agent"
|
||||
rm -f "$home/.npm-global/bin/$binary"
|
||||
|
||||
: > "$TMUX_CALLS"
|
||||
local output
|
||||
if output=$(MOSAIC_TEST_PANE_PID=$$ run_start "$home" "$agent" 2>&1); then
|
||||
fail "launch succeeded with '$binary' absent from the pane PATH"
|
||||
fi
|
||||
echo "$output" | grep -qF 'code=missing-binary' || fail "missing '$binary' diagnostic missing"
|
||||
echo "$output" | grep -qF "'$binary'" || fail "missing-binary diagnostic did not name $binary"
|
||||
if tr '\0' '\n' < "$TMUX_CALLS" | grep -qF new-session; then
|
||||
fail "launcher created a session it knew would die ($binary absent)"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_missing_pane_binary_rejected mosaic
|
||||
assert_missing_pane_binary_rejected pi
|
||||
|
||||
# #1241. tmux destroys a session when its pane command exits, so no pane PID a
|
||||
# second after new-session means the runtime died on startup. This used to be a
|
||||
# WARNING about the heartbeat sidecar followed by exit 0 — three layers above it
|
||||
# then reported a fleet that was not running.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_DEAD_PANE="$ROOT/dead-pane"
|
||||
write_generated "$HOME_DEAD_PANE" "coder-dead-pane"
|
||||
if output=$(MOSAIC_TEST_PANE_PID='' run_start "$HOME_DEAD_PANE" coder-dead-pane 2>&1); then
|
||||
fail "launcher reported success over a pane that did not survive"
|
||||
fi
|
||||
echo "$output" | grep -qF 'code=pane-did-not-survive' || fail "dead-pane diagnostic missing"
|
||||
if echo "$output" | grep -qiF 'heartbeat'; then
|
||||
fail "dead pane is still being reported as a heartbeat-sidecar problem"
|
||||
fi
|
||||
tr '\0' '\n' < "$TMUX_CALLS" | grep -qF new-session || \
|
||||
fail "dead-pane case did not reach the launch it is measuring"
|
||||
|
||||
# #1241, the other way a pane fails. Above, tmux destroyed the session and
|
||||
# has-session said so. Here the session is still there and no PID comes back
|
||||
# after the retries — a different fault (the pane is alive but unusable, or
|
||||
# tmux is answering inconsistently) that an operator has to be told apart from
|
||||
# a runtime that died on startup.
|
||||
#
|
||||
# This case exists because the branch that handles it shipped with nothing able
|
||||
# to reach it: the shim answered has-session only for the holder, so every
|
||||
# non-holder agent landed in the session-is-gone branch no matter what. A
|
||||
# defensive branch nothing exercises is the same shape as the bug this whole
|
||||
# change is about, one layer down.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_NO_PID="$ROOT/pane-no-pid"
|
||||
write_generated "$HOME_NO_PID" "coder-no-pid"
|
||||
if output=$(MOSAIC_TEST_PANE_PID='' MOSAIC_TEST_HELD_SESSIONS='=coder-no-pid:0.0' \
|
||||
run_start "$HOME_NO_PID" coder-no-pid 2>&1); then
|
||||
fail "launcher reported success over a session with no resolvable pane PID"
|
||||
fi
|
||||
echo "$output" | grep -qF 'code=pane-pid-unresolved' || \
|
||||
fail "session-present/no-PID was not reported as pane-pid-unresolved: $output"
|
||||
if echo "$output" | grep -qF 'code=pane-did-not-survive'; then
|
||||
fail "a session tmux still reports was diagnosed as a destroyed session"
|
||||
fi
|
||||
if echo "$output" | grep -qiF 'heartbeat'; then
|
||||
fail "an unresolvable pane PID is still being reported as a heartbeat-sidecar problem"
|
||||
fi
|
||||
|
||||
# Exact stop derives the socket exclusively from the validated generated
|
||||
# projection and ignores an ambient socket supplied by the caller.
|
||||
: > "$TMUX_CALLS"
|
||||
@@ -544,30 +586,11 @@ HOME="$HOME_STOP" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
|
||||
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
|
||||
MOSAIC_HOME="$HOME_STOP" MOSAIC_TMUX_SOCKET=ambient-socket "$START" --stop coder-stop
|
||||
stop_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
contains_line "$stop_args" mosaic-test || fail "exact stop did not use the validated generated socket"
|
||||
contains_line "$stop_args" kill-session || fail "exact stop did not request session termination"
|
||||
contains_line "$stop_args" '=coder-stop' || fail "exact stop did not exact-match the generated agent name"
|
||||
if contains_literal "$stop_args" ambient-socket; then
|
||||
echo "$stop_args" | grep -qxF 'mosaic-test' || fail "exact stop did not use the validated generated socket"
|
||||
echo "$stop_args" | grep -qxF 'kill-session' || fail "exact stop did not request session termination"
|
||||
echo "$stop_args" | grep -qxF '=coder-stop' || fail "exact stop did not exact-match the generated agent name"
|
||||
if echo "$stop_args" | grep -qF 'ambient-socket'; then
|
||||
fail "exact stop trusted an ambient socket"
|
||||
fi
|
||||
|
||||
# A seat scaffolded under ~/.mosaic owns its harness home, so the pane launches
|
||||
# through the composition instead of the operator's own home. --dangerous keeps the
|
||||
# seat on the permissions footing `mosaic yolo` gave it.
|
||||
: > "$TMUX_CALLS"
|
||||
HOME_SEAT="$ROOT/seat"
|
||||
write_generated "$HOME_SEAT" "coder-seat"
|
||||
mkdir -p "$HOME_SEAT/.mosaic/fleet/agents/coder-seat"
|
||||
printf '{"schema":1,"harness":"pi","bundle":"primary"}\n' \
|
||||
> "$HOME_SEAT/.mosaic/fleet/agents/coder-seat/profile.json"
|
||||
run_start "$HOME_SEAT" "coder-seat"
|
||||
seat_args=$(tr '\0' '\n' < "$TMUX_CALLS")
|
||||
echo "$seat_args" | grep -qxF 'fleet' || fail "scaffolded seat did not launch through fleet launch"
|
||||
echo "$seat_args" | grep -qxF 'launch' || fail "scaffolded seat did not launch through fleet launch"
|
||||
echo "$seat_args" | grep -qxF 'coder-seat' || fail "fleet launch did not name the seat"
|
||||
echo "$seat_args" | grep -qxF -- '--dangerous' || fail "scaffolded seat lost dangerous permissions"
|
||||
if echo "$seat_args" | grep -qxF 'yolo'; then
|
||||
fail "scaffolded seat still launched through mosaic yolo"
|
||||
fi
|
||||
|
||||
echo 'ok - start-agent-session generated environment boundary'
|
||||
|
||||
@@ -5,10 +5,7 @@
|
||||
|
||||
detect_platform() {
|
||||
local remote_url
|
||||
# `|| true` is load-bearing under `set -e`: outside a git repo this returns 128 and
|
||||
# kills the CALLER before the -z check below can run, so the error message that is
|
||||
# already written here was unreachable. Same idiom as get_gitea_repo_args() below.
|
||||
remote_url=$(git remote get-url origin 2>/dev/null) || true
|
||||
remote_url=$(git remote get-url origin 2>/dev/null)
|
||||
|
||||
if [[ -z "$remote_url" ]]; then
|
||||
echo "error: not a git repository or no origin remote" >&2
|
||||
@@ -42,10 +39,7 @@ detect_platform() {
|
||||
|
||||
get_repo_info() {
|
||||
local remote_url
|
||||
# `|| true` is load-bearing under `set -e`: outside a git repo this returns 128 and
|
||||
# kills the CALLER before the -z check below can run, so the error message that is
|
||||
# already written here was unreachable. Same idiom as get_gitea_repo_args() below.
|
||||
remote_url=$(git remote get-url origin 2>/dev/null) || true
|
||||
remote_url=$(git remote get-url origin 2>/dev/null)
|
||||
|
||||
if [[ -z "$remote_url" ]]; then
|
||||
echo "error: not a git repository or no origin remote" >&2
|
||||
@@ -246,21 +240,6 @@ PY
|
||||
} >&2
|
||||
}
|
||||
|
||||
# Explain tea's most misleading failure. `user does not exist [uid: 0, name: ]` reads
|
||||
# as a missing account; it almost always means a REVOKED OR STALE TOKEN. `tea login`
|
||||
# keeps its OWN COPY of the token, so rotating the credential store does not update it.
|
||||
# Diagnostic only -- stderr, no control flow, no exit.
|
||||
explain_tea_user_does_not_exist() {
|
||||
cat >&2 <<'MSG'
|
||||
NOTE: `user does not exist [uid: 0, name: ]` from tea usually means a REVOKED OR STALE TOKEN,
|
||||
not a missing account. A `tea login` stores its OWN COPY of the token; rotating the
|
||||
credential store does NOT update it.
|
||||
CHECK: the login's cached copy (`tea login list` -- read the FULL table, never `| head`),
|
||||
then re-register that login against the current token.
|
||||
DO NOT probe capability with a mutating request; a POST is the action, not a check.
|
||||
MSG
|
||||
}
|
||||
|
||||
get_gitea_login_for_host() {
|
||||
local host="${1:-}"
|
||||
local login
|
||||
|
||||
@@ -91,32 +91,13 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
GITEA_LOGIN_NAME=$(get_gitea_login || true)
|
||||
if [[ -n "$GITEA_LOGIN_NAME" ]]; then
|
||||
if [[ -n "$COMMENT" ]]; then
|
||||
# `tea issue comment` is NOT a subcommand -- tea 0.11.x lists only
|
||||
# list/create/edit/reopen/close under `tea issue`. Comments are the
|
||||
# TOP-LEVEL `tea comment`, which takes the same --repo/--login flags.
|
||||
# The old call therefore always failed, was unchecked, and the script
|
||||
# closed the issue anyway, losing the record of WHY.
|
||||
#
|
||||
# Use `tea comment` rather than the API helper so the comment and the
|
||||
# close are made by the SAME principal ($GITEA_LOGIN_NAME). Routing the
|
||||
# comment through the token-authenticated helper here would attribute the
|
||||
# comment to the token holder and the close to the tea login -- two
|
||||
# principals for one operation.
|
||||
tea comment "$ISSUE_NUMBER" "$COMMENT" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME" || {
|
||||
echo "Error: failed to post comment on #$ISSUE_NUMBER -- NOT closing (fail closed)." >&2
|
||||
exit 1
|
||||
}
|
||||
tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
|
||||
fi
|
||||
tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
|
||||
else
|
||||
echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2
|
||||
if [[ -n "$COMMENT" ]]; then
|
||||
# Fail closed here too: an unchecked comment lets the issue close without its
|
||||
# audit trail, which is the same defect as the tea path above.
|
||||
gitea_issue_comment_api || {
|
||||
echo "Error: failed to post comment on #$ISSUE_NUMBER -- NOT closing (fail closed)." >&2
|
||||
exit 1
|
||||
}
|
||||
gitea_issue_comment_api
|
||||
fi
|
||||
gitea_issue_close_api
|
||||
fi
|
||||
|
||||
@@ -254,32 +254,15 @@ from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _origin_and_path(url):
|
||||
# Normalize a URL to (scheme-class, host, distinguishing-port) + comment path.
|
||||
#
|
||||
# #991: http and https collapse into ONE scheme class ("web"). A Gitea whose
|
||||
# ROOT_URL is configured http:// returns http:// object URLs even when every
|
||||
# client reaches it over https://, so a scheme-strict comparison rejects the
|
||||
# provider's own correct answer about a write that landed — a deterministic
|
||||
# false negative on every comment posted against such a deployment. The
|
||||
# scheme is also not what this check defends: the forgeries it exists to
|
||||
# catch (look-alike host, decoy path prefix, wrong owner/repo/number) all
|
||||
# vary the HOST or the PATH, both of which stay strict below. Any OTHER
|
||||
# scheme (file:, ftp:, javascript:) remains distinguishing and is rejected.
|
||||
#
|
||||
# Port: an implicit port and its own scheme's default compare equal, so
|
||||
# http://h == https://h. An EXPLICIT non-default port still distinguishes,
|
||||
# because a different port is a different service on the same host.
|
||||
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
|
||||
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
|
||||
# port and its explicit default form compare equal.
|
||||
parsed = urlparse(url or "")
|
||||
scheme = (parsed.scheme or "").lower()
|
||||
host = (parsed.hostname or "").lower()
|
||||
if scheme in ("http", "https"):
|
||||
scheme_class = "web"
|
||||
default_port = 80 if scheme == "http" else 443
|
||||
port = None if parsed.port in (None, default_port) else parsed.port
|
||||
else:
|
||||
scheme_class = scheme
|
||||
port = parsed.port
|
||||
return (scheme_class, host, port), parsed.path.rstrip("/")
|
||||
default_port = 80 if scheme == "http" else 443
|
||||
port = parsed.port if parsed.port is not None else default_port
|
||||
return (scheme, host, port), parsed.path.rstrip("/")
|
||||
|
||||
|
||||
try:
|
||||
|
||||
@@ -156,7 +156,6 @@ case "$PLATFORM" in
|
||||
exit 0
|
||||
fi
|
||||
echo "Warning: tea issue create failed, trying Gitea API fallback..." >&2
|
||||
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
|
||||
fi
|
||||
gitea_issue_create_api
|
||||
;;
|
||||
|
||||
@@ -71,7 +71,6 @@ elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Warning: tea issue view failed, trying Gitea API fallback..." >&2
|
||||
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
|
||||
fi
|
||||
gitea_issue_view_api
|
||||
else
|
||||
|
||||
@@ -1,306 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# mosaic-worktree.sh — the only supported way to create and dispose of a git
|
||||
# worktree on a fleet host.
|
||||
#
|
||||
# Why this exists as a helper and not as a rule: the rule already existed, in
|
||||
# the framework's own words ("Big work → /var/tmp"), and 255 GB accumulated in
|
||||
# $HOME across 842 directories anyway. Five placement conventions were live on
|
||||
# one fleet host simultaneously. Every one was a decision an agent had to make,
|
||||
# and a decision an agent has to make is a decision that drifts.
|
||||
#
|
||||
# So this script makes NO placement decision available. The caller supplies a
|
||||
# branch name. Every path is DERIVED:
|
||||
#
|
||||
# main worktree <- git worktree list --porcelain (never cwd, which may
|
||||
# itself already be a worktree)
|
||||
# REPO_NAME <- basename of the main worktree
|
||||
# REPO_PARENT <- dirname of the main worktree
|
||||
# WT_ROOT <- $REPO_PARENT/$REPO_NAME-worktrees
|
||||
# SLUG <- branch with '/' replaced by '-'
|
||||
# WT_PATH <- $WT_ROOT/$SLUG
|
||||
#
|
||||
# The derivation puts the worktree on the same filesystem as the object store
|
||||
# it shares, as a sibling of the repo, under one root per repo. Those are the
|
||||
# properties that make the checkout cheap and — via `git worktree list` —
|
||||
# enumerable, which is the only reason automated cleanup can ever be safe.
|
||||
#
|
||||
# Usage:
|
||||
# mosaic-worktree.sh new <branch> [--from <base>] create (branch may exist)
|
||||
# mosaic-worktree.sh path <branch> print derived path, no side effect
|
||||
# mosaic-worktree.sh list this repo's worktrees + state
|
||||
# mosaic-worktree.sh rm <branch> [--force] remove; refuses to lose work
|
||||
# mosaic-worktree.sh gc [--apply] report/remove clean+pushed worktrees
|
||||
#
|
||||
# `rm` and `gc` refuse to delete a worktree with uncommitted changes, with
|
||||
# commits absent from every remote, or holding ignored files that are not of the
|
||||
# well-known regenerable kind (a `.env` is ignored so it is never committed,
|
||||
# which is also why nothing else holds a copy). That check is by EVIDENCE, never
|
||||
# by size or age. --force overrides it and is yours to type deliberately.
|
||||
#
|
||||
# Run from anywhere inside the repo, or pass --repo <path>.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
die() { printf 'mosaic-worktree: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
REPO_HINT=""
|
||||
ARGS=()
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--repo) REPO_HINT="${2:-}"; shift 2 ;;
|
||||
*) ARGS+=("$1"); shift ;;
|
||||
esac
|
||||
done
|
||||
set -- "${ARGS[@]+"${ARGS[@]}"}"
|
||||
|
||||
CMD="${1:-}"
|
||||
[ -n "$CMD" ] || die "no command. Try: new | path | list | rm | gc"
|
||||
shift || true
|
||||
|
||||
# ---- mechanical derivation -------------------------------------------------
|
||||
# The FIRST entry of `git worktree list --porcelain` is always the main
|
||||
# worktree, regardless of which worktree we are standing in. Deriving from cwd
|
||||
# would nest worktrees inside worktrees.
|
||||
resolve_repo() {
|
||||
local start="${REPO_HINT:-$PWD}"
|
||||
git -C "$start" rev-parse --git-dir >/dev/null 2>&1 \
|
||||
|| die "not inside a git repository: $start"
|
||||
# Take the first entry WITHOUT closing the pipe early. `awk ... exit` on the
|
||||
# first match closes the read end while git is still writing, git takes SIGPIPE,
|
||||
# and under `set -euo pipefail` the command substitution returns 141 and this
|
||||
# function aborts SILENTLY — no message, no worktree, and `new` exits 141 while
|
||||
# printing nothing at all.
|
||||
#
|
||||
# Whether it happens depends on how much git still had to write when awk left,
|
||||
# so the failure is a function of REPO SIZE: fine on a repo with three
|
||||
# worktrees, reliably broken on one with seventy. That is backwards — the repos
|
||||
# this helper exists to serve are exactly the ones that accumulated worktrees,
|
||||
# and it silently did nothing on those while working everywhere it was tried.
|
||||
# Measured on a repo with 73 worktrees (10 KB of porcelain): rc=141, no output.
|
||||
#
|
||||
# The file's own comment block below already names this class for `head -200`
|
||||
# and removed that cap for the same reason. The `exit` here is the same defect
|
||||
# in the same file, so the rule is now uniform: nothing in this script closes a
|
||||
# git pipe early. Dropping `exit` costs one pass over a few KB.
|
||||
MAIN_WT="$(git -C "$start" worktree list --porcelain | awk '/^worktree /&&!seen{print substr($0,10); seen=1}')"
|
||||
[ -n "$MAIN_WT" ] || die "could not resolve the main worktree"
|
||||
REPO_NAME="$(basename -- "$MAIN_WT")"
|
||||
REPO_PARENT="$(dirname -- "$MAIN_WT")"
|
||||
WT_ROOT="$REPO_PARENT/$REPO_NAME-worktrees"
|
||||
}
|
||||
|
||||
slugify() { printf '%s' "$1" | tr '/' '-'; }
|
||||
|
||||
derive_path() {
|
||||
local branch="$1"
|
||||
[ -n "$branch" ] || die "branch name required"
|
||||
printf '%s/%s' "$WT_ROOT" "$(slugify "$branch")"
|
||||
}
|
||||
|
||||
# A worktree root under $HOME defeats the entire point: wrong filesystem, and
|
||||
# $HOME is for configuration and state, not work products. Refuse rather than
|
||||
# silently produce the layout we are trying to eliminate.
|
||||
assert_not_home() {
|
||||
local p="$1" home_real repo_real
|
||||
home_real="$(cd "$HOME" && pwd -P)"
|
||||
repo_real="$(cd "$(dirname -- "$p")" 2>/dev/null && pwd -P || dirname -- "$p")"
|
||||
case "$repo_real/" in
|
||||
"$home_real"/*)
|
||||
die "refusing: derived path is under \$HOME ($p).
|
||||
The repo itself lives under \$HOME, so its worktrees would too. Move the repo
|
||||
to a work filesystem (e.g. /src/$REPO_NAME) and re-run. \$HOME holds
|
||||
configuration, credentials, state and caches — not checkouts." ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ---- work-loss evidence ----------------------------------------------------
|
||||
# Two independent questions, both answered from git, neither from size or age:
|
||||
# dirty — anything uncommitted in the tree
|
||||
# unpushed — commits reachable from HEAD that no remote ref contains
|
||||
# precious — IGNORED files git will not mention and will not miss
|
||||
#
|
||||
# The third question is not obvious and was missed on the first pass. An
|
||||
# independent reviewer demonstrated it in four commands: a pushed, clean
|
||||
# worktree whose .gitignore covers `*.secret`, holding one `local.secret`.
|
||||
# `git status --porcelain` is empty, `rev-list --count HEAD --not --remotes` is
|
||||
# 0 — the evidence reads SAFE — and `git worktree remove` deletes the file. The
|
||||
# same shape covers `.env`, credentials, scratch notes, downloaded fixtures:
|
||||
# precisely the files that are ignored BECAUSE they must not be committed, which
|
||||
# is also why nothing else is holding a copy.
|
||||
#
|
||||
# So ignored files count as work unless they are the well-known regenerable
|
||||
# kind. Getting that set wrong is asymmetric: an over-broad list preserves a
|
||||
# worktree that could have been reclaimed (cheap, visible, fixable by --force),
|
||||
# an over-narrow one deletes the only copy of a secret (silent, permanent).
|
||||
# The list stays short and conservative for that reason.
|
||||
DISPOSABLE_RE='(^|/)(node_modules|\.venv|venv|__pycache__|\.mypy_cache|\.pytest_cache|\.ruff_cache|\.turbo|\.cache|\.parcel-cache|\.gradle|dist|build|out|target|coverage|\.next|\.nuxt|\.svelte-kit)(/|$)|\.(pyc|pyo|o|class)$'
|
||||
|
||||
# These three run under `set -euo pipefail` inside command substitution, which
|
||||
# makes any nonzero exit ANYWHERE in the pipeline abort the calling function
|
||||
# silently. Two ways that bites, one of which shipped:
|
||||
#
|
||||
# * `grep -v` exits 1 when it filters everything out. A worktree whose only
|
||||
# ignored entry is `node_modules/` is exactly the SAFE case, and it made
|
||||
# `rm` exit 1 with no message and no removal — found by review.
|
||||
# * `head -200` closes the pipe, SIGPIPEs the producer, and turns a worktree
|
||||
# with 201 dirty files into the same silent abort. Not reported; it is the
|
||||
# same defect one step upstream, so the cap is gone. Counting is cheap;
|
||||
# the cap only ever protected output that is now never printed.
|
||||
#
|
||||
# Every one of them therefore ends in a total, and every stage that can
|
||||
# legitimately exit nonzero says so explicitly.
|
||||
wt_dirty() {
|
||||
local out
|
||||
out="$(git -C "$1" status --porcelain 2>/dev/null || true)"
|
||||
if [ -n "$out" ]; then printf '%s\n' "$out" | wc -l; else printf '0'; fi
|
||||
}
|
||||
|
||||
wt_unpushed() { git -C "$1" rev-list --count HEAD --not --remotes 2>/dev/null || printf '?'; }
|
||||
|
||||
# Default --ignored (not =matching) so a 40k-file node_modules collapses to one
|
||||
# directory entry instead of being enumerated and then discarded.
|
||||
wt_precious() {
|
||||
local ignored
|
||||
ignored="$(git -C "$1" status --porcelain --ignored 2>/dev/null \
|
||||
| awk '/^!! /{print substr($0,4)}' || true)"
|
||||
[ -n "$ignored" ] || { printf '0'; return 0; }
|
||||
printf '%s\n' "$ignored" | grep -Ecv "$DISPOSABLE_RE" || true
|
||||
}
|
||||
|
||||
wt_state() {
|
||||
local wt="$1" d u p
|
||||
d="$(wt_dirty "$wt")"; u="$(wt_unpushed "$wt")"; p="$(wt_precious "$wt")"
|
||||
if [ "$d" -eq 0 ] && [ "$u" = "0" ] && [ "$p" -eq 0 ]; then
|
||||
printf 'SAFE\tclean; 0 unpushed; no ignored files worth keeping'
|
||||
else
|
||||
printf 'PRESERVE\t%s uncommitted; %s unpushed; %s ignored-but-not-disposable' "$d" "$u" "$p"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- commands --------------------------------------------------------------
|
||||
cmd_path() { resolve_repo; derive_path "${1:-}"; echo; }
|
||||
|
||||
cmd_new() {
|
||||
local branch="${1:-}" base=""
|
||||
shift || true
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in --from) base="${2:-}"; shift 2 ;; *) die "unknown flag: $1" ;; esac
|
||||
done
|
||||
[ -n "$branch" ] || die "usage: mosaic-worktree.sh new <branch> [--from <base>]"
|
||||
|
||||
resolve_repo
|
||||
local path; path="$(derive_path "$branch")"
|
||||
assert_not_home "$path"
|
||||
|
||||
if [ -e "$path" ]; then
|
||||
echo "exists: $path"
|
||||
echo "(already checked out — reuse it, or 'rm' it first)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p "$WT_ROOT"
|
||||
|
||||
# Existing branch -> check it out. New branch -> create from base (default:
|
||||
# the remote's default branch if resolvable, else current HEAD).
|
||||
if git -C "$MAIN_WT" show-ref --verify --quiet "refs/heads/$branch" \
|
||||
|| git -C "$MAIN_WT" show-ref --verify --quiet "refs/remotes/origin/$branch"; then
|
||||
git -C "$MAIN_WT" worktree add "$path" "$branch"
|
||||
else
|
||||
if [ -z "$base" ]; then
|
||||
base="$(git -C "$MAIN_WT" symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)"
|
||||
[ -n "$base" ] || base="HEAD"
|
||||
fi
|
||||
git -C "$MAIN_WT" worktree add -b "$branch" "$path" "$base"
|
||||
fi
|
||||
|
||||
cat <<EOF
|
||||
|
||||
worktree: $path
|
||||
branch: $branch
|
||||
|
||||
Removal is part of this task, not a later chore. When the work is pushed:
|
||||
mosaic-worktree.sh rm $branch
|
||||
EOF
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
resolve_repo
|
||||
printf 'repo: %s\nroot: %s\n\n' "$MAIN_WT" "$WT_ROOT"
|
||||
git -C "$MAIN_WT" worktree list --porcelain \
|
||||
| awk '/^worktree /{print substr($0,10)}' \
|
||||
| while read -r wt; do
|
||||
[ "$wt" = "$MAIN_WT" ] && { printf '%-10s %s (main)\n' "-" "$wt"; continue; }
|
||||
printf '%-10s %s\t%s\n' "$(wt_state "$wt" | cut -f1)" "$wt" "$(wt_state "$wt" | cut -f2)"
|
||||
done
|
||||
}
|
||||
|
||||
cmd_rm() {
|
||||
local branch="${1:-}" force=0
|
||||
shift || true
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in --force) force=1; shift ;; *) die "unknown flag: $1" ;; esac
|
||||
done
|
||||
[ -n "$branch" ] || die "usage: mosaic-worktree.sh rm <branch> [--force]"
|
||||
|
||||
resolve_repo
|
||||
local path; path="$(derive_path "$branch")"
|
||||
[ -d "$path" ] || die "no worktree at $path"
|
||||
|
||||
local d u p
|
||||
d="$(wt_dirty "$path")"; u="$(wt_unpushed "$path")"; p="$(wt_precious "$path")"
|
||||
if [ "$force" -eq 0 ] && { [ "$d" -ne 0 ] || [ "$u" != "0" ] || [ "$p" -ne 0 ]; }; then
|
||||
die "refusing to remove $path
|
||||
uncommitted files: $d
|
||||
unpushed commits: $u
|
||||
ignored, not disposable: $p
|
||||
Commit and push first — that is the contract. Ignored files are counted because
|
||||
git will neither report them nor miss them: a .env or a *.secret is ignored
|
||||
precisely so it is never committed, which is also why nothing else holds a copy.
|
||||
List them with: git -C $path status --porcelain --ignored | grep '^!!'
|
||||
If this work is genuinely disposable, re-run with --force."
|
||||
fi
|
||||
|
||||
# NB: ${force:+--force} would expand for force=0 too ("0" is non-empty).
|
||||
if [ "$force" -eq 1 ]; then
|
||||
git -C "$MAIN_WT" worktree remove --force "$path"
|
||||
else
|
||||
git -C "$MAIN_WT" worktree remove "$path"
|
||||
fi
|
||||
git -C "$MAIN_WT" worktree prune
|
||||
echo "removed: $path"
|
||||
rmdir "$WT_ROOT" 2>/dev/null || true
|
||||
}
|
||||
|
||||
cmd_gc() {
|
||||
local apply=0
|
||||
[ "${1:-}" = "--apply" ] && apply=1
|
||||
resolve_repo
|
||||
git -C "$MAIN_WT" worktree prune
|
||||
git -C "$MAIN_WT" worktree list --porcelain \
|
||||
| awk '/^worktree /{print substr($0,10)}' \
|
||||
| while read -r wt; do
|
||||
[ "$wt" = "$MAIN_WT" ] && continue
|
||||
local_state="$(wt_state "$wt")"
|
||||
case "$local_state" in
|
||||
SAFE*)
|
||||
if [ "$apply" -eq 1 ]; then
|
||||
git -C "$MAIN_WT" worktree remove "$wt" && echo "removed: $wt"
|
||||
else
|
||||
echo "reclaimable (clean + fully pushed): $wt"
|
||||
fi ;;
|
||||
*) echo "preserved: $wt [$(printf '%s' "$local_state" | cut -f2)]" ;;
|
||||
esac
|
||||
done
|
||||
git -C "$MAIN_WT" worktree prune
|
||||
[ "$apply" -eq 1 ] || echo $'\n(report only — re-run with --apply to remove the reclaimable ones)'
|
||||
}
|
||||
|
||||
case "$CMD" in
|
||||
new) cmd_new "$@" ;;
|
||||
path) cmd_path "$@" ;;
|
||||
list) cmd_list "$@" ;;
|
||||
rm) cmd_rm "$@" ;;
|
||||
gc) cmd_gc "$@" ;;
|
||||
-h|--help|help) sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//' ;;
|
||||
*) die "unknown command: $CMD (new | path | list | rm | gc)" ;;
|
||||
esac
|
||||
@@ -84,7 +84,7 @@ cp "$TARGET" "$BAK"
|
||||
export MOSAIC_TEST_WORK_DIR="$WORK/.work"
|
||||
|
||||
# --- where the prose lives: usage() { ... EOF ---------------------------------
|
||||
PROSE_LO="$(grep -n -m1 '^usage() {' "$BAK" | cut -d: -f1)"
|
||||
PROSE_LO="$(grep -n '^usage() {' "$BAK" | head -1 | cut -d: -f1)"
|
||||
PROSE_HI="$(awk -v lo="$PROSE_LO" 'NR > lo && /^EOF$/ { print NR; exit }' "$BAK")"
|
||||
if [[ -z "$PROSE_LO" || -z "$PROSE_HI" ]]; then
|
||||
echo "!! cannot locate the usage() heredoc -- the prose guard would be inert; refusing" >&2
|
||||
|
||||
@@ -219,7 +219,6 @@ case "$PLATFORM" in
|
||||
exit 0
|
||||
fi
|
||||
echo "Warning: tea pr create failed, trying Gitea API fallback..." >&2
|
||||
{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true
|
||||
gitea_pr_create_api
|
||||
;;
|
||||
*)
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# pr-edit.sh - Edit a pull request on GitHub or Gitea
|
||||
# Usage: pr-edit.sh -n <pr_number> [-t <title>] [-b <body>] [-B <base>] [--draft|--ready] [--login <name>] [-r owner/repo] [-H host]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=detect-platform.sh
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
PR_NUMBER=""
|
||||
TITLE=""
|
||||
BODY=""
|
||||
BASE_BRANCH=""
|
||||
DRAFT_MODE=""
|
||||
LOGIN_OVERRIDE=""
|
||||
REPO_OVERRIDE=""
|
||||
HOST_OVERRIDE=""
|
||||
AUTH_CONFIG=""
|
||||
|
||||
cleanup() {
|
||||
[[ -z "$AUTH_CONFIG" ]] || rm -f -- "$AUTH_CONFIG"
|
||||
}
|
||||
terminate() {
|
||||
local signal="$1"
|
||||
trap - "$signal"
|
||||
cleanup
|
||||
kill -s "$signal" "$$"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'terminate HUP' HUP
|
||||
trap 'terminate INT' INT
|
||||
trap 'terminate TERM' TERM
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [OPTIONS]
|
||||
|
||||
Edit a pull request on the current repository (Gitea or GitHub).
|
||||
|
||||
Options:
|
||||
-n, --number NUMBER Pull request number (required)
|
||||
-t, --title TITLE New title
|
||||
-b, --body BODY New body/description
|
||||
-B, --base BRANCH New base branch
|
||||
--draft Mark the pull request as draft
|
||||
--ready Mark the pull request ready for review
|
||||
-l, --login NAME Gitea login (must authenticate as MOSAIC_GIT_IDENTITY)
|
||||
-r, --repo OWNER/REPO Explicit target repository
|
||||
-H, --host HOST Explicit Gitea host (required with --repo off-host)
|
||||
-h, --help Show this help message
|
||||
EOF
|
||||
exit "${1:-1}"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-n|--number) PR_NUMBER="${2:-}"; shift 2 ;;
|
||||
-t|--title) TITLE="${2:-}"; shift 2 ;;
|
||||
-b|--body) BODY="${2:-}"; shift 2 ;;
|
||||
-B|--base) BASE_BRANCH="${2:-}"; shift 2 ;;
|
||||
--draft)
|
||||
[[ "$DRAFT_MODE" != "ready" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; }
|
||||
DRAFT_MODE="draft"; shift ;;
|
||||
--ready)
|
||||
[[ "$DRAFT_MODE" != "draft" ]] || { echo "Error: --draft and --ready are mutually exclusive" >&2; exit 1; }
|
||||
DRAFT_MODE="ready"; shift ;;
|
||||
-l|--login) LOGIN_OVERRIDE="${2:-}"; shift 2 ;;
|
||||
-r|--repo) REPO_OVERRIDE="${2:-}"; shift 2 ;;
|
||||
-H|--host) HOST_OVERRIDE="${2:-}"; shift 2 ;;
|
||||
-h|--help) usage 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n "$PR_NUMBER" ]] || { echo "Error: Pull request number is required (-n)" >&2; exit 1; }
|
||||
[[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || { echo "Error: Pull request number must be a positive integer" >&2; exit 1; }
|
||||
if [[ -z "$TITLE" && -z "$BODY" && -z "$BASE_BRANCH" && -z "$DRAFT_MODE" ]]; then
|
||||
echo "Error: At least one edit option is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
[[ -z "$REPO_OVERRIDE" || "$REPO_OVERRIDE" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || {
|
||||
echo "Error: --repo must be OWNER/REPO" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ -n "$HOST_OVERRIDE" || -n "$REPO_OVERRIDE" ]]; then
|
||||
PLATFORM="gitea"
|
||||
else
|
||||
PLATFORM=$(detect_platform)
|
||||
fi
|
||||
|
||||
case "$PLATFORM" in
|
||||
github)
|
||||
[[ -z "$LOGIN_OVERRIDE" ]] || { echo "Error: --login is only valid for Gitea" >&2; exit 1; }
|
||||
if [[ -n "$TITLE" || -n "$BODY" || -n "$BASE_BRANCH" ]]; then
|
||||
CMD=(gh pr edit "$PR_NUMBER")
|
||||
[[ -n "$TITLE" ]] && CMD+=(--title "$TITLE")
|
||||
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
||||
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
|
||||
"${CMD[@]}"
|
||||
fi
|
||||
if [[ "$DRAFT_MODE" == "draft" ]]; then
|
||||
gh pr ready "$PR_NUMBER" --undo
|
||||
elif [[ "$DRAFT_MODE" == "ready" ]]; then
|
||||
gh pr ready "$PR_NUMBER"
|
||||
fi
|
||||
;;
|
||||
gitea)
|
||||
IDENTITY="${MOSAIC_GIT_IDENTITY:-}"
|
||||
[[ -n "$IDENTITY" ]] || {
|
||||
echo "Error: MOSAIC_GIT_IDENTITY is required for a mutating Gitea operation" >&2
|
||||
exit 1
|
||||
}
|
||||
HOST="${HOST_OVERRIDE:-}"
|
||||
if [[ -z "$HOST" ]]; then
|
||||
HOST=$(get_remote_host) || {
|
||||
echo "Error: Could not resolve Gitea host; pass --host with --repo" >&2
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
HOST="${HOST#http://}"; HOST="${HOST#https://}"; HOST="${HOST%%/*}"
|
||||
REPO_SLUG="${REPO_OVERRIDE:-}"
|
||||
if [[ -z "$REPO_SLUG" ]]; then
|
||||
REPO_SLUG=$(get_repo_slug) || { echo "Error: Could not resolve Gitea repo slug from remote" >&2; exit 1; }
|
||||
fi
|
||||
|
||||
if [[ -n "$LOGIN_OVERRIDE" ]]; then
|
||||
GITEA_LOGIN_NAME="$LOGIN_OVERRIDE"
|
||||
elif [[ -n "${GITEA_LOGIN:-}" ]]; then
|
||||
GITEA_LOGIN_NAME="$GITEA_LOGIN"
|
||||
else
|
||||
echo "Error: --login (or GITEA_LOGIN) is required; refusing host-first login selection" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOKEN=$(get_gitea_token_for_login "$GITEA_LOGIN_NAME" "$HOST") || {
|
||||
echo "Error: login '$GITEA_LOGIN_NAME' is not configured for target host '$HOST'" >&2
|
||||
exit 1
|
||||
}
|
||||
AUTH_CONFIG=$(gitea_write_auth_config "$TOKEN") || {
|
||||
echo "Error: could not stage private Gitea authentication" >&2
|
||||
exit 1
|
||||
}
|
||||
unset TOKEN
|
||||
|
||||
API_BASE="https://${HOST}/api/v1"
|
||||
# Resolve identity through the SAME private curl config used for the
|
||||
# mutation. Tea login names are globally scoped and can be duplicated
|
||||
# across hosts; a separate `tea api --login NAME` could validate another
|
||||
# credential than this host-bound token.
|
||||
AUTHENTICATED_USER=$(curl -fsS --config "$AUTH_CONFIG" -H "User-Agent: mosaic-pr-edit" "$API_BASE/user" \
|
||||
| python3 -c 'import json,sys; value=json.load(sys.stdin).get("login"); print(value) if isinstance(value,str) and value else sys.exit(1)') || {
|
||||
echo "Error: could not authenticate the host-bound credential for '$GITEA_LOGIN_NAME'" >&2
|
||||
exit 1
|
||||
}
|
||||
[[ "$AUTHENTICATED_USER" == "$IDENTITY" ]] || {
|
||||
echo "Error: host-bound credential authenticates as '$AUTHENTICATED_USER', not MOSAIC_GIT_IDENTITY '$IDENTITY'" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
REPO_API="$API_BASE/repos/${REPO_SLUG}"
|
||||
curl -fsS --config "$AUTH_CONFIG" -H "User-Agent: mosaic-pr-edit" "$REPO_API" >/dev/null || {
|
||||
echo "Error: target repository preflight failed for https://${HOST}/${REPO_SLUG}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
PAYLOAD=$(TITLE="$TITLE" BODY="$BODY" BASE_BRANCH="$BASE_BRANCH" DRAFT_MODE="$DRAFT_MODE" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
payload = {}
|
||||
if os.environ["TITLE"]: payload["title"] = os.environ["TITLE"]
|
||||
if os.environ["BODY"]: payload["body"] = os.environ["BODY"]
|
||||
if os.environ["BASE_BRANCH"]: payload["base"] = os.environ["BASE_BRANCH"]
|
||||
if os.environ["DRAFT_MODE"]: payload["draft"] = os.environ["DRAFT_MODE"] == "draft"
|
||||
print(json.dumps(payload))
|
||||
PY
|
||||
)
|
||||
curl -fsS --config "$AUTH_CONFIG" -X PATCH \
|
||||
-H "User-Agent: mosaic-pr-edit" -H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" "$REPO_API/pulls/${PR_NUMBER}"
|
||||
echo "Updated Gitea pull request #$PR_NUMBER as '$AUTHENTICATED_USER'" >&2
|
||||
;;
|
||||
*) echo "Error: Could not detect git platform" >&2; exit 1 ;;
|
||||
esac
|
||||
@@ -243,35 +243,15 @@ from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _origin_and_path(url):
|
||||
# Normalize a URL to (scheme-class, host, distinguishing-port) + comment path.
|
||||
#
|
||||
# #991: http and https collapse into ONE scheme class ("web"). A Gitea whose
|
||||
# ROOT_URL is configured http:// returns http:// object URLs even when every
|
||||
# client reaches it over https://, so a scheme-strict comparison rejects the
|
||||
# provider's own correct answer about a comment that landed — a deterministic
|
||||
# false negative on EVERY review comment posted against such a deployment.
|
||||
# That matters more here than anywhere else: on a host where no seat can
|
||||
# create a review OBJECT, the comment-form review record this path produces
|
||||
# is the only gate-16 evidence available, and this check refuses all of it.
|
||||
# The scheme is also not what the check defends: the forgeries it exists to
|
||||
# catch (look-alike host, decoy path prefix, wrong owner/repo/kind/number)
|
||||
# all vary the HOST or the PATH, both of which stay strict below. Any OTHER
|
||||
# scheme (file:, ftp:, javascript:) remains distinguishing and is rejected.
|
||||
#
|
||||
# Port: an implicit port and its own scheme's default compare equal, so
|
||||
# http://h == https://h. An EXPLICIT non-default port still distinguishes,
|
||||
# because a different port is a different service on the same host.
|
||||
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
|
||||
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
|
||||
# port and its explicit default form compare equal.
|
||||
parsed = urlparse(url or "")
|
||||
scheme = (parsed.scheme or "").lower()
|
||||
host = (parsed.hostname or "").lower()
|
||||
if scheme in ("http", "https"):
|
||||
scheme_class = "web"
|
||||
default_port = 80 if scheme == "http" else 443
|
||||
port = None if parsed.port in (None, default_port) else parsed.port
|
||||
else:
|
||||
scheme_class = scheme
|
||||
port = parsed.port
|
||||
return (scheme_class, host, port), parsed.path.rstrip("/")
|
||||
default_port = 80 if scheme == "http" else 443
|
||||
port = parsed.port if parsed.port is not None else default_port
|
||||
return (scheme, host, port), parsed.path.rstrip("/")
|
||||
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Regression: detect_platform / get_repo_info must FAIL LOUDLY outside a git repo,
|
||||
# not kill the caller silently.
|
||||
#
|
||||
# Both functions already contained the right error path:
|
||||
# if [[ -z "$remote_url" ]]; then echo "error: not a git repository..." >&2; return 1; fi
|
||||
# but under `set -e` -- which every wrapper in this directory uses -- the preceding
|
||||
# assignment `remote_url=$(git remote get-url origin 2>/dev/null)` returns git's 128
|
||||
# outside a repo and terminates the CALLER first. The message was unreachable.
|
||||
#
|
||||
# Observed cost: pr-review.sh invoked from a non-repo cwd exits 128 with NO stdout and
|
||||
# NO stderr, even when -r/--repo and -H/--host are supplied -- the flags documented as
|
||||
# "skips git-remote inference". Two reviewer seats hit this and correctly reported
|
||||
# `blocked` with no diagnostic to report.
|
||||
#
|
||||
# The control that matters is the LOUD one: asserting "rc != 0" passes on the broken
|
||||
# build too, because 128 is also non-zero. The test must assert the MESSAGE.
|
||||
set -uo pipefail
|
||||
fail=0
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
run_outside() { # $1=function name -> "rc:sawmessage"
|
||||
local fn="$1" out rc
|
||||
out=$( cd "$TMP" && bash -c "set -e; source '$HERE/detect-platform.sh'; $fn" 2>&1 ); rc=$?
|
||||
printf '%s:%s' "$rc" "$(grep -qi 'not a git repository' <<<"$out" && echo yes || echo no)"
|
||||
}
|
||||
check() { if [ "$2" = "$3" ]; then echo " PASS $1 ($2)"; else echo " FAIL $1: got $2, want $3"; fail=1; fi; }
|
||||
|
||||
# $TMP must not be inside a git repo. Do not SKIP on failure: be-coder-07 showed the
|
||||
# original SKIP exited 0, so pointing TMPDIR beneath a git worktree made this test PASS
|
||||
# against unchanged main. A skip that exits 0 is indistinguishable from a pass.
|
||||
# GIT_CEILING_DIRECTORIES stops git walking above $TMP, making the condition hold
|
||||
# regardless of where TMPDIR lives, rather than merely detecting when it does not.
|
||||
# GIT_CEILING_DIRECTORIES is matched against the PHYSICAL path -- a symlinked TMPDIR
|
||||
# (/tmp is commonly one) makes the logical path never match, and the ceiling silently
|
||||
# does nothing. Resolve it before exporting.
|
||||
TMP="$(cd "$TMP" && pwd -P)"
|
||||
export GIT_CEILING_DIRECTORIES="$TMP"
|
||||
if ( cd "$TMP" && git rev-parse --git-dir >/dev/null 2>&1 ); then
|
||||
echo " FAIL scratch dir is inside a git repo even with GIT_CEILING_DIRECTORIES set;"
|
||||
echo " the outside-a-repo precondition cannot be established -- refusing to report a result"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== outside a git repo: rc=1 AND the diagnostic is emitted =="
|
||||
check "detect_platform" "$(run_outside detect_platform)" "1:yes"
|
||||
check "get_repo_info" "$(run_outside get_repo_info)" "1:yes"
|
||||
|
||||
echo "== inside a git repo the functions still work =="
|
||||
git init -q "$TMP/repo" 2>/dev/null
|
||||
git -C "$TMP/repo" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git 2>/dev/null
|
||||
out=$( cd "$TMP/repo" && bash -c "set -e; source '$HERE/detect-platform.sh'; detect_platform" 2>&1 ); rc=$?
|
||||
if [ "$rc" -eq 0 ] && grep -qi 'gitea' <<<"$out"; then echo " PASS detect_platform in-repo (rc=0, $out)"
|
||||
else echo " FAIL detect_platform in-repo: rc=$rc out=$out"; fail=1; fi
|
||||
|
||||
[ "$fail" -eq 0 ] && echo "OK detect-platform fails loudly outside a repo" || echo "FAILED"
|
||||
exit "$fail"
|
||||
@@ -1,64 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Regression: the tea-failure diagnostic must be STATUS-NEUTRAL.
|
||||
#
|
||||
# Found by be-coder-08 reviewing PR #1086. At all three call sites the diagnostic is emitted
|
||||
# immediately BEFORE the Gitea API fallback. Written as the last command of an && list:
|
||||
# declare -F explain_... >/dev/null && explain_...
|
||||
# under `set -e` a FAILING diagnostic exits and the fallback never runs -- a diagnostic that
|
||||
# suppresses the recovery path it exists to explain. It misbehaves ONLY when the helper is
|
||||
# PRESENT, so the helper-absent path (pre-#1086 behaviour) keeps working and reads as a
|
||||
# passing control.
|
||||
#
|
||||
# TWO DEFECTS IN THE FIRST VERSION OF THIS TEST, both found by be-coder-08:
|
||||
# 1. `out=$( ... ) 2>"$errto"` applies the redirection to the ASSIGNMENT, not to the
|
||||
# command substitution, so the probe's stderr was never actually pointed at /dev/full
|
||||
# and the /dev/full rows proved nothing. Verified: `out=$(echo x >&2) 2>/dev/full`
|
||||
# leaks to the terminal and returns 0; the redirect must be INSIDE the substitution.
|
||||
# 2. `eval "$CONSTRUCT"` changes `set -e` semantics for a bare && list, so the probe did
|
||||
# not exercise the construct as the shipped file executes it. It now writes the line
|
||||
# into a real script and runs it -- same parse, same set -e rules, no eval.
|
||||
# The construct is still LIFTED FROM THE SHIPPED FILE: retyping the fixed form makes the
|
||||
# probe pass on a build whose real call sites still carry the bare && form.
|
||||
set -uo pipefail
|
||||
fail=0
|
||||
GIT_DIR_UNDER_TEST="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
probe() { # $1=present|absent $2=stderr target $3=source file -> "rc:fallback"
|
||||
local helper="$1" errto="$2" src="$3" construct script out rc
|
||||
construct=$(grep -m1 'explain_tea_user_does_not_exist' "$GIT_DIR_UNDER_TEST/$src" | sed 's/^[[:space:]]*//')
|
||||
[ -n "$construct" ] || { printf 'no-construct:no'; return; }
|
||||
script="$TMP/probe.sh"
|
||||
{
|
||||
echo '#!/bin/bash'
|
||||
echo 'set -e'
|
||||
echo 'explain_tea_user_does_not_exist() { echo "diagnostic" >&2; }'
|
||||
[ "$helper" = absent ] && echo 'unset -f explain_tea_user_does_not_exist'
|
||||
echo "$construct" # the shipped line, parsed by a real shell
|
||||
echo 'echo FALLBACK_REACHED'
|
||||
} > "$script"
|
||||
# redirect INSIDE the substitution so the subshell's stderr really is $errto
|
||||
out=$( bash "$script" 2>"$errto" ); rc=$?
|
||||
printf '%s:%s' "$rc" "$(grep -q FALLBACK_REACHED <<<"$out" && echo yes || echo no)"
|
||||
}
|
||||
|
||||
check() { if [ "$2" = "$3" ]; then echo " PASS $1 ($2)"; else echo " FAIL $1: got $2, want $3"; fail=1; fi; }
|
||||
|
||||
echo "== diagnostic must not alter exit status or skip the fallback =="
|
||||
# /dev/full makes every stderr write fail -- the real-world shape is a closed or full fd.
|
||||
for src in pr-create.sh issue-view.sh issue-create.sh; do
|
||||
check "$src stderr OK / helper present" "$(probe present /dev/null "$src")" "0:yes"
|
||||
check "$src stderr OK / helper absent " "$(probe absent /dev/null "$src")" "0:yes"
|
||||
check "$src stderr FAILING / helper present" "$(probe present /dev/full "$src")" "0:yes"
|
||||
check "$src stderr FAILING / helper absent " "$(probe absent /dev/full "$src")" "0:yes"
|
||||
done
|
||||
|
||||
echo "== all three call sites use the status-neutral form =="
|
||||
for f in pr-create.sh issue-view.sh issue-create.sh; do
|
||||
p="$GIT_DIR_UNDER_TEST/$f"
|
||||
grep -q '{ declare -F explain_tea_user_does_not_exist >/dev/null && explain_tea_user_does_not_exist; } || true' "$p" \
|
||||
&& echo " PASS $f guarded" || { echo " FAIL $f: diagnostic is not status-neutral"; fail=1; }
|
||||
done
|
||||
|
||||
[ "$fail" -eq 0 ] && echo "OK diagnostic is status-neutral" || echo "FAILED"
|
||||
exit "$fail"
|
||||
@@ -7,40 +7,14 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-login-resolution}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
HOME_DIR="$WORK_DIR/home"
|
||||
LOG_FILE="$WORK_DIR/calls.log"
|
||||
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$REPO_DIR" "$BIN_DIR" "$HOME_DIR"
|
||||
mkdir -p "$REPO_DIR" "$BIN_DIR"
|
||||
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git
|
||||
# HERMETICITY (#1007) — TWO mechanisms with DIFFERENT jobs; do not conflate them.
|
||||
#
|
||||
# OPERATIVE: the empty repo-local `mosaic.gitIdentity` below. get_gitea_token()
|
||||
# step 0 resolves a per-agent identity from `git config --get mosaic.gitIdentity`,
|
||||
# which on a provisioned agent seat is set GLOBALLY and so leaks into this fresh
|
||||
# repo. It then reads a REAL per-slot token from $HOME and returns it WITHOUT ever
|
||||
# consulting MOSAIC_CREDENTIALS_FILE, so the fixture credentials below are silently
|
||||
# ignored. This suite is the one where the consequence is not subtle: it FAILS
|
||||
# outright on a provisioned seat (rc=1 bare, rc=0 with $HOME sandboxed, one
|
||||
# variable changed) and passes everywhere else, including CI, which has no
|
||||
# per-agent token to leak.
|
||||
#
|
||||
# CONTAINMENT: the sandboxed HOME in the four run helpers below. It only has to
|
||||
# bound a failure that the pin should already have prevented.
|
||||
#
|
||||
# NOTE FOR ANYONE AUDITING THIS SUITE: the sandboxed HOME is containment, NOT an
|
||||
# assay. Running a suite under a decoy HOME to test for this defect REMOVES the
|
||||
# trigger — ~/.gitconfig is where the global identity lives, so step 0 is skipped
|
||||
# by construction and every suite reads clean however vulnerable it is. To measure,
|
||||
# REPLICATE a seat (a decoy HOME whose .gitconfig sets mosaic.gitIdentity, with no
|
||||
# per-slot token) so step 0 reaches its fail-loud branch.
|
||||
#
|
||||
# Note the env-var route does NOT work: detect-platform.sh reads
|
||||
# "${MOSAIC_GIT_IDENTITY:-}", and `:-` treats set-but-empty identically to unset.
|
||||
git -C "$REPO_DIR" config mosaic.gitIdentity ""
|
||||
|
||||
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||
{
|
||||
@@ -112,7 +86,6 @@ run_in_repo() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||
"$@"
|
||||
@@ -310,7 +283,6 @@ run_in_repo2() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR2:$PATH" \
|
||||
HOME="$HOME_DIR" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||
"$@"
|
||||
@@ -371,7 +343,7 @@ write_fixture() { printf '%s' "$1" > "$FIXTURE_XDG/tea/config.yml"; }
|
||||
token_fallback() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
HOME="$HOME_DIR" XDG_CONFIG_HOME="$FIXTURE_XDG" PYTHONPATH="$NOYAML_DIR" bash -c '
|
||||
XDG_CONFIG_HOME="$FIXTURE_XDG" PYTHONPATH="$NOYAML_DIR" bash -c '
|
||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||
get_gitea_token_for_login "$1" "$2"
|
||||
' _ "$1" "$2"
|
||||
@@ -382,7 +354,7 @@ token_fallback() {
|
||||
token_pyyaml() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
HOME="$HOME_DIR" XDG_CONFIG_HOME="$FIXTURE_XDG" bash -c '
|
||||
XDG_CONFIG_HOME="$FIXTURE_XDG" bash -c '
|
||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||
get_gitea_token_for_login "$1" "$2"
|
||||
' _ "$1" "$2"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for #701: -h/--help must exit 0, bad args must still exit nonzero.
|
||||
#
|
||||
# Covers wrappers whose usage() previously hard-coded `exit 1`, so every
|
||||
# Covers the 7 wrappers whose usage() previously hard-coded `exit 1`, so every
|
||||
# --help invocation exited nonzero and logged a phantom isError across fleet lanes.
|
||||
# Asserts, per wrapper:
|
||||
# 1. `--help` exits 0 and prints usage.
|
||||
@@ -18,7 +18,6 @@ WRAPPERS=(
|
||||
issue-list.sh
|
||||
milestone-create.sh
|
||||
pr-create.sh
|
||||
pr-edit.sh
|
||||
pr-list.sh
|
||||
pr-merge.sh
|
||||
)
|
||||
@@ -48,7 +47,7 @@ for wrapper in "${WRAPPERS[@]}"; do
|
||||
done
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "help-exit-code regression passed (8/8 wrappers)"
|
||||
echo "help-exit-code regression passed (7/7 wrappers)"
|
||||
fi
|
||||
|
||||
exit "$fail"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user