Compare commits
105
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98f3384554 | ||
|
|
4211fb87f8 | ||
|
|
ff40794bfe | ||
|
|
94a7d5b692 | ||
|
|
12d5258e20 | ||
|
|
f6fbeaf57a | ||
|
|
8a55c04108 | ||
|
|
bea47543f3 | ||
|
|
37cd00e60d | ||
|
|
f0d2dd9920 | ||
|
|
6306914965 | ||
|
|
af43a7a63e | ||
|
|
ca97b885b0 | ||
|
|
6db0bead44 | ||
|
|
c671290d77 | ||
|
|
6a9b2cf6c1 | ||
|
|
6bd93a621d | ||
|
|
e9485c3d96 | ||
|
|
d2f0846dcc | ||
|
|
4f22a58041 | ||
|
|
9b6869fab7 | ||
|
|
20ad89c86b | ||
|
|
a55d1a1812 | ||
|
|
9abd7e386f | ||
|
|
9af456c240 | ||
|
|
cb9a0d1642 | ||
|
|
420507da77 | ||
|
|
2508f0aa99 | ||
|
|
d339e8fd21 | ||
|
|
018d96a412 | ||
|
|
b01950e92f | ||
|
|
1bdeed62eb | ||
|
|
840c2b0d96 | ||
|
|
95d5cb32d4 | ||
|
|
d5f3fae896 | ||
|
|
1556982dbc | ||
|
|
1a822493ba | ||
|
|
d2eeb64433 | ||
|
|
5e58597dbe | ||
|
|
fe4fa20309 | ||
|
|
5e93ef70bd | ||
|
|
3884f2de4d | ||
|
|
a3c50d91ca | ||
|
|
2fd102e6af | ||
|
|
efb3c3a10c | ||
|
|
d4d32a80b2 | ||
|
|
c703cc50eb | ||
|
|
3d2b712355 | ||
|
|
245e0c427d | ||
|
|
ff45f7b5d0 | ||
|
|
64350892e7 | ||
|
|
6e9df3c640 | ||
|
|
f5ba042dfa | ||
|
|
7c7dab3898 | ||
|
|
d92de53399 | ||
|
|
d7e303d3c0 | ||
|
|
726d2ad3a2 | ||
|
|
e4ee1acf24 | ||
|
|
5c5a25e4de | ||
|
|
7669321ea2 | ||
|
|
d8e0aec950 | ||
|
|
49d6136b02 | ||
|
|
a80bae950d | ||
|
|
8199261caa | ||
|
|
57a2f2b40e | ||
|
|
93c1de51e1 | ||
|
|
7102ccb93e | ||
|
|
afdaa6d0e6 | ||
|
|
41749bbd33 | ||
|
|
120af4e193 | ||
|
|
ec260e678f | ||
|
|
b590a5c3d8 | ||
|
|
540ec5b6ef | ||
|
|
563d1ac053 | ||
|
|
4d8ddb9a0a | ||
|
|
722163671f | ||
|
|
f158be8003 | ||
|
|
b0f7d26dd9 | ||
|
|
3a1203b2f8 | ||
|
|
df4c591ab4 | ||
|
|
4fa2768962 | ||
|
|
aa0a7b5fa2 | ||
|
|
42ac19af48 | ||
|
|
f744f32214 | ||
|
|
8ff7aac0ca | ||
|
|
80a45b1e1c | ||
|
|
85d2108e4e | ||
|
|
16f91157a1 | ||
|
|
2fa6bcd576 | ||
|
|
1afe2b36dc | ||
|
|
63e77887a8 | ||
|
|
809ca9a1d9 | ||
|
|
57435bb879 | ||
|
|
8c51bf7575 | ||
|
|
c3d2179ad8 | ||
|
|
b47c4024cc | ||
|
|
74d1cdc7c1 | ||
|
|
8cae9e0883 | ||
|
|
2c524b6da2 | ||
|
|
b4f2019529 | ||
|
|
b1eb1fb2f9 | ||
|
|
dfeb4d9692 | ||
|
|
7ea13332ed | ||
|
|
b032d23889 | ||
|
|
ecde74439c |
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"integration_trunk": "next",
|
||||||
|
"release_branch": "main"
|
||||||
|
}
|
||||||
@@ -22,9 +22,9 @@ steps:
|
|||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: REGISTRY_USERNAME
|
||||||
REGISTRY_PASS:
|
REGISTRY_PASS:
|
||||||
from_secret: gitea_password
|
from_secret: REGISTRY_PASSWORD
|
||||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||||
|
|||||||
+74
-7
@@ -2,16 +2,38 @@
|
|||||||
# node:24-alpine + python3/make/g++/postgresql-client + pnpm + a warm pnpm
|
# node:24-alpine + python3/make/g++/postgresql-client + pnpm + a warm pnpm
|
||||||
# store. The install step resolves from the baked store (--prefer-offline)
|
# store. The install step resolves from the baked store (--prefer-offline)
|
||||||
# instead of paying a ~731s cold fetch + native compile every run.
|
# instead of paying a ~731s cold fetch + native compile every run.
|
||||||
|
#
|
||||||
|
# PINNED to an immutable lock-tag (#1328, brain D27): ci-image.yml pushes
|
||||||
|
# lock-<sha256(pnpm-lock.yaml)[:12]> atomically with :latest, so the two are
|
||||||
|
# byte-identical at push time. A mutable :latest resolves per-pod at pull time
|
||||||
|
# on the k8s backend, which made CI verdicts non-reproducible (same tree, same
|
||||||
|
# config, different images across runs; see #1324 comment 23382/23386). The pin
|
||||||
|
# changes ONLY through reviewed commits; a wrong tag fails loudly at image pull.
|
||||||
|
#
|
||||||
|
# Bump procedure: when a recipe change (pnpm-lock.yaml / Dockerfile.ci) lands on
|
||||||
|
# main, ci-image.yml pushes lock-<new>; a follow-up PR updates this anchor.
|
||||||
|
# Until then pipelines keep the old pin: reproducible, with the documented
|
||||||
|
# network-fallback lag (frozen-lockfile resolves missing packages from network).
|
||||||
|
# Known limitation: lock- addresses the lockfile only, so a Dockerfile-only
|
||||||
|
# change re-pushes the same tag with new content (#1328 follow-up: recipe-hash).
|
||||||
variables:
|
variables:
|
||||||
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
|
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:lock-9cb7ffcd8828'
|
||||||
- &enable_pnpm 'corepack enable'
|
- &enable_pnpm 'corepack enable'
|
||||||
|
|
||||||
when:
|
when:
|
||||||
# PR + manual CI run on any branch — the pull_request pipeline is the merge gate.
|
# PR + manual CI run on any branch: the pull_request pipeline is the merge
|
||||||
# push CI is restricted to protected branches (main) so a feature-branch push no
|
# gate (next is protected and the default branch since 2026-08-19).
|
||||||
# longer fires a redundant SECOND pipeline alongside its PR pipeline. This ~halves
|
# Push CI runs on main only. next deliberately runs NO push ci: post-merge
|
||||||
# CI load on the storage-constrained runner with zero loss of gating (branch
|
# verification on next is carried by publish.yml's `verify` step
|
||||||
# protection requires no push/ci status context; main still gets full push CI).
|
# (pnpm verify:release), which mirrors this pipeline's complete mandatory
|
||||||
|
# set step-for-step, enforced by scripts/verify-release.test.mjs. PR CI
|
||||||
|
# tests the PR HEAD tree (refs/pull/N/head, measured 2026-08-19), not a
|
||||||
|
# merge ref, so if next advances before a merge the landed tree differs
|
||||||
|
# from the tested one; publish verify re-runs the full set on the landed
|
||||||
|
# tree (PGlite path). Measured 2026-08-19: the 21 most recent push events
|
||||||
|
# on next each ran exactly one pipeline (publish), zero ci.
|
||||||
|
# Keeping push ci off next also avoids a redundant second full-suite run
|
||||||
|
# per merge on the storage-constrained runner.
|
||||||
- event: [pull_request, manual]
|
- event: [pull_request, manual]
|
||||||
- event: push
|
- event: push
|
||||||
branch: main
|
branch: main
|
||||||
@@ -30,6 +52,19 @@ steps:
|
|||||||
# the baked pnpm store.
|
# the baked pnpm store.
|
||||||
- pnpm install --frozen-lockfile --prefer-offline
|
- pnpm install --frozen-lockfile --prefer-offline
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The steps below (sanitization, upgrade-guard, typecheck, lint, format,
|
||||||
|
# test) are the COMPLETE mandatory verification set. SDLC-D-034 mirrors them
|
||||||
|
# one-for-one in the canonical terminal verification command — root
|
||||||
|
# `pnpm verify:release` (scripts/verify-release.mjs) — which the publish
|
||||||
|
# pipeline (.woodpecker/publish.yml `verify` step) runs before ANY publish
|
||||||
|
# effect. These lines stay direct (not routed through the runner) because the
|
||||||
|
# #1017 test-enumeration guard audits framework tool paths through THIS
|
||||||
|
# surface; scripts/verify-release.test.mjs enforces that the runner's stage
|
||||||
|
# table keeps matching these commands exactly, so the two cannot drift.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Canonical verify:release stage `sanitization`.
|
||||||
# Blocking gate: public framework package must contain no operator-specific
|
# Blocking gate: public framework package must contain no operator-specific
|
||||||
# personal data or private $HOME defaults. Runs early (no node_modules needed).
|
# personal data or private $HOME defaults. Runs early (no node_modules needed).
|
||||||
sanitization:
|
sanitization:
|
||||||
@@ -46,7 +81,30 @@ steps:
|
|||||||
# [0] of the pnpm chain, so severing that chain would silence it together
|
# [0] of the pnpm chain, so severing that chain would silence it together
|
||||||
# with everything it guards; this direct line keeps one instrument running.
|
# with everything it guards; this direct line keeps one instrument running.
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
|
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
|
||||||
|
# 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
|
# Blocking gate (#791): a framework upgrade must never write or delete an
|
||||||
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
|
||||||
# survives a keep-mode reseed byte-identical (with rsync present AND absent —
|
# survives a keep-mode reseed byte-identical (with rsync present AND absent —
|
||||||
@@ -68,6 +126,8 @@ steps:
|
|||||||
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh
|
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
|
- bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
|
||||||
|
|
||||||
|
# Canonical verify:release stage `typecheck` — the same `pnpm typecheck`
|
||||||
|
# invocation (which runs the checkout preflight first, then turbo).
|
||||||
typecheck:
|
typecheck:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
commands:
|
commands:
|
||||||
@@ -78,7 +138,8 @@ steps:
|
|||||||
- sanitization
|
- sanitization
|
||||||
- upgrade-guard
|
- upgrade-guard
|
||||||
|
|
||||||
# lint, format, and test are independent — run in parallel after typecheck
|
# lint, format, and test are independent — run in parallel after typecheck.
|
||||||
|
# Each runs exactly its canonical verify:release stage command.
|
||||||
lint:
|
lint:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
commands:
|
commands:
|
||||||
@@ -95,6 +156,12 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- typecheck
|
- typecheck
|
||||||
|
|
||||||
|
# Canonical verify:release stage `test` — the `pnpm test` line below is the
|
||||||
|
# shared command; everything else in this step is PIPELINE-LEVEL
|
||||||
|
# prerequisite the canonical command expects its caller to provide (SDLC-D-034):
|
||||||
|
# the ci-postgres service + pg_isready wait + db:migrate (postgres path),
|
||||||
|
# `apk add openssl`, and the pinned pi install. None of those can move into
|
||||||
|
# the runner (it must also work locally on the PGlite path with no database).
|
||||||
test:
|
test:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
+72
-7
@@ -1,10 +1,29 @@
|
|||||||
# Build, publish npm packages, and push Docker images
|
# Build, publish npm packages, and push Docker images
|
||||||
# Runs on main for stable publishes and on next for integration-line prereleases/images
|
# Runs on main for stable publishes and on next for integration-line prereleases/images
|
||||||
|
#
|
||||||
|
# SDLC-D-034 publish gate: every publish effect (publish-npm, publish-next-npm,
|
||||||
|
# and every image build/push step) depends DIRECTLY on the `verify` step below.
|
||||||
|
# `verify` (a) asserts the provider's commit identity matches the actual
|
||||||
|
# checkout (CI_COMMIT_SHA == git rev-parse HEAD, fail closed on mismatch or
|
||||||
|
# emptiness) and (b) runs the canonical terminal verification command
|
||||||
|
# (`pnpm verify:release`), which mirrors the PR CI pipeline's complete
|
||||||
|
# mandatory set (sanitization, upgrade-guard, preflight+typecheck, lint,
|
||||||
|
# format:check, test, build) — see scripts/verify-release.mjs. A missing,
|
||||||
|
# failed, skipped, cancelled, or inconclusive verification therefore skips the
|
||||||
|
# dependent publish effects (fail closed). Path-filtered short-circuits may
|
||||||
|
# skip publish EFFECTS (e.g. docs-only merges) but never bypass `verify` for a
|
||||||
|
# publish that does run: `verify` itself carries no path filter.
|
||||||
|
# scripts/verify-release.test.mjs enforces this DAG invariant at checkout time.
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
||||||
# toolchain + warm pnpm store. Kills the second cold install publish pays.
|
# toolchain + warm pnpm store. Kills the second cold install publish pays.
|
||||||
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
|
# PINNED to the immutable lock-tag, not :latest (#1328, brain D27): a mutable
|
||||||
|
# tag resolves per-pod at pull time on the k8s backend and made CI verdicts
|
||||||
|
# non-reproducible (#1324). Byte-identical to :latest at pin time (pushed
|
||||||
|
# atomically by the same kaniko run, main 712c770, 2026-07-26). Bump only via
|
||||||
|
# reviewed PR, per the procedure in .woodpecker/ci.yml's header comment.
|
||||||
|
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:lock-9cb7ffcd8828'
|
||||||
- &enable_pnpm 'corepack enable'
|
- &enable_pnpm 'corepack enable'
|
||||||
# Heavy kaniko image builds (~25 min) — gate them so a merge that only touches
|
# Heavy kaniko image builds (~25 min) — gate them so a merge that only touches
|
||||||
# the npm-only CLI (@mosaicstack/mosaic) or docs does NOT rebuild the platform
|
# the npm-only CLI (@mosaicstack/mosaic) or docs does NOT rebuild the platform
|
||||||
@@ -48,6 +67,45 @@ steps:
|
|||||||
# Resolve from the baked pnpm store instead of a cold network fetch.
|
# Resolve from the baked pnpm store instead of a cold network fetch.
|
||||||
- pnpm install --frozen-lockfile --prefer-offline
|
- pnpm install --frozen-lockfile --prefer-offline
|
||||||
|
|
||||||
|
# SDLC-D-034 exact-commit publish gate. No `when`/path filter on purpose: it
|
||||||
|
# runs for every event this pipeline serves so no publish effect can ever
|
||||||
|
# start without it. Fails closed on commit-identity mismatch (or either SHA
|
||||||
|
# being empty) and on any incomplete verification.
|
||||||
|
verify:
|
||||||
|
image: *node_image
|
||||||
|
commands:
|
||||||
|
- *enable_pnpm
|
||||||
|
# (a) Commit identity: the provider's claimed SHA must equal the actual
|
||||||
|
# checkout HEAD — verification of anything else must never authorize a
|
||||||
|
# publish of this commit.
|
||||||
|
- |
|
||||||
|
if [ -z "$CI_COMMIT_SHA" ]; then
|
||||||
|
echo "[verify] FATAL: CI_COMMIT_SHA is empty — cannot certify commit identity" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
CHECKOUT_SHA="$(git rev-parse HEAD 2>/dev/null || true)"
|
||||||
|
if [ -z "$CHECKOUT_SHA" ]; then
|
||||||
|
echo "[verify] FATAL: git rev-parse HEAD returned nothing — cannot certify commit identity" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$CI_COMMIT_SHA" != "$CHECKOUT_SHA" ]; then
|
||||||
|
echo "[verify] FATAL: provider commit ($CI_COMMIT_SHA) != checkout HEAD ($CHECKOUT_SHA)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "[verify] commit identity confirmed: $CHECKOUT_SHA"
|
||||||
|
# (b) Canonical terminal verification. Caller-provided prerequisites the
|
||||||
|
# runner expects (see .woodpecker/ci.yml comments): bash/rsync for the
|
||||||
|
# guard stages, openssl + the pinned pi binary for the test stage. git is
|
||||||
|
# baked into ci-base but re-asserted here so the identity check above can
|
||||||
|
# never silently depend on a stale baked image. DATABASE_URL is
|
||||||
|
# deliberately NOT set: the canonical command must hold on the PGlite
|
||||||
|
# path too and never sets or requires a database itself.
|
||||||
|
- apk add --no-cache bash rsync openssl git
|
||||||
|
- npm install -g @earendil-works/[email protected]
|
||||||
|
- pnpm verify:release
|
||||||
|
depends_on:
|
||||||
|
- install
|
||||||
|
|
||||||
build:
|
build:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
commands:
|
commands:
|
||||||
@@ -55,6 +113,7 @@ steps:
|
|||||||
- pnpm build
|
- pnpm build
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
|
- verify
|
||||||
|
|
||||||
publish-npm:
|
publish-npm:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
@@ -114,6 +173,7 @@ steps:
|
|||||||
exit 1
|
exit 1
|
||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
|
- verify
|
||||||
|
|
||||||
publish-next-npm:
|
publish-next-npm:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
@@ -192,6 +252,7 @@ steps:
|
|||||||
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
|
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
|
||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
|
- verify
|
||||||
|
|
||||||
# TODO: Uncomment when ready to publish to npmjs.org
|
# TODO: Uncomment when ready to publish to npmjs.org
|
||||||
# publish-npmjs:
|
# publish-npmjs:
|
||||||
@@ -205,6 +266,7 @@ steps:
|
|||||||
# - bash scripts/publish-npmjs.sh
|
# - bash scripts/publish-npmjs.sh
|
||||||
# depends_on:
|
# depends_on:
|
||||||
# - build
|
# - build
|
||||||
|
# - verify
|
||||||
# when:
|
# when:
|
||||||
# - event: [tag]
|
# - event: [tag]
|
||||||
|
|
||||||
@@ -213,9 +275,9 @@ steps:
|
|||||||
when: *image_build_when
|
when: *image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: REGISTRY_USERNAME
|
||||||
REGISTRY_PASS:
|
REGISTRY_PASS:
|
||||||
from_secret: gitea_password
|
from_secret: REGISTRY_PASSWORD
|
||||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||||
@@ -242,15 +304,16 @@ steps:
|
|||||||
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
|
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
|
||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
|
- verify
|
||||||
|
|
||||||
build-appservice:
|
build-appservice:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *main_image_build_when
|
when: *main_image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: REGISTRY_USERNAME
|
||||||
REGISTRY_PASS:
|
REGISTRY_PASS:
|
||||||
from_secret: gitea_password
|
from_secret: REGISTRY_PASSWORD
|
||||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||||
@@ -268,15 +331,16 @@ steps:
|
|||||||
/kaniko/executor --context . --dockerfile docker/appservice.Dockerfile $DESTINATIONS
|
/kaniko/executor --context . --dockerfile docker/appservice.Dockerfile $DESTINATIONS
|
||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
|
- verify
|
||||||
|
|
||||||
build-web:
|
build-web:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *main_image_build_when
|
when: *main_image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: REGISTRY_USERNAME
|
||||||
REGISTRY_PASS:
|
REGISTRY_PASS:
|
||||||
from_secret: gitea_password
|
from_secret: REGISTRY_PASSWORD
|
||||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||||
@@ -294,3 +358,4 @@ steps:
|
|||||||
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
|
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
|
||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
|
- verify
|
||||||
|
|||||||
@@ -81,6 +81,73 @@ pnpm format:check # Prettier check
|
|||||||
pnpm build # Build all packages and applications
|
pnpm build # Build all packages and applications
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Branch Model and Merge Process — `main` and `next` (CANONICAL)
|
||||||
|
|
||||||
|
**Every contribution targets `next` first. No exceptions.** Features, fixes, tests,
|
||||||
|
docs, and policy changes all take the same route; urgency changes queue priority,
|
||||||
|
never the route. Agents never commit to or merge into `main`.
|
||||||
|
|
||||||
|
| Branch | Role | Who merges into it |
|
||||||
|
| ------ | ---------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||||
|
| `next` | Integration trunk — the only PR target for contributions | The designated merge-gate agent, after all gates pass. Never the PR author. |
|
||||||
|
| `main` | Stable/release line — receives promotion merges from `next` only | Jason only (or an agent he explicitly delegates for a named promotion). |
|
||||||
|
|
||||||
|
### Contribution sequencing (in order, no skipping)
|
||||||
|
|
||||||
|
1. **Issue first.** Work is tracked in a Gitea issue before a branch exists. The
|
||||||
|
issue number appears in the branch name and the PR body.
|
||||||
|
2. **Branch from the current `origin/next` head.** Name it
|
||||||
|
`feat/…`, `fix/…`, `docs/…`, or `test/…` with the issue number
|
||||||
|
(e.g. `docs/1214-branch-process`). Record the base SHA in the PR body.
|
||||||
|
3. **Develop with evidence.** Applicable tests accompany the change. Hooks are
|
||||||
|
never bypassed (`--no-verify` is prohibited). Stage explicit paths — never
|
||||||
|
`git add -A`.
|
||||||
|
4. **Open the PR against `next`.** The body states: scope, base SHA,
|
||||||
|
verification commands with results, and any known pre-existing failures on
|
||||||
|
the base — documented, not retried to green and not absorbed silently.
|
||||||
|
5. **CI must be terminal-green on the exact head.** All bounded Woodpecker
|
||||||
|
steps succeed (`verify-terminal-green` contract). Pipelines for fork PRs
|
||||||
|
start `blocked`; a maintainer approves the run — approving CI is not
|
||||||
|
approving the PR.
|
||||||
|
6. **Independent review. Self-merge is prohibited** — for every agent, on every
|
||||||
|
PR, including trivial ones. Where the change touches protected or
|
||||||
|
contract-bearing content, the reviewer verifies the exact head
|
||||||
|
(exact-byte/exact-blob comparison), not a description of it. An `AMEND`
|
||||||
|
verdict returns the PR to its author; the reviewer's gate stays held until
|
||||||
|
a fresh exact head passes.
|
||||||
|
7. **Merge into `next`** happens only after CI green + review pass, pinned to
|
||||||
|
the reviewed head SHA (a post-review push voids the review).
|
||||||
|
8. **Promotion `next` → `main`** is a deliberate, Jason-owned reconciliation
|
||||||
|
merge — not part of any contribution's lifecycle. Contributors are done at
|
||||||
|
step 7.
|
||||||
|
|
||||||
|
### Responsibilities
|
||||||
|
|
||||||
|
- **Contributor** — base pinning, green CI, evidence in the PR body,
|
||||||
|
responding to AMEND verdicts, never merging own work.
|
||||||
|
- **Reviewer / merge gate** — independent verification on the exact head;
|
||||||
|
holds and lifts gates; executes the merge into `next`.
|
||||||
|
- **Orchestrator / adjudicator** — cross-PR sequencing, disposition when PRs
|
||||||
|
collide, conflict adjudication.
|
||||||
|
- **Jason** — `next` → `main` promotions, merge-authority grants, collaborator
|
||||||
|
and token provisioning. Agents cannot grant themselves or each other any of
|
||||||
|
these.
|
||||||
|
|
||||||
|
### Hotfixes and divergence
|
||||||
|
|
||||||
|
- A hotfix follows the same path: branch from `next`, PR to `next`, gates,
|
||||||
|
merge, then an expedited Jason-owned promotion if `main` needs it urgently.
|
||||||
|
Committing the fix to `main` directly is prohibited even under pressure.
|
||||||
|
- **Never land work on `main` that is not on `next`.** This has happened
|
||||||
|
(issue #1152's goal controller reached `main` without reaching `next`) and
|
||||||
|
every later PR paid for it. If it happens anyway: transplant the work onto
|
||||||
|
a `next`-based branch with provenance-preserving commits
|
||||||
|
(`git cherry-pick -x` or explicit SHA references in the messages), PR it
|
||||||
|
through the normal gates, and let promotion re-align `main`. Do not
|
||||||
|
hand-patch `main` to compensate.
|
||||||
|
- Force-pushing a branch you do not own is prohibited; rebasing your own PR
|
||||||
|
branch is fine before review, and voids any review already given.
|
||||||
|
|
||||||
## Database and Local Runtime Safety
|
## Database and Local Runtime Safety
|
||||||
|
|
||||||
- Current local data-layer work uses in-process PGlite; leave `DATABASE_URL` unset.
|
- Current local data-layer work uses in-process PGlite; leave `DATABASE_URL` unset.
|
||||||
|
|||||||
@@ -74,6 +74,14 @@ 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.
|
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
|
### TUI & Gateway
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -138,9 +146,9 @@ mosaic brain tasks
|
|||||||
mosaic brain conversations
|
mosaic brain conversations
|
||||||
|
|
||||||
# Agent forge pipeline
|
# 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 status
|
||||||
mosaic forge resume
|
mosaic forge resume [--simulate] # same fail-closed rule as forge run
|
||||||
mosaic forge personas
|
mosaic forge personas
|
||||||
|
|
||||||
# Structured logging
|
# Structured logging
|
||||||
@@ -331,7 +339,7 @@ The framework is the bash-based standards layer installed to every developer mac
|
|||||||
├── bin/mosaic ← Unified launcher (claude, codex, opencode, pi, yolo)
|
├── bin/mosaic ← Unified launcher (claude, codex, opencode, pi, yolo)
|
||||||
├── guides/ ← E2E delivery, orchestrator protocol, PRD, etc.
|
├── guides/ ← E2E delivery, orchestrator protocol, PRD, etc.
|
||||||
├── runtime/ ← Per-runtime configs (claude/, codex/, opencode/, pi/)
|
├── runtime/ ← Per-runtime configs (claude/, codex/, opencode/, pi/)
|
||||||
├── skills/ ← Universal skills (synced from agent-skills repo)
|
├── skills/ ← Universal skills (shipped with the framework package)
|
||||||
├── tools/ ← Tool suites (orchestrator, git, quality, prdy, etc.)
|
├── tools/ ← Tool suites (orchestrator, git, quality, prdy, etc.)
|
||||||
└── memory/ ← Persistent agent memory (preserved across upgrades)
|
└── memory/ ← Persistent agent memory (preserved across upgrades)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -190,7 +190,13 @@ beforeEach((ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
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;
|
const db = handle.db;
|
||||||
|
|
||||||
// Delete in dependency order (FK constraints)
|
// Delete in dependency order (FK constraints)
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import { type Type } from '@nestjs/common';
|
||||||
|
import { Test, type TestingModule } from '@nestjs/testing';
|
||||||
|
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { AgentService, type AgentSession } from '../agent/agent.service.js';
|
||||||
|
import { ProviderService } from '../agent/provider.service.js';
|
||||||
|
import { AppModule } from '../app.module.js';
|
||||||
|
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
|
||||||
|
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
||||||
|
import { CommandsModule } from '../commands/commands.module.js';
|
||||||
|
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
|
||||||
|
import { PreferencesModule } from '../preferences/preferences.module.js';
|
||||||
|
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||||
|
|
||||||
|
const fakeDb = {
|
||||||
|
$client: { exec: async (): Promise<void> => {} },
|
||||||
|
execute: async (): Promise<{ rows: unknown[] }> => ({ rows: [] }),
|
||||||
|
select: () => ({
|
||||||
|
from: () => ({
|
||||||
|
where: async (): Promise<Array<{ count: number }>> => [{ count: 1 }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
insert: () => ({ values: async (): Promise<void> => {} }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const fakeProviderService = {
|
||||||
|
onModuleInit: async (): Promise<void> => {},
|
||||||
|
onModuleDestroy: (): void => {},
|
||||||
|
getRegistry: () => ({ getAvailable: () => [], getAll: () => [], find: () => undefined }),
|
||||||
|
getDefaultModel: () => undefined,
|
||||||
|
listAvailableModels: () => [],
|
||||||
|
listProviders: () => [],
|
||||||
|
getAdapter: () => undefined,
|
||||||
|
getProvidersHealth: () => [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function compileRealAppGraph(): Promise<TestingModule> {
|
||||||
|
return Test.createTestingModule({ imports: [AppModule] })
|
||||||
|
.overrideProvider('DB_HANDLE')
|
||||||
|
.useValue({ db: fakeDb, close: async (): Promise<void> => {} })
|
||||||
|
.overrideProvider('DB')
|
||||||
|
.useValue(fakeDb)
|
||||||
|
.overrideProvider('STORAGE_ADAPTER')
|
||||||
|
.useValue({
|
||||||
|
name: 'required-security-wiring-test',
|
||||||
|
migrate: async (): Promise<void> => {},
|
||||||
|
close: async (): Promise<void> => {},
|
||||||
|
})
|
||||||
|
.overrideProvider('AUTH')
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider('BRAIN')
|
||||||
|
.useValue({ conversations: {}, agents: {} })
|
||||||
|
.overrideProvider('LOG_SERVICE')
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider('MEMORY')
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider('MEMORY_ADAPTER')
|
||||||
|
.useValue({})
|
||||||
|
.overrideProvider(ProviderService)
|
||||||
|
.useValue(fakeProviderService)
|
||||||
|
.compile();
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerToken(provider: unknown): unknown {
|
||||||
|
return typeof provider === 'function' ? provider : (provider as { provide?: unknown })?.provide;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MaskingConsumer {
|
||||||
|
moduleType: Type<unknown>;
|
||||||
|
token: Type<unknown>;
|
||||||
|
useValue: object;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function compileWithoutProvider(
|
||||||
|
moduleType: Type<unknown>,
|
||||||
|
missingToken: Type<unknown>,
|
||||||
|
maskingConsumer: MaskingConsumer,
|
||||||
|
): Promise<{ error: unknown; moduleRef: TestingModule | undefined }> {
|
||||||
|
const touchedModules = new Set([moduleType, maskingConsumer.moduleType]);
|
||||||
|
const originals = Array.from(touchedModules, (touchedModule: Type<unknown>) => ({
|
||||||
|
moduleType: touchedModule,
|
||||||
|
providers: (Reflect.getMetadata('providers', touchedModule) ?? []) as unknown[],
|
||||||
|
exports: (Reflect.getMetadata('exports', touchedModule) ?? []) as unknown[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (const original of originals) {
|
||||||
|
const providers = original.providers.flatMap((provider: unknown): unknown[] => {
|
||||||
|
const token = providerToken(provider);
|
||||||
|
if (original.moduleType === moduleType && token === missingToken) return [];
|
||||||
|
if (original.moduleType === maskingConsumer.moduleType && token === maskingConsumer.token) {
|
||||||
|
return [{ provide: maskingConsumer.token, useValue: maskingConsumer.useValue }];
|
||||||
|
}
|
||||||
|
return [provider];
|
||||||
|
});
|
||||||
|
const exports = original.exports.filter(
|
||||||
|
(exported: unknown): boolean =>
|
||||||
|
original.moduleType !== moduleType || providerToken(exported) !== missingToken,
|
||||||
|
);
|
||||||
|
Reflect.defineMetadata('providers', providers, original.moduleType);
|
||||||
|
Reflect.defineMetadata('exports', exports, original.moduleType);
|
||||||
|
}
|
||||||
|
|
||||||
|
let moduleRef: TestingModule | undefined;
|
||||||
|
let error: unknown;
|
||||||
|
try {
|
||||||
|
moduleRef = await compileRealAppGraph();
|
||||||
|
} catch (caught: unknown) {
|
||||||
|
error = caught;
|
||||||
|
} finally {
|
||||||
|
for (const original of originals) {
|
||||||
|
Reflect.defineMetadata('providers', original.providers, original.moduleType);
|
||||||
|
Reflect.defineMetadata('exports', original.exports, original.moduleType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { error, moduleRef };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeIfCompiled(moduleRef: TestingModule | undefined): Promise<void> {
|
||||||
|
if (moduleRef) await moduleRef.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('required security wiring — real AppModule startup refusal', () => {
|
||||||
|
it('FL-01 positive control: the real graph compiles when CommandAuthorizationService is bound', async () => {
|
||||||
|
const moduleRef = await compileRealAppGraph();
|
||||||
|
try {
|
||||||
|
expect(moduleRef.get(CommandAuthorizationService, { strict: false })).toBeInstanceOf(
|
||||||
|
CommandAuthorizationService,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await moduleRef.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FL-01 negative control: absence read as permission is refused at module compilation', async () => {
|
||||||
|
const { error, moduleRef } = await compileWithoutProvider(
|
||||||
|
CommandsModule,
|
||||||
|
CommandAuthorizationService,
|
||||||
|
{
|
||||||
|
moduleType: CommandsModule,
|
||||||
|
token: CommandRuntimeApprovalVerifier,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await closeIfCompiled(moduleRef);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
error,
|
||||||
|
'absence read as permission: AppModule compilation accepted a missing CommandAuthorizationService binding',
|
||||||
|
).toBeInstanceOf(Error);
|
||||||
|
if (!(error instanceof Error)) return;
|
||||||
|
expect(error.message).toContain('CommandExecutorService');
|
||||||
|
expect(error.message).toContain('CommandAuthorizationService');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FL-11 positive control: the real graph compiles when SystemOverrideService is bound', async () => {
|
||||||
|
const moduleRef = await compileRealAppGraph();
|
||||||
|
try {
|
||||||
|
expect(moduleRef.get(SystemOverrideService, { strict: false })).toBeInstanceOf(
|
||||||
|
SystemOverrideService,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await moduleRef.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FL-11 negative control: absence read as permission is refused at module compilation', async () => {
|
||||||
|
const { error, moduleRef } = await compileWithoutProvider(
|
||||||
|
PreferencesModule,
|
||||||
|
SystemOverrideService,
|
||||||
|
{
|
||||||
|
moduleType: CommandsModule,
|
||||||
|
token: CommandExecutorService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await closeIfCompiled(moduleRef);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
error,
|
||||||
|
'absence read as permission: AppModule compilation accepted a missing SystemOverrideService binding',
|
||||||
|
).toBeInstanceOf(Error);
|
||||||
|
if (!(error instanceof Error)) return;
|
||||||
|
expect(error.message).toContain('AgentService');
|
||||||
|
expect(error.message).toContain('SystemOverrideService');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const actorScope = { userId: 'security-user', tenantId: 'security-tenant' };
|
||||||
|
const conversationId = 'security-conversation';
|
||||||
|
|
||||||
|
function directExecutorWithoutAuthorization(systemOverrideSet: ReturnType<typeof vi.fn>) {
|
||||||
|
const registry = {
|
||||||
|
getManifest: vi.fn(() => ({
|
||||||
|
version: 1,
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
name: 'system',
|
||||||
|
aliases: [],
|
||||||
|
description: 'Set instruction authority',
|
||||||
|
scope: 'agent' as const,
|
||||||
|
execution: 'socket' as const,
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skills: [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
return new CommandExecutorService(
|
||||||
|
registry as never,
|
||||||
|
{ getSession: vi.fn() } as never,
|
||||||
|
{ set: systemOverrideSet, clear: vi.fn() } as never,
|
||||||
|
{ collect: vi.fn() } as never,
|
||||||
|
null,
|
||||||
|
{ agents: {} } as never,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
{ getServerStatuses: vi.fn(() => []), getToolDefinitions: vi.fn(() => []) } as never,
|
||||||
|
undefined as never,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function directAgentWithoutSystemOverride(piPrompt: ReturnType<typeof vi.fn>): {
|
||||||
|
service: AgentService;
|
||||||
|
session: AgentSession;
|
||||||
|
} {
|
||||||
|
const service = new AgentService(
|
||||||
|
{
|
||||||
|
getDefaultModel: vi.fn(() => null),
|
||||||
|
getRegistry: vi.fn(() => ({})),
|
||||||
|
findModel: vi.fn(),
|
||||||
|
listAvailableModels: vi.fn(() => []),
|
||||||
|
} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{ available: false } as never,
|
||||||
|
{} as never,
|
||||||
|
{ getToolDefinitions: vi.fn(() => []) } as never,
|
||||||
|
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
|
||||||
|
undefined as never,
|
||||||
|
null,
|
||||||
|
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const session = {
|
||||||
|
id: conversationId,
|
||||||
|
provider: 'test-provider',
|
||||||
|
modelId: 'test-model',
|
||||||
|
piSession: { prompt: piPrompt },
|
||||||
|
listeners: new Set(),
|
||||||
|
unsubscribe: vi.fn(),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
promptCount: 0,
|
||||||
|
channels: new Set(),
|
||||||
|
skillPromptAdditions: [],
|
||||||
|
sandboxDir: process.cwd(),
|
||||||
|
allowedTools: null,
|
||||||
|
userId: actorScope.userId,
|
||||||
|
tenantId: actorScope.tenantId,
|
||||||
|
metrics: {
|
||||||
|
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
modelSwitches: 0,
|
||||||
|
messageCount: 0,
|
||||||
|
lastActivityAt: new Date(0).toISOString(),
|
||||||
|
},
|
||||||
|
} as unknown as AgentSession;
|
||||||
|
const internals = service as unknown as { sessions: Map<string, AgentSession> };
|
||||||
|
internals.sessions.set(conversationId, session);
|
||||||
|
return { service, session };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('required security wiring — malformed direct absence has zero effects', () => {
|
||||||
|
it('FL-01 refuses command execution before any command effect when authorization is absent', async () => {
|
||||||
|
const systemOverrideSet = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const executor = directExecutorWithoutAuthorization(systemOverrideSet);
|
||||||
|
const payload: SlashCommandPayload = {
|
||||||
|
command: 'system',
|
||||||
|
args: 'authority that must not be stored',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
let error: unknown;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await executor.execute(payload, actorScope);
|
||||||
|
} catch (caught: unknown) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect
|
||||||
|
.soft(
|
||||||
|
error,
|
||||||
|
'absence read as permission: direct executor accepted missing command authorization',
|
||||||
|
)
|
||||||
|
.toBeInstanceOf(Error);
|
||||||
|
expect
|
||||||
|
.soft(
|
||||||
|
systemOverrideSet,
|
||||||
|
'absence read as permission: command effect occurred without command authorization',
|
||||||
|
)
|
||||||
|
.not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FL-11 refuses prompt execution before any provider or session effect when system override authority is absent', async () => {
|
||||||
|
const piPrompt = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const { service, session } = directAgentWithoutSystemOverride(piPrompt);
|
||||||
|
let error: unknown;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.prompt(conversationId, 'must not reach provider', actorScope);
|
||||||
|
} catch (caught: unknown) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect
|
||||||
|
.soft(
|
||||||
|
error,
|
||||||
|
'absence read as permission: direct session accepted missing system override authority',
|
||||||
|
)
|
||||||
|
.toBeInstanceOf(Error);
|
||||||
|
expect
|
||||||
|
.soft(
|
||||||
|
piPrompt,
|
||||||
|
'absence read as permission: provider prompt occurred without system override authority',
|
||||||
|
)
|
||||||
|
.not.toHaveBeenCalled();
|
||||||
|
expect
|
||||||
|
.soft(
|
||||||
|
session.promptCount,
|
||||||
|
'absence read as permission: session state changed without system override authority',
|
||||||
|
)
|
||||||
|
.toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,7 +26,7 @@ function makeService(operatorMemory: unknown = null): AgentService {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{ getToolDefinitions: vi.fn(() => []) } as never,
|
{ getToolDefinitions: vi.fn(() => []) } as never,
|
||||||
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
|
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
|
||||||
null,
|
{ get: vi.fn().mockResolvedValue(null), renew: vi.fn().mockResolvedValue(undefined) } as never,
|
||||||
null,
|
null,
|
||||||
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
||||||
operatorMemory as never,
|
operatorMemory as never,
|
||||||
|
|||||||
@@ -132,9 +132,8 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
@Inject(CoordService) private readonly coordService: CoordService,
|
@Inject(CoordService) private readonly coordService: CoordService,
|
||||||
@Inject(McpClientService) private readonly mcpClientService: McpClientService,
|
@Inject(McpClientService) private readonly mcpClientService: McpClientService,
|
||||||
@Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService,
|
@Inject(SkillLoaderService) private readonly skillLoaderService: SkillLoaderService,
|
||||||
@Optional()
|
|
||||||
@Inject(SystemOverrideService)
|
@Inject(SystemOverrideService)
|
||||||
private readonly systemOverride: SystemOverrideService | null,
|
private readonly systemOverride: SystemOverrideService,
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(PreferencesService)
|
@Inject(PreferencesService)
|
||||||
private readonly preferencesService: PreferencesService | null,
|
private readonly preferencesService: PreferencesService | null,
|
||||||
@@ -709,23 +708,22 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
throw new Error(`No agent session found: ${sessionId}`);
|
throw new Error(`No agent session found: ${sessionId}`);
|
||||||
}
|
}
|
||||||
this.assertSessionScope(session, scope);
|
this.assertSessionScope(session, scope);
|
||||||
session.promptCount += 1;
|
|
||||||
|
|
||||||
// Channel attachments are untrusted URI references. Preserve exact,
|
// Channel attachments are untrusted URI references. Preserve exact,
|
||||||
// authenticated metadata for the agent without treating it as authority.
|
// authenticated metadata for the agent without treating it as authority.
|
||||||
const attachmentContext = this.attachmentContext(attachments);
|
const attachmentContext = this.attachmentContext(attachments);
|
||||||
|
|
||||||
// Prepend session-scoped system override if present (renew TTL on each turn)
|
// Prepend session-scoped system override if present (renew TTL on each turn).
|
||||||
|
// Required instruction-authority wiring is consulted before session/provider effects.
|
||||||
let effectiveMessage = `${message}${attachmentContext}`;
|
let effectiveMessage = `${message}${attachmentContext}`;
|
||||||
if (this.systemOverride) {
|
const override = await this.systemOverride.get(sessionId, scope);
|
||||||
const override = await this.systemOverride.get(sessionId, scope);
|
if (override) {
|
||||||
if (override) {
|
effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`;
|
||||||
effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`;
|
await this.systemOverride.renew(sessionId, scope);
|
||||||
await this.systemOverride.renew(sessionId, scope);
|
this.logger.debug(`Applied system override for session ${sessionId}`);
|
||||||
this.logger.debug(`Applied system override for session ${sessionId}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
session.promptCount += 1;
|
||||||
try {
|
try {
|
||||||
await session.piSession.prompt(effectiveMessage);
|
await session.piSession.prompt(effectiveMessage);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ const mockMcpClient = {
|
|||||||
getToolDefinitions: vi.fn(() => []),
|
getToolDefinitions: vi.fn(() => []),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const allowAuthorization = {
|
||||||
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||||
|
};
|
||||||
|
|
||||||
function buildService(
|
function buildService(
|
||||||
redis: typeof mockRedis | null = mockRedis,
|
redis: typeof mockRedis | null = mockRedis,
|
||||||
mcpClient: {
|
mcpClient: {
|
||||||
@@ -98,6 +102,7 @@ function buildService(
|
|||||||
null,
|
null,
|
||||||
mockChatGateway as never,
|
mockChatGateway as never,
|
||||||
mcpClient as never,
|
mcpClient as never,
|
||||||
|
allowAuthorization as never,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,8 @@ export class CommandExecutorService {
|
|||||||
@Inject(forwardRef(() => ChatGateway))
|
@Inject(forwardRef(() => ChatGateway))
|
||||||
private readonly chatGateway: ChatGateway | null,
|
private readonly chatGateway: ChatGateway | null,
|
||||||
@Inject(McpClientService) private readonly mcpClient: McpClientService,
|
@Inject(McpClientService) private readonly mcpClient: McpClientService,
|
||||||
@Optional()
|
|
||||||
@Inject(CommandAuthorizationService)
|
@Inject(CommandAuthorizationService)
|
||||||
private readonly authorization: CommandAuthorizationService | null = null,
|
private readonly authorization: CommandAuthorizationService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(
|
async execute(
|
||||||
@@ -57,13 +56,13 @@ export class CommandExecutorService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const authorization = await this.authorization?.authorize(
|
const authorization = await this.authorization.authorize(
|
||||||
def,
|
def,
|
||||||
payload,
|
payload,
|
||||||
userId,
|
userId,
|
||||||
payload.approvalId,
|
payload.approvalId,
|
||||||
);
|
);
|
||||||
if (authorization && !authorization.allowed) {
|
if (!authorization.allowed) {
|
||||||
return { command, conversationId, success: false, message: authorization.reason };
|
return { command, conversationId, success: false, message: authorization.reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +170,7 @@ export class CommandExecutorService {
|
|||||||
const def = this.registry
|
const def = this.registry
|
||||||
.getManifest()
|
.getManifest()
|
||||||
.commands.find((command) => command.name === payload.command);
|
.commands.find((command) => command.name === payload.command);
|
||||||
if (!def || !this.authorization) return null;
|
if (!def) return null;
|
||||||
return this.authorization.createApproval(def, payload, scope.userId);
|
return this.authorization.createApproval(def, payload, scope.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ const mockMcpClient = {
|
|||||||
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
reconnectServer: vi.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const allowAuthorization = {
|
||||||
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildRegistry(): CommandRegistryService {
|
function buildRegistry(): CommandRegistryService {
|
||||||
@@ -74,6 +78,7 @@ function buildExecutor(registry: CommandRegistryService): CommandExecutorService
|
|||||||
null, // reloadService (optional)
|
null, // reloadService (optional)
|
||||||
null, // chatGateway (optional)
|
null, // chatGateway (optional)
|
||||||
mockMcpClient as never,
|
mockMcpClient as never,
|
||||||
|
allowAuthorization as never,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -245,9 +245,21 @@ describe('EnrollmentService.createToken', () => {
|
|||||||
const after = Date.now();
|
const after = Date.now();
|
||||||
|
|
||||||
const expiresMs = new Date(result.expiresAt).getTime();
|
const expiresMs = new Date(result.expiresAt).getTime();
|
||||||
// Should be at most 900s from now
|
|
||||||
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + 100);
|
// 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);
|
||||||
expect(expiresMs - after).toBeGreaterThanOrEqual(0);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ describe('ReloadService — /reload command sanitizes plugin errors', () => {
|
|||||||
reloadService,
|
reloadService,
|
||||||
mockChatGateway as never,
|
mockChatGateway as never,
|
||||||
mockMcpClient as never,
|
mockMcpClient as never,
|
||||||
|
{ authorize: vi.fn().mockResolvedValue({ allowed: true }) } as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
const payload: SlashCommandPayload = { command: 'reload', conversationId: 'conv-1' };
|
const payload: SlashCommandPayload = { command: 'reload', conversationId: 'conv-1' };
|
||||||
|
|||||||
@@ -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();
|
document.body.replaceChildren();
|
||||||
root = null;
|
root = null;
|
||||||
apiMock.mockReset();
|
apiMock.mockReset();
|
||||||
|
sessionStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function renderProjectDetailPage(): Promise<ReturnType<typeof createMemoryRouter>> {
|
async function renderProjectDetailPage(): Promise<ReturnType<typeof createMemoryRouter>> {
|
||||||
@@ -64,21 +65,49 @@ function clickButtonByText(text: string): void {
|
|||||||
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
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', () => {
|
describe('ProjectDetailPage', () => {
|
||||||
it('loads the project, tasks, missions, and optional PRD content for the active project', async () => {
|
it('loads the project, tasks, missions, and optional PRD content for the active project', async () => {
|
||||||
apiMock
|
mockHealthyLoad();
|
||||||
.mockResolvedValueOnce(projectFixtures[0])
|
|
||||||
.mockResolvedValueOnce(missionFixtures)
|
|
||||||
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
|
||||||
|
|
||||||
await renderProjectDetailPage();
|
await renderProjectDetailPage();
|
||||||
|
|
||||||
expect(apiMock.mock.calls).toEqual([
|
expect(apiMock.mock.calls.map((call) => call[0])).toEqual([
|
||||||
['/api/projects/project-1'],
|
'/api/projects/project-1',
|
||||||
['/api/missions'],
|
'/api/missions',
|
||||||
['/api/tasks?projectId=project-1'],
|
'/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('Mosaic Stack');
|
||||||
expect(container.textContent).toContain('Route /projects/:id');
|
expect(container.textContent).toContain('Route /projects/:id');
|
||||||
expect(container.textContent).toContain('Tasks');
|
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 () => {
|
it('opens and closes the existing read-only task modal from the tasks tab', async () => {
|
||||||
apiMock
|
mockHealthyLoad();
|
||||||
.mockResolvedValueOnce(projectFixtures[0])
|
|
||||||
.mockResolvedValueOnce(missionFixtures)
|
|
||||||
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
|
||||||
|
|
||||||
await renderProjectDetailPage();
|
await renderProjectDetailPage();
|
||||||
|
|
||||||
@@ -134,35 +160,153 @@ describe('ProjectDetailPage', () => {
|
|||||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the project with an empty missions tab when the missions request fails', async () => {
|
it('shows verified completion verdicts when the task collection is current', async () => {
|
||||||
apiMock
|
mockHealthyLoad();
|
||||||
.mockResolvedValueOnce(projectFixtures[0])
|
|
||||||
.mockRejectedValueOnce(new Error('Missions request failed'))
|
|
||||||
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
|
||||||
|
|
||||||
await renderProjectDetailPage();
|
await renderProjectDetailPage();
|
||||||
|
|
||||||
expect(container.textContent).toContain('Mosaic Stack');
|
const doneCard = [...container.querySelectorAll('div')].find(
|
||||||
expect(container.querySelector('[role="alert"]')).toBeNull();
|
(candidate) => candidate.textContent === 'Done1',
|
||||||
|
);
|
||||||
await act(async () => {
|
expect(doneCard).toBeTruthy();
|
||||||
clickButtonByText('Missions (0)');
|
const inProgressCard = [...container.querySelectorAll('div')].find(
|
||||||
});
|
(candidate) => candidate.textContent === 'In Progress1',
|
||||||
|
);
|
||||||
expect(container.textContent).toContain('No missions for this project');
|
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
|
apiMock
|
||||||
.mockRejectedValueOnce(new Error('Project request failed'))
|
.mockRejectedValueOnce(new Error('Project request failed'))
|
||||||
.mockResolvedValueOnce(missionFixtures)
|
.mockResolvedValueOnce(missionFixtures)
|
||||||
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
.mockResolvedValueOnce(projectOneTasks);
|
||||||
|
|
||||||
const router = await renderProjectDetailPage();
|
const router = await renderProjectDetailPage();
|
||||||
|
|
||||||
const alert = container.querySelector('[role="alert"]');
|
const alert = container.querySelector('[role="alert"]');
|
||||||
expect(alert).toBeTruthy();
|
expect(alert).toBeTruthy();
|
||||||
expect(alert?.textContent).toContain('Project request failed');
|
expect(alert?.textContent).toContain('Project request failed');
|
||||||
|
expect(alert?.textContent).toContain('not an empty result');
|
||||||
expect(container.textContent).not.toContain('Mosaic Stack');
|
expect(container.textContent).not.toContain('Mosaic Stack');
|
||||||
|
|
||||||
await act(async () => {
|
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 { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { MissionTimeline } from '@/components/projects/mission-timeline';
|
import { MissionTimeline } from '@/components/projects/mission-timeline';
|
||||||
import { PrdViewer } from '@/components/projects/prd-viewer';
|
import { PrdViewer } from '@/components/projects/prd-viewer';
|
||||||
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
|
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
|
||||||
import { TaskListView } from '@/components/tasks/task-list-view';
|
import { TaskListView } from '@/components/tasks/task-list-view';
|
||||||
import { TaskStatusSummary } from '@/components/tasks/task-status-summary';
|
import { TaskStatusSummary } from '@/components/tasks/task-status-summary';
|
||||||
|
import {
|
||||||
|
PartialDataNotice,
|
||||||
|
StaleDataNotice,
|
||||||
|
UnavailableDataNotice,
|
||||||
|
} from '@/components/freshness/freshness-notices';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
import type { Mission, Project, Task, TaskStatus } from '@/lib/types';
|
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';
|
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 {
|
export function ProjectDetailPage(): ReactElement {
|
||||||
const { id = '' } = useParams();
|
const { id = '' } = useParams();
|
||||||
|
return <ProjectDetail id={id} key={id} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProjectDetail({ id }: { id: string }): ReactElement {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
const enabled = id.length > 0;
|
||||||
const [missions, setMissions] = useState<Mission[]>([]);
|
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
// Primary collection gates the surface; missions and tasks are secondaries
|
||||||
const [loading, setLoading] = useState(true);
|
// whose failures degrade the surface to `partial` instead of rendering
|
||||||
const [error, setError] = useState<string | null>(null);
|
// 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 [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||||
const [taskFilter, setTaskFilter] = useState<TaskStatus | 'all'>('all');
|
const [taskFilter, setTaskFilter] = useState<TaskStatus | 'all'>('all');
|
||||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const surface = combineFreshness(project.freshness, [missions.freshness, tasks.freshness]);
|
||||||
if (!id) {
|
const tasksVerified = tasks.freshness === 'current';
|
||||||
setError('Project id is missing.');
|
const projectMissions = missions.data?.filter((mission) => mission.projectId === id) ?? null;
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
const retryAll = (): void => {
|
||||||
setLoading(true);
|
void Promise.all([project.revalidate(), missions.revalidate(), tasks.revalidate()]);
|
||||||
setError(null);
|
};
|
||||||
|
|
||||||
void Promise.all([
|
if (!enabled) {
|
||||||
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) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
<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">
|
<header className="mb-6 border-b px-1 pb-3">
|
||||||
<h1 className="text-2xl font-semibold">Project</h1>
|
<h1 className="text-2xl font-semibold">Project</h1>
|
||||||
</header>
|
</header>
|
||||||
<div role="alert" className="rounded-lg border border-error/40 px-4 py-3 text-sm">
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="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 =
|
const filteredTasks =
|
||||||
taskFilter === 'all' ? tasks : tasks.filter((task) => task.status === taskFilter);
|
projectTasks === null
|
||||||
const prdContent = getPrdContent(project);
|
? []
|
||||||
|
: 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 }> = [
|
const tabs: Array<{ id: Tab; label: string }> = [
|
||||||
{ id: 'overview', label: 'Overview' },
|
{ 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' }] : []),
|
...(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 (
|
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">
|
<header className="mb-6 border-b px-1 pb-3">
|
||||||
<nav className="mb-4 flex items-center gap-2 text-sm text-text-muted">
|
<nav className="mb-4 flex items-center gap-2 text-sm text-text-muted">
|
||||||
<button
|
<button
|
||||||
@@ -152,49 +220,64 @@ export function ProjectDetailPage(): ReactElement {
|
|||||||
Projects
|
Projects
|
||||||
</button>
|
</button>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-text-primary">{project.name}</span>
|
<span className="text-text-primary">{project.data.name}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3">
|
<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
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-full px-2 py-0.5 text-xs',
|
'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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{project.description ? (
|
{project.data.description ? (
|
||||||
<p className="mt-1 text-sm text-text-muted">{project.description}</p>
|
<p className="mt-1 text-sm text-text-muted">{project.data.description}</p>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="mt-2 text-xs text-text-muted">
|
<p className="mt-2 text-xs text-text-muted">
|
||||||
Created {new Date(project.createdAt).toLocaleDateString()} · Updated{' '}
|
Created {new Date(project.data.createdAt).toLocaleDateString()} · Updated{' '}
|
||||||
{new Date(project.updatedAt).toLocaleDateString()}
|
{new Date(project.data.updatedAt).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
<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
|
<StatCard
|
||||||
label="Done"
|
label="Done"
|
||||||
value={String(tasks.filter((task) => task.status === 'done').length)}
|
value={verdictValue(tasksVerified, String(doneCount))}
|
||||||
valueClass="text-success"
|
valueClass={tasksVerified ? 'text-success' : undefined}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="In Progress"
|
label="In Progress"
|
||||||
value={String(tasks.filter((task) => task.status === 'in-progress').length)}
|
value={verdictValue(tasksVerified, String(inProgressCount))}
|
||||||
valueClass="text-blue-400"
|
valueClass={tasksVerified ? 'text-blue-400' : undefined}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Blocked"
|
label="Blocked"
|
||||||
value={String(tasks.filter((task) => task.status === 'blocked').length)}
|
value={verdictValue(tasksVerified, String(blockedCount))}
|
||||||
valueClass={tasks.some((task) => task.status === 'blocked') ? 'text-error' : undefined}
|
valueClass={tasksVerified && blockedCount > 0 ? 'text-error' : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -211,23 +294,43 @@ export function ProjectDetailPage(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === 'overview' ? (
|
{activeTab === 'overview' ? (
|
||||||
<OverviewTab project={project} missions={missions} tasks={tasks} />
|
<OverviewTab project={project.data} missions={projectMissions} tasks={projectTasks} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{activeTab === 'tasks' ? (
|
{activeTab === 'tasks' ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-4">
|
{projectTasks === null ? (
|
||||||
<TaskStatusSummary
|
<UnavailableDataNotice
|
||||||
tasks={tasks}
|
title="Tasks"
|
||||||
activeFilter={taskFilter}
|
detail={describeFailure(tasks.failure)}
|
||||||
onFilterChange={setTaskFilter}
|
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>
|
</div>
|
||||||
) : null}
|
) : 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 ? (
|
{activeTab === 'prd' && prdContent ? (
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6">
|
<div className="rounded-lg border border-surface-border bg-surface-card p-6">
|
||||||
@@ -248,18 +351,26 @@ function OverviewTab({
|
|||||||
tasks,
|
tasks,
|
||||||
}: {
|
}: {
|
||||||
project: Project;
|
project: Project;
|
||||||
missions: Mission[];
|
missions: Mission[] | null;
|
||||||
tasks: Task[];
|
tasks: Task[] | null;
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const recentTasks = [...tasks]
|
const recentTasks =
|
||||||
.sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime())
|
tasks === null
|
||||||
.slice(0, 5);
|
? null
|
||||||
|
: [...tasks]
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(),
|
||||||
|
)
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Recent Tasks</h2>
|
<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">
|
<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>
|
<p className="text-sm text-text-muted">No tasks yet</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -287,7 +398,9 @@ function OverviewTab({
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Missions</h2>
|
<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">
|
<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>
|
<p className="text-sm text-text-muted">No missions yet</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ afterEach(async () => {
|
|||||||
document.body.replaceChildren();
|
document.body.replaceChildren();
|
||||||
root = null;
|
root = null;
|
||||||
apiMock.mockReset();
|
apiMock.mockReset();
|
||||||
|
sessionStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function renderProjectsPage(): Promise<ReturnType<typeof createMemoryRouter>> {
|
async function renderProjectsPage(): Promise<ReturnType<typeof createMemoryRouter>> {
|
||||||
@@ -71,6 +72,22 @@ async function renderProjectsPage(): Promise<ReturnType<typeof createMemoryRoute
|
|||||||
return router;
|
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', () => {
|
describe('ProjectsPage', () => {
|
||||||
it('shows a visible loading state while the project request is in flight', async () => {
|
it('shows a visible loading state while the project request is in flight', async () => {
|
||||||
const deferred = createDeferred<typeof projectFixtures>();
|
const deferred = createDeferred<typeof projectFixtures>();
|
||||||
@@ -91,7 +108,7 @@ describe('ProjectsPage', () => {
|
|||||||
|
|
||||||
const router = await renderProjectsPage();
|
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('Mosaic Stack');
|
||||||
expect(container.textContent).toContain('Agent Runtime');
|
expect(container.textContent).toContain('Agent Runtime');
|
||||||
|
|
||||||
@@ -108,7 +125,7 @@ describe('ProjectsPage', () => {
|
|||||||
expect(container.textContent).toContain('Project detail target');
|
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([]);
|
apiMock.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
await renderProjectsPage();
|
await renderProjectsPage();
|
||||||
@@ -117,9 +134,12 @@ describe('ProjectsPage', () => {
|
|||||||
expect(container.textContent).toContain(
|
expect(container.textContent).toContain(
|
||||||
'Projects will appear here when created via the gateway API',
|
'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'));
|
apiMock.mockRejectedValueOnce(new Error('Projects are unavailable'));
|
||||||
|
|
||||||
await renderProjectsPage();
|
await renderProjectsPage();
|
||||||
@@ -127,5 +147,51 @@ describe('ProjectsPage', () => {
|
|||||||
const alert = container.querySelector('[role="alert"]');
|
const alert = container.querySelector('[role="alert"]');
|
||||||
expect(alert).toBeTruthy();
|
expect(alert).toBeTruthy();
|
||||||
expect(alert?.textContent).toContain('Projects are unavailable');
|
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 { useNavigate } from 'react-router-dom';
|
||||||
import { ProjectCard } from '@/components/projects/project-card';
|
import { ProjectCard } from '@/components/projects/project-card';
|
||||||
|
import { StaleDataNotice, UnavailableDataNotice } from '@/components/freshness/freshness-notices';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { Project } from '@/lib/types';
|
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 {
|
export function ProjectsPage(): ReactElement {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [projects, setProjects] = useState<Project[]>([]);
|
const projects = useFreshCollection<Project[]>({
|
||||||
const [loading, setLoading] = useState(true);
|
source: 'gateway:/api/projects',
|
||||||
const [error, setError] = useState<string | null>(null);
|
fetcher: (signal) => api<unknown>('/api/projects', { signal }),
|
||||||
|
validate: validateProjectCollection,
|
||||||
useEffect(() => {
|
// Projects carry workspace identity (userId) that is only knowable from
|
||||||
let cancelled = false;
|
// the payload itself, so a restored entry cannot be scope-checked before
|
||||||
|
// display. Conservative choice: no last-known restore for this surface;
|
||||||
void api<Project[]>('/api/projects')
|
// cross-workspace switching is still invalidated at verification time.
|
||||||
.then((response) => {
|
});
|
||||||
if (cancelled) return;
|
const retry = (): void => {
|
||||||
setProjects(response);
|
void projects.revalidate();
|
||||||
})
|
};
|
||||||
.catch((caught: unknown) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setError(getErrorMessage(caught, 'Failed to load projects.'));
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (cancelled) return;
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
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">
|
<header className="mb-6 border-b px-1 pb-3">
|
||||||
<h1 className="text-2xl font-semibold">Projects</h1>
|
<h1 className="text-2xl font-semibold">Projects</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{error ? (
|
{projects.freshness === 'stale' && projects.snapshot ? (
|
||||||
<div role="alert" className="mb-6 rounded-lg border border-error/40 px-4 py-3 text-sm">
|
<div className="mb-6">
|
||||||
{error}
|
<StaleDataNotice label={projects.snapshot} onRetry={retry} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{loading ? (
|
{projects.freshness === 'unknown' ? (
|
||||||
<p className="py-8 text-center text-sm text-text-muted">Loading projects...</p>
|
<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">
|
<div className="py-12 text-center">
|
||||||
<h2 className="text-lg font-medium text-text-secondary">No projects yet</h2>
|
<h2 className="text-lg font-medium text-text-secondary">No projects yet</h2>
|
||||||
<p className="mt-1 text-sm text-text-muted">
|
<p className="mt-1 text-sm text-text-muted">
|
||||||
@@ -56,7 +54,7 @@ export function ProjectsPage(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{projects.map((project) => (
|
{(projects.data ?? []).map((project) => (
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
key={project.id}
|
key={project.id}
|
||||||
project={project}
|
project={project}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { createRoot, type Root } from 'react-dom/client';
|
|||||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
import { taskFixtures } from './page-fixtures';
|
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(() => ({
|
const { apiMock } = vi.hoisted(() => ({
|
||||||
apiMock: vi.fn(),
|
apiMock: vi.fn(),
|
||||||
@@ -48,6 +51,7 @@ afterEach(async () => {
|
|||||||
document.body.replaceChildren();
|
document.body.replaceChildren();
|
||||||
root = null;
|
root = null;
|
||||||
apiMock.mockReset();
|
apiMock.mockReset();
|
||||||
|
sessionStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function renderTasksPage(): Promise<void> {
|
async function renderTasksPage(): Promise<void> {
|
||||||
@@ -72,6 +76,13 @@ function clickButtonByText(text: string): void {
|
|||||||
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
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', () => {
|
describe('TasksPage', () => {
|
||||||
it('shows a visible loading state before the tasks request settles', async () => {
|
it('shows a visible loading state before the tasks request settles', async () => {
|
||||||
const deferred = createDeferred<typeof taskFixtures>();
|
const deferred = createDeferred<typeof taskFixtures>();
|
||||||
@@ -132,7 +143,7 @@ describe('TasksPage', () => {
|
|||||||
expect(container.textContent).toContain('Wire list and kanban modal interactions');
|
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'));
|
apiMock.mockRejectedValueOnce(new Error('Tasks request failed'));
|
||||||
|
|
||||||
await renderTasksPage();
|
await renderTasksPage();
|
||||||
@@ -140,5 +151,80 @@ describe('TasksPage', () => {
|
|||||||
const alert = container.querySelector('[role="alert"]');
|
const alert = container.querySelector('[role="alert"]');
|
||||||
expect(alert).toBeTruthy();
|
expect(alert).toBeTruthy();
|
||||||
expect(alert?.textContent).toContain('Tasks request failed');
|
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 { KanbanBoard } from '@/components/tasks/kanban-board';
|
||||||
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
|
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
|
||||||
import { TaskListView } from '@/components/tasks/task-list-view';
|
import { TaskListView } from '@/components/tasks/task-list-view';
|
||||||
|
import { StaleDataNotice, UnavailableDataNotice } from '@/components/freshness/freshness-notices';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
import type { Task } from '@/lib/types';
|
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';
|
type ViewMode = 'list' | 'kanban';
|
||||||
|
|
||||||
export function TasksPage(): ReactElement {
|
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 [view, setView] = useState<ViewMode>('kanban');
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const retry = (): void => {
|
||||||
let cancelled = false;
|
void tasks.revalidate();
|
||||||
|
};
|
||||||
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;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
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">
|
<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>
|
<h1 className="text-2xl font-semibold">Tasks</h1>
|
||||||
<div className="flex rounded-lg border border-surface-border">
|
<div className="flex rounded-lg border border-surface-border">
|
||||||
@@ -70,18 +57,24 @@ export function TasksPage(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{error ? (
|
{tasks.freshness === 'stale' && tasks.snapshot ? (
|
||||||
<div role="alert" className="mb-6 rounded-lg border border-error/40 px-4 py-3 text-sm">
|
<div className="mb-6">
|
||||||
{error}
|
<StaleDataNotice label={tasks.snapshot} onRetry={retry} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{loading ? (
|
{tasks.freshness === 'unknown' ? (
|
||||||
<p className="py-8 text-center text-sm text-text-muted">Loading tasks...</p>
|
<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' ? (
|
) : 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 ? (
|
{selectedTask ? (
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Administrator Guide
|
# Administrator Guide
|
||||||
|
|
||||||
> **Status:** Partially migrated. Current SSO and local upgrade/recovery procedures are available; held procedures are labeled non-operative.
|
> **Status:** Partially migrated. Current SSO and local upgrade/recovery procedures are available; held procedures are labeled non-operative.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Administrator Operations
|
# Administrator Operations
|
||||||
|
|
||||||
> **Status:** Partially migrated. Procedures explicitly identify whether they are current or held.
|
> **Status:** Partially migrated. Procedures explicitly identify whether they are current or held.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Mos Connector Lease Operations — M1
|
# Mos Connector Lease Operations — M1
|
||||||
|
|
||||||
> **Status:** Held / non-operative.
|
> **Status:** Held / non-operative.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Upgrade safety and recovery
|
# Upgrade safety and recovery
|
||||||
|
|
||||||
> **Supported route:** an already installed `mosaic` CLI using the local PGlite
|
> **Supported route:** an already installed `mosaic` CLI using the local PGlite
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
|
|
||||||
> **Status:** Partially migrated. The SSO provider and Discord ingress security pages are current.
|
> **Status:** Partially migrated. The SSO provider and Discord ingress security pages are current.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Discord ingress security
|
# Discord ingress security
|
||||||
|
|
||||||
> **Status:** Current Discord behavior only. Telegram shared-contract parity, Matrix channel ingress, and a gateway-wide shared adapter registry are not implemented or are not proven by the current source/tests.
|
> **Status:** Current Discord behavior only. Telegram shared-contract parity, Matrix channel ingress, and a gateway-wide shared adapter registry are not implemented or are not proven by the current source/tests.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
title: SSO Providers
|
title: SSO Providers
|
||||||
type: runbook
|
|
||||||
audience: admin
|
audience: admin
|
||||||
status: current
|
|
||||||
source_of_truth: false
|
source_of_truth: false
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# API Documentation
|
# API Documentation
|
||||||
|
|
||||||
> **Status:** Scaffold only. The canonical gateway contract has not yet been migrated into this directory.
|
> **Status:** Scaffold only. The canonical gateway contract has not yet been migrated into this directory.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Developer Guide
|
# Developer Guide
|
||||||
|
|
||||||
> **Status:** Partially migrated. Architecture, lease-broker verification, and channel-adapter authoring pages are current; other contributor chapters remain unmigrated.
|
> **Status:** Partially migrated. Architecture, lease-broker verification, and channel-adapter authoring pages are current; other contributor chapters remain unmigrated.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
> **Status:** Partially migrated. The lease-broker security-contract pages below are current references; the remaining architecture pages are still being classified.
|
> **Status:** Partially migrated. The lease-broker security-contract pages below are current references; the remaining architecture pages are still being classified.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Channel protocol architecture
|
# Channel protocol architecture
|
||||||
|
|
||||||
> **Status:** Current shared type contract and Discord compatibility baseline. The shared gateway registry, Telegram parity, Matrix integration, identity-linking, and multi-surface multiplexing described below are draft or unimplemented.
|
> **Status:** Current shared type contract and Discord compatibility baseline. The shared gateway registry, Telegram parity, Matrix integration, identity-linking, and multi-surface multiplexing described below are draft or unimplemented.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Compaction observer revocation and runtime generations
|
# Compaction observer revocation and runtime generations
|
||||||
|
|
||||||
> **Status:** Current contract reference.
|
> **Status:** Current contract reference.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Architecture Decisions
|
# Architecture Decisions
|
||||||
|
|
||||||
> **Status:** Current decision index. A decision describes an implemented and accepted boundary; draft proposals belong under `rfcs/` or `docs/plans/`.
|
> **Status:** Current decision index. A decision describes an implemented and accepted boundary; draft proposals belong under `rfcs/` or `docs/plans/`.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: record
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Mos Runtime Portability M1 — Logical Identity and Fencing
|
# Mos Runtime Portability M1 — Logical Identity and Fencing
|
||||||
|
|
||||||
> **Decision status:** Current implemented decision (M1).
|
> **Decision status:** Current implemented decision (M1).
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Authenticated external lease broker protocol
|
# Authenticated external lease broker protocol
|
||||||
|
|
||||||
> **Status:** Current contract reference.
|
> **Status:** Current contract reference.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# WI-1 lease broker security notes
|
# WI-1 lease broker security notes
|
||||||
|
|
||||||
> **Status:** Current contract reference.
|
> **Status:** Current contract reference.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Whole mutator-class lease gate
|
# Whole mutator-class lease gate
|
||||||
|
|
||||||
> **Status:** Current contract reference.
|
> **Status:** Current contract reference.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Architecture RFCs
|
# Architecture RFCs
|
||||||
|
|
||||||
> **Status:** Current proposal index. RFCs are draft design material and have no operational or implementation authority until an approved decision and implementation evidence supersede them.
|
> **Status:** Current proposal index. RFCs are draft design material and have no operational or implementation authority until an approved decision and implementation evidence supersede them.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: spec
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# RFC: Optional AI Egress Gateways
|
# RFC: Optional AI Egress Gateways
|
||||||
|
|
||||||
> **Status:** Draft / proposed — not approved, not current, and not integrated.
|
> **Status:** Draft / proposed — not approved, not current, and not integrated.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Channel adapters
|
# Channel adapters
|
||||||
|
|
||||||
> **Status:** Current shared channel types plus the Discord reference/compatibility implementation. A shared gateway adapter registry, Telegram parity, and Matrix channel integration remain unimplemented or unproven.
|
> **Status:** Current shared channel types plus the Discord reference/compatibility implementation. A shared gateway adapter registry, Telegram parity, and Matrix channel integration remain unimplemented or unproven.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
title: Lease-broker operations
|
title: Lease-broker operations
|
||||||
type: runbook
|
|
||||||
audience: developer
|
audience: developer
|
||||||
status: current
|
|
||||||
source_of_truth: false
|
source_of_truth: false
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: tracking
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Mission Manifest — MVP
|
# Mission Manifest — MVP
|
||||||
|
|
||||||
> Top-level rollup tracking Mosaic Stack MVP execution.
|
> Top-level rollup tracking Mosaic Stack MVP execution.
|
||||||
|
|||||||
+298
-14
@@ -1,5 +1,19 @@
|
|||||||
|
---
|
||||||
|
kind: spec
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# PRD: Mosaic Stack v0.1.0
|
# 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
|
## Metadata
|
||||||
|
|
||||||
- **Owner:** Jason Woltje
|
- **Owner:** Jason Woltje
|
||||||
@@ -102,6 +116,128 @@ 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)
|
## Fleet Declarative Configuration Management Workstream (FCM, #758)
|
||||||
|
|
||||||
### Problem and objective
|
### Problem and objective
|
||||||
@@ -115,19 +251,18 @@ gateway-backed agent catalog.
|
|||||||
|
|
||||||
### Normative requirements
|
### Normative requirements
|
||||||
|
|
||||||
| ID | Requirement |
|
| ID | Requirement |
|
||||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `FCM-REQ-01` | The roster SHALL be the sole writable desired-state source for local fleet membership, launch policy, and persisted lifecycle target. Generated environment files, systemd enablement, tmux sessions, and heartbeat state SHALL be non-authoritative projections. |
|
| `FCM-REQ-01` | The roster SHALL be the sole writable desired-state source for local fleet membership, launch policy, and persisted lifecycle target. Generated environment files, systemd enablement, tmux sessions, and heartbeat state SHALL be non-authoritative projections. |
|
||||||
| `FCM-REQ-02` | The implementation SHALL provide one executable structural contract for YAML/JSON input and one shared semantic validator. Roster load, profile validation, provision, migration, and apply SHALL reuse the existing baseline-plus-`roles.local` profile/persona resolver; a parallel role resolver is forbidden. |
|
| `FCM-REQ-02` | The implementation SHALL provide one executable structural contract for YAML/JSON input and one shared semantic validator. Roster load, profile validation, provision, migration, and apply SHALL reuse the existing baseline-plus-`roles.local` profile/persona resolver; a parallel role resolver is forbidden. |
|
||||||
| `FCM-REQ-03` | The local fleet CLI SHALL expose documented programmatic validate, show, plan, apply/reconcile, create, inspect, update, delete, start, stop, restart, status, verify, and doctor operations with stable JSON and exit-code behavior. Existing `fleet add/remove` compatibility aliases may remain during the stated deprecation window. |
|
| `FCM-REQ-03` | The local fleet CLI SHALL expose documented programmatic validate, show, plan, apply/reconcile, create, inspect, update, delete, start, stop, restart, status, verify, and doctor operations with stable JSON and exit-code behavior. Existing `fleet add/remove` compatibility aliases may remain during the stated deprecation window. |
|
||||||
| `FCM-REQ-04` | A fresh create SHALL persist `enabled:true` and `desired_state:stopped` unless an explicit persisted start is requested. The model SHALL distinguish enabled state, persisted desired state, and observed state. Migration, apply, reboot, and rollback SHALL not start an agent that was observed stopped before cutover. |
|
| `FCM-REQ-04` | A fresh create SHALL persist `enabled:true` and `desired_state:stopped` unless an explicit persisted start is requested. The model SHALL distinguish enabled state, persisted desired state, and observed state. Migration, apply, reboot, and rollback SHALL not start an agent that was observed stopped before cutover. |
|
||||||
| `FCM-REQ-05` | The launch chain SHALL consume deterministic, digest-stamped generated input only. Optional local overrides SHALL be parsed as strict data, may not shadow authoritative generated keys, and may not contain arbitrary commands, credential values, channels, or unknown `MOSAIC_AGENT_*` keys. Forbidden legacy keys, including `MOSAIC_AGENT_COMMAND`, SHALL be privately quarantined before launch and reported only by key name and content hash. |
|
| `FCM-REQ-05` | The launch chain SHALL consume deterministic, digest-stamped generated input only. Optional local overrides SHALL be parsed as strict data, may not shadow authoritative generated keys, and may not contain arbitrary commands, credential values, channels, or unknown `MOSAIC_AGENT_*` keys. Forbidden legacy keys, including `MOSAIC_AGENT_COMMAND`, SHALL be privately quarantined before launch and reported only by key name and content hash. |
|
||||||
| `FCM-REQ-06` | Mutations and apply SHALL validate before mutation, use an expected generation/lock, write projections atomically, produce a deterministic plan, and emit recovery information on partial failure. Reconciliation SHALL act only on local, enabled, roster-owned projections and SHALL not kill unmanaged tmux sessions by fuzzy name. |
|
| `FCM-REQ-06` | Mutations and apply SHALL validate before mutation, use an expected generation/lock, write projections atomically, produce a deterministic plan, and emit recovery information on partial failure. Reconciliation SHALL act only on local, enabled, roster-owned projections and SHALL not kill unmanaged tmux sessions by fuzzy name. |
|
||||||
| `FCM-REQ-07` | Canonical required classes are `code`, `review`, `validator`, `orchestrator`, `team-leader`, `enhancer`, and `interaction`. `validator` issues an independent final certificate but has no merge authority; `merge-gate` remains sole approve-to-land/merge authority. Team-leader capacity is bounded by an orchestrator-issued lease, and interaction is request/status only. Tess and Ultron are configurable instance/display names, not required machine identities. |
|
| `FCM-REQ-07` | Canonical required classes are `code`, `review`, `validator`, `orchestrator`, `team-leader`, `enhancer`, and `interaction`. `validator` issues an independent final certificate but has no merge authority; `merge-gate` remains sole approve-to-land/merge authority. Team-leader capacity is bounded by an orchestrator-issued lease, and interaction is request/status only. Tess and Ultron are configurable instance/display names, not required machine identities. |
|
||||||
| `FCM-REQ-08` | v1 migration SHALL be field-complete, reversible, and explicit about aliases, unresolved classes, lifecycle inference, generated-file regeneration, local override quarantine, schema-only remote/connector fields, and rollback. Every shipped example, profile, and service preset SHALL be migrated and executable, retained as an explicitly versioned v1 fixture, or retired with a replacement and deprecation note. |
|
| `FCM-REQ-08` | v1 migration SHALL be field-complete, reversible, and explicit about aliases, unresolved classes, lifecycle inference, generated-file regeneration, local override quarantine, schema-only remote/connector fields, and rollback. Every shipped example, profile, and service preset SHALL be migrated and executable, retained as an explicitly versioned v1 fixture, or retired with a replacement and deprecation note. |
|
||||||
| `FCM-REQ-09` | M1–M5 SHALL remain local tmux/systemd control-plane work. Remote/SSH reconciliation, connector mutation, secret references, arbitrary command/channel overrides, gateway/API convergence, and UI configuration storage are excluded and require a separate PRD/threat model. |
|
| `FCM-REQ-09` | M1–M5 SHALL remain local tmux/systemd control-plane work. Remote/SSH reconciliation, connector mutation, secret references, arbitrary command/channel overrides, gateway/API convergence, and UI configuration storage are excluded and require a separate PRD/threat model. |
|
||||||
| `FCM-REQ-10` | Documentation and examples are delivery gates. The M0 checklist at [docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md](./fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md) and the baseline disposition inventory at [docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md](./fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md) SHALL be maintained as acceptance evidence. |
|
| `FCM-REQ-10` | Documentation and examples are delivery gates. The M0 checklist at [docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md](./fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md) and the baseline disposition inventory at [docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md](./fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md) SHALL be maintained as acceptance evidence. |
|
||||||
| `FCM-REQ-11` | Fleet provisioning SHALL validate the fleet CLI and distinct runtimes requested by the roster against the exact PATH construction used by the runtime pane, through one shared implementation rather than the operator PATH or a parallel PATH model. Name resolution alone is insufficient: a resolved script's shebang interpreter SHALL also be reachable, and Node SHALL execute a side-effect-free version probe when it is that interpreter. `fleet install` and `install-systemd` SHALL fail before installation effects when a required executable is absent or unreachable. `fleet doctor` SHALL emit the same named checks as non-green evidence. Every runtime failure SHALL name the runtime, all requesting roster rows, the pane PATH searched, and an exact install command. |
|
|
||||||
|
|
||||||
### Acceptance criteria
|
### Acceptance criteria
|
||||||
|
|
||||||
@@ -139,7 +274,6 @@ gateway-backed agent catalog.
|
|||||||
6. `AC-FCM-06`: A v1 roster migration previews field-by-field disposition, preserves observed stopped/running state, inventories rather than reconciles remote/schema-only entries, supports a canary and rollback, and classifies every shipped example, profile, and service preset according to the M0 inventory.
|
6. `AC-FCM-06`: A v1 roster migration previews field-by-field disposition, preserves observed stopped/running state, inventories rather than reconciles remote/schema-only entries, supports a canary and rollback, and classifies every shipped example, profile, and service preset according to the M0 inventory.
|
||||||
7. `AC-FCM-07`: Required role authority is validated: validator certificate is consumed but does not merge, merge-gate is the sole merge authority, team-leader leases do not change roster/credentials/authority, and interaction/Tess cannot claim orchestration or merge powers.
|
7. `AC-FCM-07`: Required role authority is validated: validator certificate is consumed but does not merge, merge-gate is the sole merge authority, team-leader leases do not change roster/credentials/authority, and interaction/Tess cannot claim orchestration or merge powers.
|
||||||
8. `AC-FCM-08`: Documentation, examples, migration, troubleshooting, operational recovery, package/update asset drift, schema/example/profile validation, independent code/security review, validator certificate, and terminal-green CI are complete before #758 closes.
|
8. `AC-FCM-08`: Documentation, examples, migration, troubleshooting, operational recovery, package/update asset drift, schema/example/profile validation, independent code/security review, validator certificate, and terminal-green CI are complete before #758 closes.
|
||||||
9. `AC-FCM-09`: Red-first isolated tests create (a) a roster whose runtime exists on the operator PATH but is absent from the constructed pane PATH and (b) a greenfield pane where `mosaic` and a runtime resolve as Node-shebang scripts while Node is absent. They prove `fleet install` fails before effects, the launcher creates no doomed session, and `fleet doctor` reports named non-green checks. Diagnostics include the executable or runtime, all requesting rows, searched pane PATH, shebang dependency when present, and exact runtime install command; repeated rows are checked once per distinct runtime/effective pane path. Tests use temporary `--mosaic-home` state and fixture binaries, never host runtime mutation.
|
|
||||||
|
|
||||||
### M0 implementation gate
|
### M0 implementation gate
|
||||||
|
|
||||||
@@ -148,6 +282,68 @@ lands. M0 consists only of these normative requirements, the complete task DAG,
|
|||||||
documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards
|
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.
|
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)
|
## Exact Cross-Harness Fleet Communications Contract (#766)
|
||||||
@@ -1347,6 +1543,59 @@ 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
|
## 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.
|
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.
|
||||||
@@ -1370,3 +1619,38 @@ All work is **alpha** (< 0.1.0) until Jason approves 0.1.0 beta release.
|
|||||||
10. ASSUMPTION: **Conversations and messages get their own PG tables** (not stored in brain's entity model). They follow a chat-specific schema with proper foreign keys to users and projects. Rationale: Chat has different access patterns (streaming, pagination, search) than brain entities.
|
10. ASSUMPTION: **Conversations and messages get their own PG tables** (not stored in brain's entity model). They follow a chat-specific schema with proper foreign keys to users and projects. Rationale: Chat has different access patterns (streaming, pagination, search) than brain entities.
|
||||||
|
|
||||||
11. RESOLVED: **Pi handles all target LLM providers natively.** Anthropic, OpenAI/Codex, Z.ai, Ollama, LM Studio, and llama.cpp are all supported via Pi's built-in providers or `models.json` configuration with `openai-completions` API type. No custom provider adapters needed in @mosaicstack/agent — only configuration management.
|
11. RESOLVED: **Pi handles all target LLM providers natively.** Anthropic, OpenAI/Codex, Z.ai, Ollama, LM Studio, and llama.cpp are all supported via Pi's built-in providers or `models.json` configuration with `openai-completions` API type. No custom provider adapters needed in @mosaicstack/agent — only configuration management.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Release Integrity Workstream (RI, #1275)
|
||||||
|
|
||||||
|
### Problem and objective
|
||||||
|
|
||||||
|
At `next` 476db12b (review of 2026-08-17), publication from `next` is not bound to the full verification pipeline for the same commit: the publish pipeline's publish steps depend on `build` only, while ordinary push CI excludes `next`. Public Forge/MACP paths contain false-success placeholders: a stub executor that reports `completed` with exit zero, planning/remediation gates that execute literal `true`, a review gate that echoes an approving verdict, and a gate runner that treats empty commands and unimplemented CI-provider gates as passing. Shipping UI surfaces can render a failed fetch as an empty, healthy collection.
|
||||||
|
|
||||||
|
Objective: for alpha 0.0.50, the release cannot publish, report, or display work state that the repository has not actually verified. Decisions SDLC-D-033 through SDLC-D-038 (Jason, 2026-08-17) scope this floor; full decision text and required-behavior lists live in jarvis-brain `docs/plans/2026-08-16_mosaic-stack-sdlc-protocol.md` and `data/decisions/mosaic-stack-sdlc-protocol.json`. This section restates only the normative requirements.
|
||||||
|
|
||||||
|
### Normative requirements
|
||||||
|
|
||||||
|
1. **RI-N1 Exact-commit publication verification (SDLC-D-034).** One canonical terminal verification command performs self-contained re-verification in the publish pipeline against the job's checked-out commit before any external publication effect. The command contains or invokes the complete mandatory verification set (semantic parity with the PR merge gate, including sanitization, upgrade-guard, typecheck, lint, format check, tests, and build); CI and publication do not maintain separate semantic checklists. Every publish step depends on the verification step in the executable pipeline DAG. Provider commit identity and `git rev-parse HEAD` must identify the same commit. Missing, skipped, cancelled, stale, or inconclusive checks fail closed. Documentation-only runs may skip publication but cannot bypass verification when a publication effect will occur. A negative control must prove that a broken check blocks every publish step.
|
||||||
|
|
||||||
|
2. **RI-N2 Fail-closed Forge/MACP with explicit simulation (SDLC-D-035).** Simulation requires explicit caller intent (e.g. `--simulate`) and produces a distinct typed `simulated` state that can never satisfy dependencies, acceptance criteria, gates, merge, or release. Normal execution exits nonzero with a typed capability failure when a required executor, reviewer, command, or CI provider is absent — no stub completion, no literal-`true` gates, no synthetic approvals, no empty-command passes. A manual gate with no automation enters a waiting state; it does not pass. Positive tests prove explicit simulation still works; negative controls prove simulation and every missing-provider case cannot advance lifecycle state.
|
||||||
|
|
||||||
|
3. **RI-N3 One transitional PRD authority (SDLC-D-036).** `@mosaicstack/prdy` structured storage under `docs/prdy/`, driven by `mosaic mission --plan`, is the authoritative PRD representation for the alpha. `mosaic prdy` either routes through the same application service or operates only as an explicit, named Markdown import/export adapter; `docs/PRD.md` is not a peer authority. `mission --plan` must persist the mission↔PRD linkage (mission id/version, PRD id/version, selected requirements). Markdown output is a generated view carrying source identity; editing it cannot mutate authority silently. Import is explicit, validated, and conflict-aware (proposed successor, never overwrite). Structural validity is separate from approval.
|
||||||
|
|
||||||
|
4. **RI-N4 One quality-rails evaluator (SDLC-D-037).** The TypeScript quality-rails package is the sole authoritative evaluator. A complete probe inventory maps every current TypeScript and shell check to one canonical check with disposition (preserve/strengthen/retire, each named). Effective shell enforcement probes are absorbed before their independent paths retire; expected-file presence alone is not parity. The evaluator returns typed results (`passed`/`failed`/`blocked`/`error`/`not-applicable`) with check version, subject, and reason; missing implementation, missing input, unknown check, process error, timeout, or malformed output can never become `passed` or an unqualified skip. Check definitions and policy are versioned and digested. Shell commands become thin adapters with no separate verdict logic. The canonical terminal verification command (RI-N1) invokes this evaluator rather than duplicating its logic. Contract, parity, and negative-control tests are required, plus independent review of probe equivalence.
|
||||||
|
|
||||||
|
5. **RI-N5 Consequence-aware stale UI (SDLC-D-038).** Mission Control distinguishes typed freshness states (`current`, `stale`, `partial`, `unknown`, `unavailable`) rather than inferring from empty arrays or null. A failed fetch never renders as an empty healthy collection. Last-known data may display for situational awareness only with source identity, version, and age visibly labeled; any derived completion/assurance/release verdict whose inputs are stale becomes `unknown`; all state-changing actions are disabled until fresh state loads and is revalidated. With no verified snapshot, surfaces show an explicit unavailable state. Cache corruption, cross-workspace data, schema mismatch, and version regression invalidate the snapshot. Tests cover the failure matrix (network, auth, malformed, partial, corruption, stale age, schema mismatch, recovery, stale-action rejection) with negative controls proving no case yields a current green verdict or enabled mutation.
|
||||||
|
|
||||||
|
### Acceptance criteria
|
||||||
|
|
||||||
|
- AC-RI-1: A push to `next` that fails any mandatory verification step publishes nothing (no npm package, no image), demonstrated by a checked-in negative control and by pipeline evidence on a real `next` publish run where the verification step is green and every publish step depends on it.
|
||||||
|
- AC-RI-2: With no executor/reviewer/CI provider wired, Forge and MACP normal runs exit nonzero with typed capability failures; with `--simulate`, runs complete but every result is typed `simulated` and cannot satisfy any gate, dependency, or completion state — proven by unit tests including negative controls.
|
||||||
|
- AC-RI-3: A PRD created or revised through either `mosaic mission --plan` or `mosaic prdy` resolves to one authority under `docs/prdy/` with stable identities and versions; the mission↔PRD linkage survives restart; a Markdown export is labeled as generated and cannot silently become a second writer; divergent legacy content blocks baseline claims until explicitly resolved — proven by contract tests.
|
||||||
|
- AC-RI-4: `quality-rails check` through any entry point (TS CLI, framework shell adapter) returns the same typed verdict for the same subject; the probe inventory names every legacy check's disposition; a deliberately broken probe fails closed — proven by contract/parity/negative-control tests and independent review of probe equivalence.
|
||||||
|
- AC-RI-5: No shipping surface renders a failed fetch as an empty healthy state; stale/partial/unavailable states are typed, labeled, and mutation-disabled — proven by the failure-matrix tests.
|
||||||
|
- AC-RI-6: All cards merged to `next` via squash PR with terminal-green CI; release evidence for 0.0.50 records commit, verification run, and published artifacts.
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
|
||||||
|
The canonical dispatcher/control-plane vertical slice (work graph, execution attempts, fenced leases, typed check-in, independent verifier dispatch) is decided post-alpha (SDLC-D-033, option B). Multi-pipeline verification certificates (SDLC-D-034 option B) are post-alpha. Full AF-1..AF-4 objective matrices and Mission Control portfolio surfaces are post-alpha.
|
||||||
|
|||||||
+44
-8
@@ -1,3 +1,9 @@
|
|||||||
|
---
|
||||||
|
kind: spec
|
||||||
|
source_of_truth: true
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Mosaic Stack Documentation
|
# Mosaic Stack Documentation
|
||||||
|
|
||||||
This directory is the canonical home for Mosaic Stack product, architecture, API, operations, and delivery documentation.
|
This directory is the canonical home for Mosaic Stack product, architecture, API, operations, and delivery documentation.
|
||||||
@@ -146,21 +152,51 @@ Every canonical page should:
|
|||||||
7. Include an owner or maintenance responsibility for operationally sensitive content.
|
7. Include an owner or maintenance responsibility for operationally sensitive content.
|
||||||
8. Link to the relevant book index and related canonical pages.
|
8. Link to the relevant book index and related canonical pages.
|
||||||
|
|
||||||
Recommended front matter for canonical pages:
|
Required front matter for every canonical page:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
title: Human-readable page title
|
kind: tracking | projection | spec | guide | record | superseded
|
||||||
type: guide
|
status: active # or: completed | superseded-by: <path>
|
||||||
audience: developer
|
source_of_truth: false # optional, defaults false
|
||||||
status: current
|
audience: developer # optional: user | admin | developer | all
|
||||||
source_of_truth: false
|
title: Human-readable page title # optional
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
Allowed `type` values include `guide`, `concept`, `reference`, `decision`, `rfc`, and `runbook`. Allowed `audience` values are `user`, `admin`, `developer`, and `all`. Allowed `status` values are `current`, `draft`, `deprecated`, and `historical`.
|
`kind` says what the document **is**. One value, required, and it follows the document's content,
|
||||||
|
never its filename: a file named `TASKS.md` whose body says "this is a build plan, not a task
|
||||||
|
tracker" is a `spec`.
|
||||||
|
|
||||||
Indexes may omit front matter when their purpose is self-evident. A page with normative authority must explicitly identify the authority it owns and the boundaries of that authority.
|
| kind | rule |
|
||||||
|
| ---------- | ---------------------------------------------------------------- |
|
||||||
|
| tracking | Live state, single-writer. Never a spec |
|
||||||
|
| projection | Generated. Never hand-edited. MUST have a drift test |
|
||||||
|
| spec | How to build one goal or workstream |
|
||||||
|
| guide | Explains use. Decides nothing |
|
||||||
|
| record | What happened. Never authoritative, never updated after the fact |
|
||||||
|
| superseded | Kept for history, and NAMES its replacement |
|
||||||
|
|
||||||
|
`source_of_truth` is a separate boolean because authority is **orthogonal to kind**. A document can
|
||||||
|
be a `spec` and still be the thing everything else answers to;
|
||||||
|
`docs/requirements/native-kanban-sot.md` is exactly that. Folding authority into `kind` forced one
|
||||||
|
field to carry two independent facts, which is why an earlier draft of this contract could not
|
||||||
|
classify that file at all.
|
||||||
|
|
||||||
|
`status` has three values. `active` means in force. `completed` means the work the document
|
||||||
|
describes landed and the document is now finished rather than stale; executed implementation plans
|
||||||
|
take this. `superseded-by: <path>` replaces `status` entirely and names the replacement.
|
||||||
|
|
||||||
|
**This contract covers `.md` files only.** It is not an omission: a YAML document cannot carry YAML
|
||||||
|
front matter. The repository's own `[email protected]` throws `Source contains multiple documents` on a
|
||||||
|
front-mattered `.yaml`, and `parseNorthStar` (`packages/mosaic/src/commands/fleet.ts:242`) is a live
|
||||||
|
consumer that would break. `.yaml` sources declare their own kind inside the document or not at all.
|
||||||
|
|
||||||
|
A `parent` field is planned and is deliberately not yet required; it lands once the docs flatten
|
||||||
|
settles the paths it would point at.
|
||||||
|
|
||||||
|
Indexes may omit front matter when their purpose is self-evident. A page with normative authority
|
||||||
|
must explicitly identify the authority it owns and the boundaries of that authority.
|
||||||
|
|
||||||
## Obsidian and link conventions
|
## Obsidian and link conventions
|
||||||
|
|
||||||
|
|||||||
+15
-1
@@ -1,5 +1,19 @@
|
|||||||
# Tasks — MVP (Top-Level Rollup)
|
# Tasks — MVP (Top-Level Rollup)
|
||||||
|
|
||||||
|
> ---
|
||||||
|
>
|
||||||
|
> **STATUS: SUPERSEDED — 2026-08-20.** kind `tracking` · superseded by `docs/fleet/NORTH_STAR.yaml`
|
||||||
|
>
|
||||||
|
> This file is the pre-backlog tracking mechanism. `NS-2` in the north star declares the
|
||||||
|
> replacement: every backlog item is a Mosaic Backlog card projected from the YAML. That
|
||||||
|
> model replaced this one and nobody retired the old file, so it kept reading as
|
||||||
|
> authoritative while going stale.
|
||||||
|
>
|
||||||
|
> **Do not trust a status in this file.** Verified 2026-08-20: it was already behind the
|
||||||
|
> code when it froze five weeks ago.
|
||||||
|
>
|
||||||
|
> Kept as a record of what was believed. Do not update it; update the YAML.
|
||||||
|
|
||||||
> Single-writer: orchestrator only. Workers read but never modify.
|
> Single-writer: orchestrator only. Workers read but never modify.
|
||||||
>
|
>
|
||||||
> **Mission:** mvp-20260312
|
> **Mission:** mvp-20260312
|
||||||
@@ -108,7 +122,7 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
|||||||
|
|
||||||
## north-star doctrine consolidation — doc PR — feat/north-star-doctrine
|
## north-star doctrine consolidation — doc PR — feat/north-star-doctrine
|
||||||
|
|
||||||
- Status: applied Mos's consolidated merge-map to docs/fleet/north-star.md (budget governance + control plane/central register + 200k cap + delegation + unified-identity Fleet + role-based naming + tmux security + drift re-captures). Doctrine only; #622/#623/#625/#628 out-of-scope. Conflict checklist green. Detail: scratchpads/north-star-doctrine.md.
|
- Status: applied Mos's consolidated merge-map to docs/fleet/FLEET-DOCTRINE.md (budget governance + control plane/central register + 200k cap + delegation + unified-identity Fleet + role-based naming + tmux security + drift re-captures). Doctrine only; #622/#623/#625/#628 out-of-scope. Conflict checklist green. Detail: scratchpads/north-star-doctrine.md.
|
||||||
|
|
||||||
## #631 — re-seed preserves user fleet data (CRITICAL) — fix/631-reseed-preserves-fleet-data
|
## #631 — re-seed preserves user fleet data (CRITICAL) — fix/631-reseed-preserves-fleet-data
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# User Guide
|
# User Guide
|
||||||
|
|
||||||
> **Status:** Partially migrated. The quickstart, web-dashboard reference, and Discord conversation workflow are current.
|
> **Status:** Partially migrated. The quickstart, web-dashboard reference, and Discord conversation workflow are current.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
title: Mosaic Stack Quickstart
|
title: Mosaic Stack Quickstart
|
||||||
type: guide
|
|
||||||
audience: user
|
audience: user
|
||||||
status: current
|
|
||||||
source_of_truth: false
|
source_of_truth: false
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
title: Mosaic web dashboard
|
title: Mosaic web dashboard
|
||||||
type: guide
|
|
||||||
audience: user
|
audience: user
|
||||||
status: current
|
|
||||||
source_of_truth: false
|
source_of_truth: false
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Discord conversations
|
# Discord conversations
|
||||||
|
|
||||||
> **Status:** Current Discord workflow for an administrator-provisioned, authorized guild channel.
|
> **Status:** Current Discord workflow for an administrator-provisioned, authorized guild channel.
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
3. [Provider Configuration](#provider-configuration)
|
3. [Provider Configuration](#provider-configuration)
|
||||||
4. [MCP Server Configuration](#mcp-server-configuration)
|
4. [MCP Server Configuration](#mcp-server-configuration)
|
||||||
5. [Environment Variables Reference](#environment-variables-reference)
|
5. [Environment Variables Reference](#environment-variables-reference)
|
||||||
6. [Local Fleet Canary](./fleet-local-canary.md)
|
6. [Pi Goal Loop Operations](#pi-goal-loop-operations)
|
||||||
|
7. [Local Fleet Canary](./fleet-local-canary.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -264,6 +265,16 @@ 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_SYSTEM_PROMPT` | — | Platform-level system prompt injected into all sessions |
|
||||||
| `AGENT_USER_TOOLS` | all tools | Comma-separated allowlist of tools for non-admin users |
|
| `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
|
### Providers
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
@@ -374,3 +385,29 @@ Session cleanup is scoped to one session identifier and only removes that sessio
|
|||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| ----------------------- | ----------------------------- | ------------------------------------------ |
|
| ----------------------- | ----------------------------- | ------------------------------------------ |
|
||||||
| `MOSAIC_WORKSPACE_ROOT` | monorepo root (auto-detected) | Root path for mission workspace operations |
|
| `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,9 +8,10 @@
|
|||||||
4. [Tasks](#tasks)
|
4. [Tasks](#tasks)
|
||||||
5. [Settings](#settings)
|
5. [Settings](#settings)
|
||||||
6. [CLI Usage](#cli-usage)
|
6. [CLI Usage](#cli-usage)
|
||||||
7. [Sub-package Commands](#sub-package-commands)
|
7. [Pi Persistent Goals](#pi-persistent-goals)
|
||||||
8. [Telemetry](#telemetry)
|
8. [Sub-package Commands](#sub-package-commands)
|
||||||
9. [Local Fleet Canary](./fleet-local-canary.md)
|
9. [Telemetry](#telemetry)
|
||||||
|
10. [Local Fleet Canary](./fleet-local-canary.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -317,6 +318,57 @@ mosaic prdy
|
|||||||
mosaic quality-rails
|
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
|
### Claude Code Skill Registration
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: tracking
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Mission Manifest — Federation v1
|
# Mission Manifest — Federation v1
|
||||||
|
|
||||||
> Persistent document tracking full mission scope, status, and session history.
|
> Persistent document tracking full mission scope, status, and session history.
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
# Tasks — Federation v1
|
# Tasks — Federation v1
|
||||||
|
|
||||||
|
> ---
|
||||||
|
>
|
||||||
|
> **STATUS: SUPERSEDED — 2026-08-20.** kind `tracking` · superseded by `docs/fleet/NORTH_STAR.yaml`
|
||||||
|
>
|
||||||
|
> This file is the pre-backlog tracking mechanism. `NS-2` in the north star declares the
|
||||||
|
> replacement: every backlog item is a Mosaic Backlog card projected from the YAML. That
|
||||||
|
> model replaced this one and nobody retired the old file, so it kept reading as
|
||||||
|
> authoritative while going stale.
|
||||||
|
>
|
||||||
|
> **Do not trust a status in this file.** Verified 2026-08-20: it was already behind the
|
||||||
|
> code when it froze five weeks ago. `FED-M3-06` is marked not-started and `get.controller.ts`
|
||||||
|
> has existed for eight weeks; `FED-M3-10/11` claim no tests exist while fifteen spec files
|
||||||
|
> do. `FED-M2-DEPLOY-IMG-FIX` names `apps/gateway/Dockerfile`, which is not in the repo.
|
||||||
|
>
|
||||||
|
> Kept as a record of what was believed. Do not update it; update the YAML.
|
||||||
|
|
||||||
> Single-writer: orchestrator only. Workers read but never modify.
|
> Single-writer: orchestrator only. Workers read but never modify.
|
||||||
>
|
>
|
||||||
> **Mission:** federation-v1-20260419
|
> **Mission:** federation-v1-20260419
|
||||||
|
|||||||
@@ -1,9 +1,26 @@
|
|||||||
# Mosaic Fleet — North Star
|
---
|
||||||
|
kind: spec
|
||||||
|
parent: docs/fleet/NORTH_STAR.yaml
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
|
# Mosaic Fleet — Doctrine
|
||||||
|
|
||||||
|
> **This is the WHY. `NORTH_STAR.yaml` is the WHAT and WHEN.**
|
||||||
|
> Renamed from `north-star.md` on 2026-08-20. It sat one character away from the
|
||||||
|
> generated `NORTH_STAR.md` in the same directory, and the two are read by different
|
||||||
|
> populations — the PRDs and TASKS files cite this one, while the agent role contracts
|
||||||
|
> and the generator spec cite the YAML pair. Same-name-different-thing was the confusion;
|
||||||
|
> the content was never in conflict.
|
||||||
|
>
|
||||||
|
> **Nothing here overrides `NORTH_STAR.yaml`.** Where this document states a plan item,
|
||||||
|
> the YAML is authoritative. Where it states a decision, a rationale, or a role
|
||||||
|
> definition, this document is the record and the YAML carries none of it.
|
||||||
|
>
|
||||||
> **Workstream:** W-FLEET (Fleet) under mission `mvp-20260312`
|
> **Workstream:** W-FLEET (Fleet) under mission `mvp-20260312`
|
||||||
> **Umbrella:** [docs/MISSION-MANIFEST.md](../MISSION-MANIFEST.md) · [docs/PRD.md](../PRD.md) (Mosaic Stack v0.1.0)
|
> **Umbrella:** [docs/MISSION-MANIFEST.md](../MISSION-MANIFEST.md)
|
||||||
> **Status:** doctrine — authored 2026-06-20. Owner of this file: Fleet workstream lead.
|
> **Authored:** 2026-06-20. Owner: Fleet workstream lead.
|
||||||
> This document does **not** modify the MVP rollup; a rollup row is proposed, not written here.
|
> This document does **not** modify the MVP rollup.
|
||||||
|
|
||||||
## Vision
|
## Vision
|
||||||
|
|
||||||
@@ -264,15 +281,17 @@ Dedicated Postgres **instance** vs. dedicated **schema** in the existing instanc
|
|||||||
Recommendation: dedicated schema, existing instance (a migration file, not new infra);
|
Recommendation: dedicated schema, existing instance (a migration file, not new infra);
|
||||||
re-evaluate if isolation or write-volume demands it.
|
re-evaluate if isolation or write-volume demands it.
|
||||||
|
|
||||||
## Phased roadmap
|
## Phased roadmap — SUPERSEDED
|
||||||
|
|
||||||
| Phase | Outcome | Status |
|
Superseded 2026-08-20 by [`NORTH_STAR.yaml`](./NORTH_STAR.yaml), whose `goals` carry both
|
||||||
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
|
a `phase` (build order) and a `tier` (which promise the goal delivers). The five-phase
|
||||||
| 0–1 | tmux PoC, hardening, published CLI v0.0.34 (#565–#568) | ✅ done |
|
table that stood here could not express those as separate axes, and its "Phase 2 —
|
||||||
| **2 — Observability** | fleet ps (host+tenant aware join), heartbeat protocol + dogfood stub answers it, agent watch (read-only), agent send --verify receipts | ▶ now |
|
Observability ▶ now" row stayed unfalsified for two months because a phase has no exit
|
||||||
| 3 — Real runtimes | claude/codex/pi/opencode answer heartbeat; **hybrid lifecycle** (core always-on: **orchestrator + enhancer**; ephemeral workers per lane) | planned |
|
test. Tiers do: see `AC-NS-0` through `AC-NS-7`.
|
||||||
| 4 — Unified definition | one agent schema in gateway; mosaic agent --new → materialized per-tenant session; uid-tenant provisioning; **`fleet` schema migration + `forge-exec` TaskExecutor adapter (forge → `agent-send.sh`)** | planned |
|
|
||||||
| 5 — Control plane | federation-backed cross-host × cross-tenant fleet view; **webUI** (surface chosen then) for MVP-X1 parity; **central register live (spend ledger, docs-as-projections, multi-host Kanban)** | planned |
|
The phase-2 content itself is not lost — it is specified in
|
||||||
|
[`PRD.md`](./PRD.md) (Fleet Phase 2: Operator Observability) and is now tracked as
|
||||||
|
goals `I1`–`I5` at tier 0.
|
||||||
|
|
||||||
## Decisions of record (2026-06-20, with Jason)
|
## Decisions of record (2026-06-20, with Jason)
|
||||||
|
|
||||||
+13
-32
@@ -5,14 +5,14 @@ Generated environment files are rebuildable projections, not an operator-editabl
|
|||||||
|
|
||||||
## Launch chain
|
## Launch chain
|
||||||
|
|
||||||
| Layer | Responsibility |
|
| Layer | Responsibility |
|
||||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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
|
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,
|
command. `MOSAIC_AGENT_COMMAND`, command/channel overrides, unknown keys, generated-key shadowing,
|
||||||
@@ -24,6 +24,7 @@ secret-like key names, duplicate keys, comments, quoted/export syntax, and unsaf
|
|||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
MOSAIC_AGENT_NAME=<roster name>
|
MOSAIC_AGENT_NAME=<roster name>
|
||||||
|
MOSAIC_GIT_IDENTITY=<roster name>
|
||||||
MOSAIC_AGENT_CLASS=<roster class>
|
MOSAIC_AGENT_CLASS=<roster class>
|
||||||
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
||||||
MOSAIC_AGENT_MODEL=<roster model hint>
|
MOSAIC_AGENT_MODEL=<roster model hint>
|
||||||
@@ -33,8 +34,10 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
|||||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||||
```
|
```
|
||||||
|
|
||||||
The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. mosaic fleet add
|
`MOSAIC_GIT_IDENTITY` is not independently configurable: it must equal `MOSAIC_AGENT_NAME`, preventing
|
||||||
rejects another runtime before it writes the roster or modifies generated, local, or quarantine state.
|
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 legacy dogfood stub remains an observability-only canary on its separate `mosaic-factory` socket;
|
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.
|
it has no generated-launch adapter and cannot be added through this path.
|
||||||
|
|
||||||
@@ -59,28 +62,6 @@ valid allowed local data can move to `.env.local`; invalid legacy input is priva
|
|||||||
Diagnostics expose only rule code, key name, and a SHA-256 content hash. They do not reveal command
|
Diagnostics expose only rule code, key name, and a SHA-256 content hash. They do not reveal command
|
||||||
text, credentials, or other values.
|
text, credentials, or other values.
|
||||||
|
|
||||||
## Pane executable preflight
|
|
||||||
|
|
||||||
The fleet install, install-systemd, and doctor commands plus the session launcher use
|
|
||||||
**pane-runtime-path.sh** as the single pane-PATH implementation. Install inspects every distinct
|
|
||||||
roster runtime and effective MOSAIC_RUNTIME_BIN pair before creating holder identity, tool,
|
|
||||||
projection, or unit files. Doctor reports the same checks as JSON.
|
|
||||||
|
|
||||||
A resolved command is not automatically executable. The helper reads a script shebang, unwraps the
|
|
||||||
common “/usr/bin/env node” and “/usr/bin/env -S node …” forms, then resolves the declared command
|
|
||||||
against the pane PATH. When Node is the declared interpreter, the helper runs the side-effect-free
|
|
||||||
“node --version” probe. It does not run “mosaic --version”, whose startup update check can write cache
|
|
||||||
state. Native binaries have no PATH-resolved shebang dependency and retain their normal executable
|
|
||||||
check. Failures name the executable or runtime, requesting roster rows, searched pane PATH,
|
|
||||||
dependency, and runtime install command.
|
|
||||||
|
|
||||||
Supported runtime install commands are:
|
|
||||||
|
|
||||||
- **Claude:** curl -fsSL https://claude.ai/install.sh | bash
|
|
||||||
- **Codex:** npm install -g @openai/codex
|
|
||||||
- **OpenCode:** npm install -g opencode-ai
|
|
||||||
- **Pi:** npm install -g @earendil-works/pi-coding-agent
|
|
||||||
|
|
||||||
## Launch and stop behavior
|
## Launch and stop behavior
|
||||||
|
|
||||||
The launcher obtains the agent's socket only from the validated generated projection. It creates or
|
The launcher obtains the agent's socket only from the validated generated projection. It creates or
|
||||||
|
|||||||
+45
-25
@@ -3,7 +3,7 @@
|
|||||||
> **Generated file — do not edit by hand.**
|
> **Generated file — do not edit by hand.**
|
||||||
> Projected deterministically from [`NORTH_STAR.yaml`](./NORTH_STAR.yaml) by the pure
|
> Projected deterministically from [`NORTH_STAR.yaml`](./NORTH_STAR.yaml) by the pure
|
||||||
> generator in `packages/mosaic/src/commands/fleet.ts` (`renderNorthStarMarkdown`).
|
> generator in `packages/mosaic/src/commands/fleet.ts` (`renderNorthStarMarkdown`).
|
||||||
> Edit the YAML, then regenerate. Self-contained Mosaic — no Hermes dependency.
|
> Edit the YAML, then regenerate. Self-contained Mosaic.
|
||||||
|
|
||||||
## Mission
|
## Mission
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ A self-driving Mosaic system that 24/7 unattended converts a machine-readable go
|
|||||||
|
|
||||||
## Substrate
|
## Substrate
|
||||||
|
|
||||||
The Mosaic Backlog is the backlog of record + dispatch engine, built on Mosaic's native Postgres storage service (@mosaicstack/db drizzle; PGlite-embedded by default, full Postgres by config). NOT Hermes.
|
The Mosaic Backlog is the backlog of record + dispatch engine, built on Mosaic's native Postgres storage service (@mosaicstack/db drizzle; PGlite-embedded by default, full Postgres by config).
|
||||||
|
|
||||||
## Standing objectives
|
## Standing objectives
|
||||||
|
|
||||||
@@ -24,16 +24,18 @@ The Mosaic Backlog is the backlog of record + dispatch engine, built on Mosaic's
|
|||||||
- **NS-7** — Meta-loop (session-review + enhancer) continuously proposes small fleet-improvement PRs.
|
- **NS-7** — Meta-loop (session-review + enhancer) continuously proposes small fleet-improvement PRs.
|
||||||
- **NS-8** — Single operator-flippable PAUSE kill-switch (fleet/run/PAUSED) honored before every dispatch and every merge.
|
- **NS-8** — Single operator-flippable PAUSE kill-switch (fleet/run/PAUSED) honored before every dispatch and every merge.
|
||||||
- **NS-9** — Mosaic is a general-purpose multi-agent system: the user declares the SYSTEM TYPE to run (e.g. software delivery, personal assistant, research, business/operations) and the orchestrator provisions the matching persona roster and org structure from a cross-domain baseline persona library; the delivery/coding fleet is one profile among many.
|
- **NS-9** — Mosaic is a general-purpose multi-agent system: the user declares the SYSTEM TYPE to run (e.g. software delivery, personal assistant, research, business/operations) and the orchestrator provisions the matching persona roster and org structure from a cross-domain baseline persona library; the delivery/coding fleet is one profile among many.
|
||||||
|
- **NS-10** — An adoption is not complete until the mechanism it replaces is removed. Two live conventions for one concern is the defect, not a transition state. Measured 2026-08-20: brain-home adopted by 9 modules and not 10; MOSAIC_HOME honored in 4 places, each re-deriving it; backlog cards declared while TASKS.md files stayed authoritative. Every one was decided correctly and left half-applied.
|
||||||
|
|
||||||
## Success criteria
|
## Success criteria
|
||||||
|
|
||||||
- **AC-NS-1** — The supervisor keeps a two-agent floor (1 orchestrator + >=1 enhancer) healthy across reboot.
|
- **AC-NS-0** (tier 0) — The operator launches an agent on any configured harness with one command, observes its state and sends it work without attaching to a terminal multiplexer.
|
||||||
- **AC-NS-2** — A goal added to this YAML is decomposed to cards and either merged or escalated, with no human in the loop.
|
- **AC-NS-1** (tier 1) — The supervisor keeps a two-agent floor (1 orchestrator + >=1 enhancer) healthy across reboot.
|
||||||
- **AC-NS-3** — No PR merges with failure/error/no-status/timeout CI, and none bypass pr-merge.sh.
|
- **AC-NS-2** (tier 1) — A goal added to this YAML is decomposed to cards and either merged or escalated, with no human in the loop.
|
||||||
- **AC-NS-4** — TTL is enforced on claims; token caps remain advisory until a real meter exists.
|
- **AC-NS-3** (tier 1) — No PR merges with failure/error/no-status/timeout CI, and none bypass pr-merge.sh.
|
||||||
- **AC-NS-5** — Flipping fleet/run/PAUSED halts dispatch and merges within one tick.
|
- **AC-NS-4** (tier 1) — TTL is enforced on claims; token caps remain advisory until a real meter exists.
|
||||||
- **AC-NS-6** — A user can declare a system type and the fleet provisions the matching persona roster + topology from the baseline library, with no code change.
|
- **AC-NS-5** (tier 1) — Flipping fleet/run/PAUSED halts dispatch and merges within one tick.
|
||||||
- **AC-NS-7** — A user-customized persona (edited or added via the orchestrator) survives mosaic update: baseline reseed never clobbers user overrides.
|
- **AC-NS-6** (tier 2) — A user can declare a system type and the fleet provisions the matching persona roster + topology from the baseline library, with no code change.
|
||||||
|
- **AC-NS-7** (tier 2) — A user-customized persona (edited or added via the orchestrator) survives mosaic update: baseline reseed never clobbers user overrides.
|
||||||
|
|
||||||
## Workstreams
|
## Workstreams
|
||||||
|
|
||||||
@@ -45,26 +47,44 @@ The Mosaic Backlog is the backlog of record + dispatch engine, built on Mosaic's
|
|||||||
| D | Merge-gate — single approver, pr-merge.sh after CI wait |
|
| D | Merge-gate — single approver, pr-merge.sh after CI wait |
|
||||||
| E | Meta-loop — session-review + enhancer improvement PRs |
|
| E | Meta-loop — session-review + enhancer improvement PRs |
|
||||||
| F | Safety-rails — TTL claims, advisory spend, PAUSE kill-switch |
|
| F | Safety-rails — TTL claims, advisory spend, PAUSE kill-switch |
|
||||||
|
| G | Kill-switch — operator PAUSE honored before dispatch and merge |
|
||||||
| H | Personas & system profiles — cross-domain library, system-type provisioning, update-surviving customization |
|
| H | Personas & system profiles — cross-domain library, system-type provisioning, update-surviving customization |
|
||||||
|
| I | Operator surface — launcher, fleet visibility, reliable steering (tier 0) |
|
||||||
|
| J | Web control plane — browser surface over the gateway (tier 1) |
|
||||||
|
| K | Clients — desktop and mobile over the same backend (tier 2) |
|
||||||
|
| L | Auth profiles — per-provider accounts, per-session selection (tier 2) |
|
||||||
|
|
||||||
## Goals (backlog projection)
|
## Goals (backlog projection)
|
||||||
|
|
||||||
| id | title | phase | priority | depends_on |
|
| id | title | tier | phase | priority | depends_on |
|
||||||
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ----------- | ---------- |
|
| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- | ----- | ----------- | -------------- |
|
||||||
| A1 | Machine-readable NORTH_STAR.yaml + Markdown projection | 1 | must-have | — |
|
| A1 | Machine-readable NORTH_STAR.yaml + Markdown projection | 1 | 1 | must-have | — |
|
||||||
| A2 | Mosaic Backlog schema + storage-service card store (drizzle/PGlite) | 1 | must-have | A1 |
|
| A2 | Mosaic Backlog schema + storage-service card store (drizzle/PGlite) | 1 | 1 | must-have | A1 |
|
||||||
| A3a | Card lifecycle — create/claim/release with stable ids + depends_on DAG | 1 | must-have | A2 |
|
| A3a | Card lifecycle — create/claim/release with stable ids + depends_on DAG | 1 | 1 | must-have | A2 |
|
||||||
| A3b | TTL-bounded claim enforcement (wall-clock) on cards | 1 | must-have | A3a |
|
| A3b | TTL-bounded claim enforcement (wall-clock) on cards | 1 | 1 | must-have | A3a |
|
||||||
| A4 | Advisory spend projection per card (degrades to TTL, no real meter) | 1 | should-have | A3a |
|
| A4 | Advisory spend projection per card (degrades to TTL, no real meter) | 1 | 1 | should-have | A3a |
|
||||||
| B1 | Supervisor tick — readiness scan, two-agent-floor health check | 2 | must-have | A3a |
|
| B1 | Supervisor tick — readiness scan, two-agent-floor health check | 1 | 2 | must-have | A3a |
|
||||||
| B2 | Native dispatch/claim — assign ready dependency-satisfied work | 2 | must-have | A3b, B1 |
|
| B2 | Native dispatch/claim — assign ready dependency-satisfied work | 1 | 2 | must-have | A3b, B1 |
|
||||||
| B3a | Planner decompose — goal added to YAML → cards | 2 | must-have | A2, B1 |
|
| B3a | Planner decompose — goal added to YAML → cards | 1 | 2 | must-have | A2, B1 |
|
||||||
| B3b | Replan request on empty backlog; escalate on no-decompose | 2 | should-have | B3a |
|
| B3b | Replan request on empty backlog; escalate on no-decompose | 1 | 2 | should-have | B3a |
|
||||||
| G1 | PAUSE kill-switch + merge-gate honored before dispatch and merge | 2 | must-have | B2 |
|
| G1 | PAUSE kill-switch + merge-gate honored before dispatch and merge | 1 | 2 | must-have | B2 |
|
||||||
| H1 | Cross-domain baseline persona library (exec, marketing, ops, research, assistant + engineering roles) | 1 | must-have | A1 |
|
| H1 | Cross-domain baseline persona library (exec, marketing, ops, research, assistant + engineering roles) | 2 | 1 | must-have | A1 |
|
||||||
| H2 | System-type profiles — declarative mapping of system type to persona roster + topology | 2 | must-have | H1 |
|
| H2 | System-type profiles — declarative mapping of system type to persona roster + topology | 2 | 2 | must-have | H1 |
|
||||||
| H3 | System-type provisioning — user declares type; orchestrator instantiates the matching roster + structure | 2 | must-have | H2 |
|
| H3 | System-type provisioning — user declares type; orchestrator instantiates the matching roster + structure | 2 | 2 | must-have | H2 |
|
||||||
| H4 | Update-surviving persona customization — ad-hoc edits/additions persisted in a PRESERVE-protected override layer (baseline merged with overrides) | 2 | must-have | H1 |
|
| H4 | Update-surviving persona customization — ad-hoc edits/additions persisted in a PRESERVE-protected override layer (baseline merged with overrides) | 2 | 2 | must-have | H1 |
|
||||||
|
| A5 | NORTH_STAR schema validator — every goal's workstream declared, every workstream has a goal, every depends_on id exists, every tier has a success criterion; runs in CI beside the Markdown regeneration check | 0 | 1 | must-have | A1 |
|
||||||
|
| I1 | One home resolver — a single function resolving MOSAIC_HOME with a sane default, adopted by every module. Today brain-home.ts is imported by 9 modules while 10 still use DEFAULT_MOSAIC_HOME, and MOSAIC_HOME is re-derived ad hoc in 4 places. NS-10 applies - finish the adoption and delete the second path | 0 | 1 | must-have | — |
|
||||||
|
| I2 | mosaic fleet ps sees the fleet that is actually running. Three measured blockers: the roster declares socket `mosaic-fleet` which does not exist, the 18 live sessions are on the default socket, and nothing writes a roster because seats are launched outside the CLI. Make the socket configurable and the roster written at launch, or make ps read tmux + seat dirs directly | 0 | 1 | must-have | I1 |
|
||||||
|
| I3 | Migrate fleet steering onto mosaic agent send --verify (exists, FLEET-OBS-005, spec FR-5) and retire tools/tmux/agent-send.sh, which forges the sender (D33) and returns an uninformative rc (D16, D34). FR-5 predates those defects by a month | 0 | 1 | must-have | I1 |
|
||||||
|
| I4 | mosaic fleet absorbs what launch-seat.sh does and launch-seat.sh is deprecated: compose the prompt file set, force the skill set, wire the style hook, fail closed on any unreadable input, export per-seat git identity, and register the seat in the roster. launch-seat.sh was a manual method; it is the reference implementation, not the destination | 0 | 1 | must-have | I1, I5 |
|
||||||
|
| I5 | Harness probe matrix — verify a working prompt-injection path for claude, codex, opencode and pi, and refuse any runtime whose path is unverified. AC-NS-0 clause 1 ("any configured harness") rests on this. The probe work in docs/plans/2026-08-19_launch-seat-multi-runtime.md (brain, untracked) transfers; its launch-seat.sh target does not | 0 | 1 | must-have | — |
|
||||||
|
| I6 | Finish the heartbeat responder (FLEET-OBS-002, the only Phase-2 task still in-progress; spec FR-2). Health must mean "answered a heartbeat", not "pane alive" — pane state measured two seats wrong on 2026-08-20 | 0 | 1 | must-have | — |
|
||||||
|
| I7 | Independent review and live-fleet dogfood of the Phase-2 verbs (FLEET-OBS-008), then land them (FLEET-OBS-009). Implementation is done and verification is not; "done" in a task file frozen five weeks is not evidence | 0 | 1 | must-have | I2, I3, I4, I6 |
|
||||||
|
| I8 | Neutralize misleading documentation — supersede headers on docs that state a stale status, a false blocker or a retired mechanism. Cheap, and it is tier 0 because a stale doc does not merely fail to help an agent, it actively misroutes one. Rebuilding the documentation is a separate and later job | 0 | 1 | must-have | — |
|
||||||
|
| I9 | Study t3code's agent-attach and multi-provider auth methods and record what transfers. Reference only — Mosaic implements its own within the stack, never adopts the code and never takes the dependency. Informs HOW I/J/K/L are built, not whether | 0 | 1 | should-have | — |
|
||||||
|
| J1 | Web control plane over the gateway — fleet visibility and steering in a browser, same data source as I2 | 1 | 3 | must-have | I2 |
|
||||||
|
| K1 | Desktop and mobile clients against the gateway, authenticated | 2 | 4 | must-have | J1 |
|
||||||
|
| L1 | Per-provider auth profiles with per-session selection | 2 | 4 | must-have | I4 |
|
||||||
|
|
||||||
## Assumptions (vetoable)
|
## Assumptions (vetoable)
|
||||||
|
|
||||||
|
|||||||
+129
-2
@@ -6,7 +6,7 @@
|
|||||||
# packages/mosaic/src/commands/fleet.ts (renderNorthStarMarkdown). Edit the YAML,
|
# packages/mosaic/src/commands/fleet.ts (renderNorthStarMarkdown). Edit the YAML,
|
||||||
# never the .md.
|
# never the .md.
|
||||||
#
|
#
|
||||||
# Self-contained Mosaic. NO Hermes runtime dependency. The backlog of record is
|
# Self-contained Mosaic. The backlog of record is
|
||||||
# the Mosaic Backlog on Mosaic's OWN native Postgres storage service.
|
# the Mosaic Backlog on Mosaic's OWN native Postgres storage service.
|
||||||
|
|
||||||
version: 1
|
version: 1
|
||||||
@@ -24,7 +24,7 @@ substrate:
|
|||||||
note: >-
|
note: >-
|
||||||
The Mosaic Backlog is the backlog of record + dispatch engine, built on
|
The Mosaic Backlog is the backlog of record + dispatch engine, built on
|
||||||
Mosaic's native Postgres storage service (@mosaicstack/db drizzle;
|
Mosaic's native Postgres storage service (@mosaicstack/db drizzle;
|
||||||
PGlite-embedded by default, full Postgres by config). NOT Hermes.
|
PGlite-embedded by default, full Postgres by config).
|
||||||
|
|
||||||
standing_objectives:
|
standing_objectives:
|
||||||
- id: NS-1
|
- id: NS-1
|
||||||
@@ -69,32 +69,53 @@ standing_objectives:
|
|||||||
business/operations) and the orchestrator provisions the matching persona
|
business/operations) and the orchestrator provisions the matching persona
|
||||||
roster and org structure from a cross-domain baseline persona library; the
|
roster and org structure from a cross-domain baseline persona library; the
|
||||||
delivery/coding fleet is one profile among many.
|
delivery/coding fleet is one profile among many.
|
||||||
|
- id: NS-10
|
||||||
|
text: >-
|
||||||
|
An adoption is not complete until the mechanism it replaces is removed.
|
||||||
|
Two live conventions for one concern is the defect, not a transition
|
||||||
|
state. Measured 2026-08-20: brain-home adopted by 9 modules and not 10;
|
||||||
|
MOSAIC_HOME honored in 4 places, each re-deriving it; backlog cards
|
||||||
|
declared while TASKS.md files stayed authoritative. Every one was decided
|
||||||
|
correctly and left half-applied.
|
||||||
|
|
||||||
success_criteria:
|
success_criteria:
|
||||||
|
- id: AC-NS-0
|
||||||
|
tier: 0
|
||||||
|
text: >-
|
||||||
|
The operator launches an agent on any configured harness with one
|
||||||
|
command, observes its state and sends it work without attaching to a
|
||||||
|
terminal multiplexer.
|
||||||
- id: AC-NS-1
|
- id: AC-NS-1
|
||||||
|
tier: 1
|
||||||
text: >-
|
text: >-
|
||||||
The supervisor keeps a two-agent floor (1 orchestrator + >=1 enhancer)
|
The supervisor keeps a two-agent floor (1 orchestrator + >=1 enhancer)
|
||||||
healthy across reboot.
|
healthy across reboot.
|
||||||
- id: AC-NS-2
|
- id: AC-NS-2
|
||||||
|
tier: 1
|
||||||
text: >-
|
text: >-
|
||||||
A goal added to this YAML is decomposed to cards and either merged or
|
A goal added to this YAML is decomposed to cards and either merged or
|
||||||
escalated, with no human in the loop.
|
escalated, with no human in the loop.
|
||||||
- id: AC-NS-3
|
- id: AC-NS-3
|
||||||
|
tier: 1
|
||||||
text: >-
|
text: >-
|
||||||
No PR merges with failure/error/no-status/timeout CI, and none bypass
|
No PR merges with failure/error/no-status/timeout CI, and none bypass
|
||||||
pr-merge.sh.
|
pr-merge.sh.
|
||||||
- id: AC-NS-4
|
- id: AC-NS-4
|
||||||
|
tier: 1
|
||||||
text: >-
|
text: >-
|
||||||
TTL is enforced on claims; token caps remain advisory until a real meter
|
TTL is enforced on claims; token caps remain advisory until a real meter
|
||||||
exists.
|
exists.
|
||||||
- id: AC-NS-5
|
- id: AC-NS-5
|
||||||
|
tier: 1
|
||||||
text: >-
|
text: >-
|
||||||
Flipping fleet/run/PAUSED halts dispatch and merges within one tick.
|
Flipping fleet/run/PAUSED halts dispatch and merges within one tick.
|
||||||
- id: AC-NS-6
|
- id: AC-NS-6
|
||||||
|
tier: 2
|
||||||
text: >-
|
text: >-
|
||||||
A user can declare a system type and the fleet provisions the matching
|
A user can declare a system type and the fleet provisions the matching
|
||||||
persona roster + topology from the baseline library, with no code change.
|
persona roster + topology from the baseline library, with no code change.
|
||||||
- id: AC-NS-7
|
- id: AC-NS-7
|
||||||
|
tier: 2
|
||||||
text: >-
|
text: >-
|
||||||
A user-customized persona (edited or added via the orchestrator) survives
|
A user-customized persona (edited or added via the orchestrator) survives
|
||||||
mosaic update: baseline reseed never clobbers user overrides.
|
mosaic update: baseline reseed never clobbers user overrides.
|
||||||
@@ -112,80 +133,186 @@ workstreams:
|
|||||||
title: Meta-loop — session-review + enhancer improvement PRs
|
title: Meta-loop — session-review + enhancer improvement PRs
|
||||||
- id: F
|
- id: F
|
||||||
title: Safety-rails — TTL claims, advisory spend, PAUSE kill-switch
|
title: Safety-rails — TTL claims, advisory spend, PAUSE kill-switch
|
||||||
|
- id: G
|
||||||
|
title: Kill-switch — operator PAUSE honored before dispatch and merge
|
||||||
- id: H
|
- id: H
|
||||||
title: Personas & system profiles — cross-domain library, system-type provisioning, update-surviving customization
|
title: Personas & system profiles — cross-domain library, system-type provisioning, update-surviving customization
|
||||||
|
- id: I
|
||||||
|
title: Operator surface — launcher, fleet visibility, reliable steering (tier 0)
|
||||||
|
- id: J
|
||||||
|
title: Web control plane — browser surface over the gateway (tier 1)
|
||||||
|
- id: K
|
||||||
|
title: Clients — desktop and mobile over the same backend (tier 2)
|
||||||
|
- id: L
|
||||||
|
title: Auth profiles — per-provider accounts, per-session selection (tier 2)
|
||||||
|
|
||||||
|
# NOTE: workstreams C, D, E and F are declared but currently project no goals.
|
||||||
|
# That is planning debt, not an editing error: their goals have not been written
|
||||||
|
# yet. The A5 validator below reports it rather than letting it stay invisible.
|
||||||
|
|
||||||
goals:
|
goals:
|
||||||
- id: A1
|
- id: A1
|
||||||
title: Machine-readable NORTH_STAR.yaml + Markdown projection
|
title: Machine-readable NORTH_STAR.yaml + Markdown projection
|
||||||
phase: 1
|
phase: 1
|
||||||
|
tier: 1
|
||||||
priority: must-have
|
priority: must-have
|
||||||
depends_on: []
|
depends_on: []
|
||||||
- id: A2
|
- id: A2
|
||||||
title: Mosaic Backlog schema + storage-service card store (drizzle/PGlite)
|
title: Mosaic Backlog schema + storage-service card store (drizzle/PGlite)
|
||||||
phase: 1
|
phase: 1
|
||||||
|
tier: 1
|
||||||
priority: must-have
|
priority: must-have
|
||||||
depends_on: [A1]
|
depends_on: [A1]
|
||||||
- id: A3a
|
- id: A3a
|
||||||
title: Card lifecycle — create/claim/release with stable ids + depends_on DAG
|
title: Card lifecycle — create/claim/release with stable ids + depends_on DAG
|
||||||
phase: 1
|
phase: 1
|
||||||
|
tier: 1
|
||||||
priority: must-have
|
priority: must-have
|
||||||
depends_on: [A2]
|
depends_on: [A2]
|
||||||
- id: A3b
|
- id: A3b
|
||||||
title: TTL-bounded claim enforcement (wall-clock) on cards
|
title: TTL-bounded claim enforcement (wall-clock) on cards
|
||||||
phase: 1
|
phase: 1
|
||||||
|
tier: 1
|
||||||
priority: must-have
|
priority: must-have
|
||||||
depends_on: [A3a]
|
depends_on: [A3a]
|
||||||
- id: A4
|
- id: A4
|
||||||
title: Advisory spend projection per card (degrades to TTL, no real meter)
|
title: Advisory spend projection per card (degrades to TTL, no real meter)
|
||||||
phase: 1
|
phase: 1
|
||||||
|
tier: 1
|
||||||
priority: should-have
|
priority: should-have
|
||||||
depends_on: [A3a]
|
depends_on: [A3a]
|
||||||
- id: B1
|
- id: B1
|
||||||
title: Supervisor tick — readiness scan, two-agent-floor health check
|
title: Supervisor tick — readiness scan, two-agent-floor health check
|
||||||
phase: 2
|
phase: 2
|
||||||
|
tier: 1
|
||||||
priority: must-have
|
priority: must-have
|
||||||
depends_on: [A3a]
|
depends_on: [A3a]
|
||||||
- id: B2
|
- id: B2
|
||||||
title: Native dispatch/claim — assign ready dependency-satisfied work
|
title: Native dispatch/claim — assign ready dependency-satisfied work
|
||||||
phase: 2
|
phase: 2
|
||||||
|
tier: 1
|
||||||
priority: must-have
|
priority: must-have
|
||||||
depends_on: [A3b, B1]
|
depends_on: [A3b, B1]
|
||||||
- id: B3a
|
- id: B3a
|
||||||
title: Planner decompose — goal added to YAML → cards
|
title: Planner decompose — goal added to YAML → cards
|
||||||
phase: 2
|
phase: 2
|
||||||
|
tier: 1
|
||||||
priority: must-have
|
priority: must-have
|
||||||
depends_on: [A2, B1]
|
depends_on: [A2, B1]
|
||||||
- id: B3b
|
- id: B3b
|
||||||
title: Replan request on empty backlog; escalate on no-decompose
|
title: Replan request on empty backlog; escalate on no-decompose
|
||||||
phase: 2
|
phase: 2
|
||||||
|
tier: 1
|
||||||
priority: should-have
|
priority: should-have
|
||||||
depends_on: [B3a]
|
depends_on: [B3a]
|
||||||
- id: G1
|
- id: G1
|
||||||
title: PAUSE kill-switch + merge-gate honored before dispatch and merge
|
title: PAUSE kill-switch + merge-gate honored before dispatch and merge
|
||||||
phase: 2
|
phase: 2
|
||||||
|
tier: 1
|
||||||
priority: must-have
|
priority: must-have
|
||||||
depends_on: [B2]
|
depends_on: [B2]
|
||||||
- id: H1
|
- id: H1
|
||||||
title: Cross-domain baseline persona library (exec, marketing, ops, research, assistant + engineering roles)
|
title: Cross-domain baseline persona library (exec, marketing, ops, research, assistant + engineering roles)
|
||||||
phase: 1
|
phase: 1
|
||||||
|
tier: 2
|
||||||
priority: must-have
|
priority: must-have
|
||||||
depends_on: [A1]
|
depends_on: [A1]
|
||||||
- id: H2
|
- id: H2
|
||||||
title: System-type profiles — declarative mapping of system type to persona roster + topology
|
title: System-type profiles — declarative mapping of system type to persona roster + topology
|
||||||
phase: 2
|
phase: 2
|
||||||
|
tier: 2
|
||||||
priority: must-have
|
priority: must-have
|
||||||
depends_on: [H1]
|
depends_on: [H1]
|
||||||
- id: H3
|
- id: H3
|
||||||
title: System-type provisioning — user declares type; orchestrator instantiates the matching roster + structure
|
title: System-type provisioning — user declares type; orchestrator instantiates the matching roster + structure
|
||||||
phase: 2
|
phase: 2
|
||||||
|
tier: 2
|
||||||
priority: must-have
|
priority: must-have
|
||||||
depends_on: [H2]
|
depends_on: [H2]
|
||||||
- id: H4
|
- id: H4
|
||||||
title: Update-surviving persona customization — ad-hoc edits/additions persisted in a PRESERVE-protected override layer (baseline merged with overrides)
|
title: Update-surviving persona customization — ad-hoc edits/additions persisted in a PRESERVE-protected override layer (baseline merged with overrides)
|
||||||
phase: 2
|
phase: 2
|
||||||
|
tier: 2
|
||||||
priority: must-have
|
priority: must-have
|
||||||
depends_on: [H1]
|
depends_on: [H1]
|
||||||
|
- id: A5
|
||||||
|
title: NORTH_STAR schema validator — every goal's workstream declared, every workstream has a goal, every depends_on id exists, every tier has a success criterion; runs in CI beside the Markdown regeneration check
|
||||||
|
phase: 1
|
||||||
|
tier: 0
|
||||||
|
priority: must-have
|
||||||
|
depends_on: [A1]
|
||||||
|
- id: I1
|
||||||
|
title: One home resolver — a single function resolving MOSAIC_HOME with a sane default, adopted by every module. Today brain-home.ts is imported by 9 modules while 10 still use DEFAULT_MOSAIC_HOME, and MOSAIC_HOME is re-derived ad hoc in 4 places. NS-10 applies - finish the adoption and delete the second path
|
||||||
|
phase: 1
|
||||||
|
tier: 0
|
||||||
|
priority: must-have
|
||||||
|
depends_on: []
|
||||||
|
- id: I2
|
||||||
|
title: 'mosaic fleet ps sees the fleet that is actually running. Three measured blockers: the roster declares socket `mosaic-fleet` which does not exist, the 18 live sessions are on the default socket, and nothing writes a roster because seats are launched outside the CLI. Make the socket configurable and the roster written at launch, or make ps read tmux + seat dirs directly'
|
||||||
|
phase: 1
|
||||||
|
tier: 0
|
||||||
|
priority: must-have
|
||||||
|
depends_on: [I1]
|
||||||
|
- id: I3
|
||||||
|
title: Migrate fleet steering onto mosaic agent send --verify (exists, FLEET-OBS-005, spec FR-5) and retire tools/tmux/agent-send.sh, which forges the sender (D33) and returns an uninformative rc (D16, D34). FR-5 predates those defects by a month
|
||||||
|
phase: 1
|
||||||
|
tier: 0
|
||||||
|
priority: must-have
|
||||||
|
depends_on: [I1]
|
||||||
|
- id: I4
|
||||||
|
title: 'mosaic fleet absorbs what launch-seat.sh does and launch-seat.sh is deprecated: compose the prompt file set, force the skill set, wire the style hook, fail closed on any unreadable input, export per-seat git identity, and register the seat in the roster. launch-seat.sh was a manual method; it is the reference implementation, not the destination'
|
||||||
|
phase: 1
|
||||||
|
tier: 0
|
||||||
|
priority: must-have
|
||||||
|
depends_on: [I1, I5]
|
||||||
|
- id: I5
|
||||||
|
title: Harness probe matrix — verify a working prompt-injection path for claude, codex, opencode and pi, and refuse any runtime whose path is unverified. AC-NS-0 clause 1 ("any configured harness") rests on this. The probe work in docs/plans/2026-08-19_launch-seat-multi-runtime.md (brain, untracked) transfers; its launch-seat.sh target does not
|
||||||
|
phase: 1
|
||||||
|
tier: 0
|
||||||
|
priority: must-have
|
||||||
|
depends_on: []
|
||||||
|
- id: I6
|
||||||
|
title: Finish the heartbeat responder (FLEET-OBS-002, the only Phase-2 task still in-progress; spec FR-2). Health must mean "answered a heartbeat", not "pane alive" — pane state measured two seats wrong on 2026-08-20
|
||||||
|
phase: 1
|
||||||
|
tier: 0
|
||||||
|
priority: must-have
|
||||||
|
depends_on: []
|
||||||
|
- id: I7
|
||||||
|
title: Independent review and live-fleet dogfood of the Phase-2 verbs (FLEET-OBS-008), then land them (FLEET-OBS-009). Implementation is done and verification is not; "done" in a task file frozen five weeks is not evidence
|
||||||
|
phase: 1
|
||||||
|
tier: 0
|
||||||
|
priority: must-have
|
||||||
|
depends_on: [I2, I3, I4, I6]
|
||||||
|
- id: I8
|
||||||
|
title: Neutralize misleading documentation — supersede headers on docs that state a stale status, a false blocker or a retired mechanism. Cheap, and it is tier 0 because a stale doc does not merely fail to help an agent, it actively misroutes one. Rebuilding the documentation is a separate and later job
|
||||||
|
phase: 1
|
||||||
|
tier: 0
|
||||||
|
priority: must-have
|
||||||
|
depends_on: []
|
||||||
|
- id: I9
|
||||||
|
title: Study t3code's agent-attach and multi-provider auth methods and record what transfers. Reference only — Mosaic implements its own within the stack, never adopts the code and never takes the dependency. Informs HOW I/J/K/L are built, not whether
|
||||||
|
phase: 1
|
||||||
|
tier: 0
|
||||||
|
priority: should-have
|
||||||
|
depends_on: []
|
||||||
|
- id: J1
|
||||||
|
title: Web control plane over the gateway — fleet visibility and steering in a browser, same data source as I2
|
||||||
|
phase: 3
|
||||||
|
tier: 1
|
||||||
|
priority: must-have
|
||||||
|
depends_on: [I2]
|
||||||
|
- id: K1
|
||||||
|
title: Desktop and mobile clients against the gateway, authenticated
|
||||||
|
phase: 4
|
||||||
|
tier: 2
|
||||||
|
priority: must-have
|
||||||
|
depends_on: [J1]
|
||||||
|
- id: L1
|
||||||
|
title: Per-provider auth profiles with per-session selection
|
||||||
|
phase: 4
|
||||||
|
tier: 2
|
||||||
|
priority: must-have
|
||||||
|
depends_on: [I4]
|
||||||
|
|
||||||
assumptions:
|
assumptions:
|
||||||
- id: ASM-1
|
- id: ASM-1
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
|
---
|
||||||
|
kind: spec
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# PRD — Mosaic Fleet Suite (init, configure, operate)
|
# PRD — Mosaic Fleet Suite (init, configure, operate)
|
||||||
|
|
||||||
> **Workstream:** W-FLEET (Fleet) under mission `mvp-20260312` · **Phase:** 3→4 productization
|
> **Workstream:** W-FLEET (Fleet) under mission `mvp-20260312` · **Phase:** 3→4 productization
|
||||||
> **North star:** [docs/fleet/north-star.md](./north-star.md) · prior: Phase-2 observability (#579), durable launch (#581), real-agent enablement (#583/#584/#586), releases 0.0.35–0.0.37
|
> **North star:** [docs/fleet/FLEET-DOCTRINE.md](./FLEET-DOCTRINE.md) · prior: Phase-2 observability (#579), durable launch (#581), real-agent enablement (#583/#584/#586), releases 0.0.35–0.0.37
|
||||||
> **Lead:** Jarvis @ `w-jarvis`. **Collaborator:** coder agent @ `dragon-lin` (jwoltje@10.1.10.37:coder0-0).
|
> **Lead:** Jarvis @ `w-jarvis`. **Collaborator:** coder agent @ `dragon-lin` (jwoltje@10.1.10.37:coder0-0).
|
||||||
> Owner of this file: Fleet workstream lead. Does not modify MVP single-writer control-plane files.
|
> Owner of this file: Fleet workstream lead. Does not modify MVP single-writer control-plane files.
|
||||||
|
|
||||||
|
|||||||
+6
-1
@@ -1,7 +1,12 @@
|
|||||||
|
---
|
||||||
|
kind: spec
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# PRD — Fleet Phase 2: Operator Observability
|
# PRD — Fleet Phase 2: Operator Observability
|
||||||
|
|
||||||
> **Workstream:** W-FLEET under `mvp-20260312` · **Phase:** 2
|
> **Workstream:** W-FLEET under `mvp-20260312` · **Phase:** 2
|
||||||
> **North star:** [docs/fleet/north-star.md](./north-star.md)
|
> **North star:** [docs/fleet/FLEET-DOCTRINE.md](./FLEET-DOCTRINE.md)
|
||||||
> **Source umbrella PRD:** [docs/PRD.md](../PRD.md) (Mosaic Stack v0.1.0)
|
> **Source umbrella PRD:** [docs/PRD.md](../PRD.md) (Mosaic Stack v0.1.0)
|
||||||
> **Tracks task:** `fleet-observability-1` — restore operator observability into fleet agent sessions.
|
> **Tracks task:** `fleet-observability-1` — restore operator observability into fleet agent sessions.
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -1,10 +1,25 @@
|
|||||||
# Tasks — W-FLEET (Fleet) Phase 2: Observability
|
# Tasks — W-FLEET (Fleet) Phase 2: Observability
|
||||||
|
|
||||||
|
> ---
|
||||||
|
>
|
||||||
|
> **STATUS: SUPERSEDED — 2026-08-20.** kind `tracking` · superseded by `docs/fleet/NORTH_STAR.yaml`
|
||||||
|
>
|
||||||
|
> This file is the pre-backlog tracking mechanism. `NS-2` in the north star declares the
|
||||||
|
> replacement: every backlog item is a Mosaic Backlog card projected from the YAML. That
|
||||||
|
> model replaced this one and nobody retired the old file, so it kept reading as
|
||||||
|
> authoritative while going stale.
|
||||||
|
>
|
||||||
|
> **Do not trust a status in this file.** Verified 2026-08-20: it was already behind the
|
||||||
|
> code when it froze five weeks ago. The `FLEET-OBS` series was the one thing worth salvaging and
|
||||||
|
> is now carried as goals `I2`, `I3`, `I6` and `I7` at tier 0.
|
||||||
|
>
|
||||||
|
> Kept as a record of what was believed. Do not update it; update the YAML.
|
||||||
|
|
||||||
> Workstream task file for the Fleet. Single-writer: Fleet workstream lead (orchestrator).
|
> Workstream task file for the Fleet. Single-writer: Fleet workstream lead (orchestrator).
|
||||||
> Workers read but never modify. This is **not** the MVP rollup (`docs/TASKS.md`) — a
|
> Workers read but never modify. This is **not** the MVP rollup (`docs/TASKS.md`) — a
|
||||||
> rollup row is proposed to the MVP orchestrator, not written here.
|
> rollup row is proposed to the MVP orchestrator, not written here.
|
||||||
>
|
>
|
||||||
> Mission: `mvp-20260312` · PRD: [docs/fleet/PRD.md](./PRD.md) · North star: [docs/fleet/north-star.md](./north-star.md)
|
> Mission: `mvp-20260312` · PRD: [docs/fleet/PRD.md](./PRD.md) · North star: [docs/fleet/FLEET-DOCTRINE.md](./FLEET-DOCTRINE.md)
|
||||||
> Status: `not-started` | `in-progress` | `done` | `blocked` | `failed`
|
> Status: `not-started` | `in-progress` | `done` | `blocked` | `failed`
|
||||||
|
|
||||||
| id | status | description | depends_on | agent | pr | notes |
|
| id | status | description | depends_on | agent | pr | notes |
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Desired, Derived, and Observed Fleet State
|
# Desired, Derived, and Observed Fleet State
|
||||||
|
|
||||||
## One writable authority
|
## One writable authority
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Generated Environment Launch Chain
|
# Generated Environment Launch Chain
|
||||||
|
|
||||||
The launcher consumes validated data, not shell configuration.
|
The launcher consumes validated data, not shell configuration.
|
||||||
|
|
||||||
1. Read and validate the canonical roster.
|
1. Read and validate the canonical roster.
|
||||||
2. Render deterministic <name>.env.generated data from that roster.
|
2. Render deterministic <name>.env.generated data from that roster, including `MOSAIC_GIT_IDENTITY` derived exactly from the roster agent name.
|
||||||
3. Parse optional <name>.env.local through a strict allowlist.
|
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.
|
4. Reject generated-key shadowing, unknown or sensitive-looking keys, unsafe paths/values, duplicates, malformed lines, shell syntax, and command overrides.
|
||||||
5. Derive the runtime command from validated runtime/model/reasoning data.
|
5. Reject a Git identity that is unsafe or differs from the generated agent name.
|
||||||
6. Target only the exact configured tmux socket and roster session after ownership checks.
|
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.
|
||||||
|
|
||||||
## File precedence and ownership
|
## File precedence and ownership
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Fleet Identity, Class, and Runtime
|
# Fleet Identity, Class, and Runtime
|
||||||
|
|
||||||
Each roster field has one job. Do not use names or model strings as authority shortcuts.
|
Each roster field has one job. Do not use names or model strings as authority shortcuts.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Fleet Role Authority and Leases
|
# Fleet Role Authority and Leases
|
||||||
|
|
||||||
Role content describes behavior; protected authority is immutable code metadata derived only from the canonical class.
|
Role content describes behavior; protected authority is immutable code metadata derived only from the canonical class.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# F4 — Orchestrator chat connector + Matrix (local homeserver)
|
# F4 — Orchestrator chat connector + Matrix (local homeserver)
|
||||||
|
|
||||||
> **Issue:** #616 · **Doctrine:** `docs/fleet/north-star.md` (#613) — orchestrator-chat-connector decision.
|
> **Issue:** #616 · **Doctrine:** `docs/fleet/FLEET-DOCTRINE.md` (#613) — orchestrator-chat-connector decision.
|
||||||
> **Status:** Phase 1 (abstraction + scaffold) in this PR; Phase 2+ are follow-ups (below).
|
> **Status:** Phase 1 (abstraction + scaffold) in this PR; Phase 2+ are follow-ups (below).
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Configure an Interaction Instance
|
# Configure an Interaction Instance
|
||||||
|
|
||||||
An interaction instance is a configurable local roster member with canonical class: interaction and matching tool_policy: interaction. “Tess” may be used as a display alias, but neither that alias nor the stable name is required or authority-bearing.
|
An interaction instance is a configurable local roster member with canonical class: interaction and matching tool_policy: interaction. “Tess” may be used as a display alias, but neither that alias nor the stable name is required or authority-bearing.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Configure a Validator Instance
|
# Configure a Validator Instance
|
||||||
|
|
||||||
A validator instance is a configurable local roster member with canonical class: validator and matching tool_policy: validator. “Ultron” may be used as a display alias, but it is not a required identity, class alias, product name, or source of authority.
|
A validator instance is a configurable local roster member with canonical class: validator and matching tool_policy: validator. “Ultron” may be used as a display alias, but it is not a required identity, class alias, product name, or source of authority.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Create, Inspect, Update, and Delete a Local Fleet Agent
|
# Create, Inspect, Update, and Delete a Local Fleet Agent
|
||||||
|
|
||||||
Use the local roster-v2 control plane only. These commands change desired state and derived environment projections; they never start, stop, reconcile, inspect, or otherwise act on systemd, tmux, sessions, or runtimes.
|
Use the local roster-v2 control plane only. These commands change desired state and derived environment projections; they never start, stop, reconcile, inspect, or otherwise act on systemd, tmux, sessions, or runtimes.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Customize Fleet Roles
|
# Customize Fleet Roles
|
||||||
|
|
||||||
Mosaic resolves persona contracts through two layers:
|
Mosaic resolves persona contracts through two layers:
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Safely Reconcile and Control a Local Fleet Agent
|
# Safely Reconcile and Control a Local Fleet Agent
|
||||||
|
|
||||||
Use the canonical local roster-v2 command surface:
|
Use the canonical local roster-v2 command surface:
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Executable Fleet Example, Profile, and Service-Preset Dispositions
|
# Executable Fleet Example, Profile, and Service-Preset Dispositions
|
||||||
|
|
||||||
**Issue:** #758 · **Card:** FCM-M1-003 · **Status:** M1 executable disposition evidence
|
**Issue:** #758 · **Card:** FCM-M1-003 · **Status:** M1 executable disposition evidence
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Legacy Fleet Class Aliases
|
# Legacy Fleet Class Aliases
|
||||||
|
|
||||||
Fleet class compatibility is intentionally narrow. The shared resolver accepts exactly three legacy
|
Fleet class compatibility is intentionally narrow. The shared resolver accepts exactly three legacy
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Previewing a Fleet Roster v1-to-v2 Migration
|
# Previewing a Fleet Roster v1-to-v2 Migration
|
||||||
|
|
||||||
**Issue:** #758 · **Card:** FCM-M4-001 · **Effect boundary:** preview only
|
**Issue:** #758 · **Card:** FCM-M4-001 · **Effect boundary:** preview only
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Fleet Configuration Backup and Restore Boundary
|
# Fleet Configuration Backup and Restore Boundary
|
||||||
|
|
||||||
**Issue:** #758 · **Card:** FCM-M4-001
|
**Issue:** #758 · **Card:** FCM-M4-001
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Environment Quarantine Operations
|
# Environment Quarantine Operations
|
||||||
|
|
||||||
Legacy <name>.env is input evidence, never current launch authority. Projection preparation classifies it deterministically:
|
Legacy <name>.env is input evidence, never current launch authority. Projection preparation classifies it deterministically:
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Reconcile and Recover a Local Fleet
|
# Reconcile and Recover a Local Fleet
|
||||||
|
|
||||||
## Safe sequence
|
## Safe sequence
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Systemd and tmux Troubleshooting
|
# Systemd and tmux Troubleshooting
|
||||||
|
|
||||||
Start with read-only mosaic fleet status, `doctor`, and `verify`. Do not manually adopt, rename, terminate, or recreate sessions while ownership is ambiguous.
|
Start with read-only mosaic fleet status, `doctor`, and `verify`. Do not manually adopt, rename, terminate, or recreate sessions while ownership is ambiguous.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Upgrade and Installed-Asset Drift
|
# Upgrade and Installed-Asset Drift
|
||||||
|
|
||||||
Fleet source assets and installed assets can differ after an update, but FCM-M5-001 does not add a trustworthy source-versus-installed revision detector or refresh command. Do not infer freshness from checkout presence, timestamps, generated environment files, running sessions, or a ready migration preview.
|
Fleet source assets and installed assets can differ after an update, but FCM-M5-001 does not add a trustworthy source-versus-installed revision detector or refresh command. Do not infer freshness from checkout presence, timestamps, generated environment files, running sessions, or a ready migration preview.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Local Fleet Agent Mutations
|
# Local Fleet Agent Mutations
|
||||||
|
|
||||||
FCM-M2-002 provides local roster-v2 create, get, update, delete, and plan operations. They only change desired state and derived environment projections. They never start, stop, inspect, reconcile, or otherwise act on runtimes, systemd units, tmux sessions, or heartbeats.
|
FCM-M2-002 provides local roster-v2 create, get, update, delete, and plan operations. They only change desired state and derived environment projections. They never start, stop, inspect, reconcile, or otherwise act on runtimes, systemd units, tmux sessions, or heartbeats.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Fleet Control-Plane CLI
|
# Fleet Control-Plane CLI
|
||||||
|
|
||||||
The local desired-state surface is mosaic fleet. It is distinct from the gateway-backed mosaic agent catalog and from legacy compatibility commands that act on roster v1.
|
The local desired-state surface is mosaic fleet. It is distinct from the gateway-backed mosaic agent catalog and from legacy compatibility commands that act on roster v1.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Fleet Generated Environment Boundary
|
# Fleet Generated Environment Boundary
|
||||||
|
|
||||||
**Card:** FCM-M2-001 · **Issue:** #758 · **Status:** merged contract
|
**Card:** FCM-M2-001 · **Issue:** #758 · **Status:** merged contract
|
||||||
@@ -35,6 +40,7 @@ values, credential material, or command text.
|
|||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
MOSAIC_AGENT_NAME=<roster name>
|
MOSAIC_AGENT_NAME=<roster name>
|
||||||
|
MOSAIC_GIT_IDENTITY=<roster name>
|
||||||
MOSAIC_AGENT_CLASS=<roster class>
|
MOSAIC_AGENT_CLASS=<roster class>
|
||||||
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
MOSAIC_AGENT_RUNTIME=<roster runtime>
|
||||||
MOSAIC_AGENT_MODEL=<roster model hint>
|
MOSAIC_AGENT_MODEL=<roster model hint>
|
||||||
@@ -44,8 +50,9 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
|||||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||||
```
|
```
|
||||||
|
|
||||||
The generated launch contract supports only `claude`, `codex`, `opencode`, and `pi`. fleet add
|
`MOSAIC_GIT_IDENTITY` is derived from and must equal `MOSAIC_AGENT_NAME`; it is not a separate
|
||||||
uses that same runtime authority and rejects any other runtime before it writes the roster or changes
|
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
|
||||||
projection, local, or quarantine files. The legacy dogfood stub on its separate `mosaic-factory`
|
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
|
socket remains an observability canary; it has no generated-launch adapter and cannot be added through
|
||||||
this projection path.
|
this projection path.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Local Fleet Lifecycle Transitions
|
# Local Fleet Lifecycle Transitions
|
||||||
|
|
||||||
Roster-v2 `lifecycle.enabled` and `lifecycle.desired_state` are the only persisted lifecycle authority. Systemd, tmux, generated environment, and heartbeat state are derived or observed.
|
Roster-v2 `lifecycle.enabled` and `lifecycle.desired_state` are the only persisted lifecycle authority. Systemd, tmux, generated environment, and heartbeat state are derived or observed.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Fleet Role Classes and Authority
|
# Fleet Role Classes and Authority
|
||||||
|
|
||||||
A fleet role class is a machine identity resolved from the persona library. Resolution uses the
|
A fleet role class is a machine identity resolved from the persona library. Resolution uses the
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Fleet Roster v2 Structural Contract
|
# Fleet Roster v2 Structural Contract
|
||||||
|
|
||||||
**Status:** FCM-M1-001 local-tmux structural compiler contract. This document describes parsing,
|
**Status:** FCM-M1-001 local-tmux structural compiler contract. This document describes parsing,
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Local Fleet Status and Drift
|
# Local Fleet Status and Drift
|
||||||
|
|
||||||
mosaic fleet status [<name>], `verify`, and `doctor` are observational roster-v2 commands. They emit one JSON result and do not write projections, mutate desired state, operate lifecycle, or change tmux.
|
mosaic fleet status [<name>], `verify`, and `doctor` are observational roster-v2 commands. They emit one JSON result and do not write projections, mutate desired state, operate lifecycle, or change tmux.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Deployment Guide
|
# Deployment Guide
|
||||||
|
|
||||||
> **Status: non-operative for PostgreSQL, federated, and bare-metal production.** The checked-in
|
> **Status: non-operative for PostgreSQL, federated, and bare-metal production.** The checked-in
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Mosaic Stack — Developer Guide
|
# Mosaic Stack — Developer Guide
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
@@ -9,8 +14,9 @@
|
|||||||
5. [Adding New MCP Tools](#adding-new-mcp-tools)
|
5. [Adding New MCP Tools](#adding-new-mcp-tools)
|
||||||
6. [Database Schema and Migrations](#database-schema-and-migrations)
|
6. [Database Schema and Migrations](#database-schema-and-migrations)
|
||||||
7. [Claude Code Skill Bridge](#claude-code-skill-bridge)
|
7. [Claude Code Skill Bridge](#claude-code-skill-bridge)
|
||||||
8. [API Endpoint Reference](#api-endpoint-reference)
|
8. [Pi Persistent Goal Extension](#pi-persistent-goal-extension)
|
||||||
9. [Local Fleet Canary](./fleet-local-canary.md)
|
9. [API Endpoint Reference](#api-endpoint-reference)
|
||||||
|
10. [Local Fleet Canary](./fleet-local-canary.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -396,6 +402,85 @@ 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
|
canonical root directly. Codex still relies on the existing full skill-sync
|
||||||
linker and needs separate parity analysis before this lifecycle API is extended.
|
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
|
## API Endpoint Reference
|
||||||
|
|
||||||
All endpoints are served by the gateway at `http://localhost:14242` by default.
|
All endpoints are served by the gateway at `http://localhost:14242` by default.
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Local Fleet Canary
|
# Local Fleet Canary
|
||||||
|
|
||||||
The local fleet canary runs a small tmux-backed Mosaic agent fleet on an
|
The local fleet canary runs a small tmux-backed Mosaic agent fleet on an
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: guide
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Migrating to the Federated Tier
|
# Migrating to the Federated Tier
|
||||||
|
|
||||||
> **KBN-101-07 ownership:** This active documentation is a **non-operative KBN-101
|
> **KBN-101-07 ownership:** This active documentation is a **non-operative KBN-101
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
---
|
||||||
|
kind: tracking
|
||||||
|
status: active
|
||||||
|
---
|
||||||
|
|
||||||
# Mission Manifest — Mosaic Native Kanban and Canonical Task SOT P0–P3
|
# Mission Manifest — Mosaic Native Kanban and Canonical Task SOT P0–P3
|
||||||
|
|
||||||
**Mission status:** CANON INDEPENDENTLY APPROVED; publication in progress under issue [#751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
|
**Mission status:** CANON INDEPENDENTLY APPROVED; publication in progress under issue [#751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user