Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7a6179a58 | ||
|
|
06c714ddf3 | ||
|
|
cb2bf4e4a4 |
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": 2,
|
|
||||||
"integration_trunk": "next",
|
|
||||||
"release_branch": "main",
|
|
||||||
"flow": "trunk-release",
|
|
||||||
"canonical_remote": "https://git.mosaicstack.dev/mosaicstack/stack",
|
|
||||||
"canonical_clone": "host:/src/mosaic-stack",
|
|
||||||
"worktree_root": "host:/src/mosaic-stack-worktrees",
|
|
||||||
"worktree_policy": "orchestrator-precreated",
|
|
||||||
"notes": "next=development/integration; main=production release. Never branch work off main. worktree_policy is TRANSITIONAL: the wrapper worktree consumer is BLOCKED on the J3/#1174 amendment (checked roots + capacity guard); pre-creation is the interim orchestration choice, not closed policy — it becomes a timing choice only after the wrapper can validate this root."
|
|
||||||
}
|
|
||||||
@@ -22,9 +22,9 @@ steps:
|
|||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: REGISTRY_USERNAME
|
from_secret: gitea_username
|
||||||
REGISTRY_PASS:
|
REGISTRY_PASS:
|
||||||
from_secret: REGISTRY_PASSWORD
|
from_secret: gitea_password
|
||||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||||
|
|||||||
+11
-123
@@ -2,48 +2,24 @@
|
|||||||
# 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:lock-9cb7ffcd8828'
|
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
|
||||||
- &enable_pnpm 'corepack enable'
|
- &enable_pnpm 'corepack enable'
|
||||||
|
|
||||||
when:
|
when:
|
||||||
# PR + manual CI run on any branch: the pull_request pipeline is the merge
|
# PR + manual CI run on any branch — the pull_request pipeline is the merge gate.
|
||||||
# gate (next is protected and the default branch since 2026-08-19).
|
# push CI is restricted to protected branches (main) so a feature-branch push no
|
||||||
# Push CI runs on main only. next deliberately runs NO push ci: post-merge
|
# longer fires a redundant SECOND pipeline alongside its PR pipeline. This ~halves
|
||||||
# verification on next is carried by publish.yml's `verify` step
|
# CI load on the storage-constrained runner with zero loss of gating (branch
|
||||||
# (pnpm verify:release), which mirrors this pipeline's complete mandatory
|
# protection requires no push/ci status context; main still gets full push CI).
|
||||||
# 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
|
||||||
|
|
||||||
# Turbo remote cache (turbo.mosaicstack.dev) is wired in publish.yml via the
|
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
|
||||||
# org-level Woodpecker secret `turbo_token` (events: push/tag/cron/manual/
|
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
|
||||||
# deployment — never pull_request). This PR pipeline deliberately gets no
|
# This avoids from_secret which is blocked on pull_request events.
|
||||||
# remote-cache credentials: an untrusted PR must not be able to write to (or
|
# If the env vars aren't set, turbo falls back to local cache only.
|
||||||
# poison) the shared cache. Without TURBO_* env vars turbo falls back to
|
|
||||||
# local cache only, which is the intended behavior here.
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
install:
|
install:
|
||||||
@@ -54,19 +30,6 @@ steps:
|
|||||||
# the baked pnpm store.
|
# the baked pnpm store.
|
||||||
- pnpm install --frozen-lockfile --prefer-offline
|
- pnpm install --frozen-lockfile --prefer-offline
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# The steps below (sanitization, upgrade-guard, typecheck, lint, format,
|
|
||||||
# test) are the COMPLETE mandatory verification set. SDLC-D-034 mirrors them
|
|
||||||
# one-for-one in the canonical terminal verification command — root
|
|
||||||
# `pnpm verify:release` (scripts/verify-release.mjs) — which the publish
|
|
||||||
# pipeline (.woodpecker/publish.yml `verify` step) runs before ANY publish
|
|
||||||
# effect. These lines stay direct (not routed through the runner) because the
|
|
||||||
# #1017 test-enumeration guard audits framework tool paths through THIS
|
|
||||||
# surface; scripts/verify-release.test.mjs enforces that the runner's stage
|
|
||||||
# table keeps matching these commands exactly, so the two cannot drift.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# Canonical verify:release stage `sanitization`.
|
|
||||||
# Blocking gate: public framework package must contain no operator-specific
|
# Blocking gate: public framework package must contain no operator-specific
|
||||||
# personal data or private $HOME defaults. Runs early (no node_modules needed).
|
# personal data or private $HOME defaults. Runs early (no node_modules needed).
|
||||||
sanitization:
|
sanitization:
|
||||||
@@ -83,73 +46,7 @@ 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 regression for the git identity ladder (#1356): mock tea on PATH,
|
|
||||||
# sandboxed repo, no real credentials (3/3 green under an empty HOME). Pins
|
|
||||||
# fail-closed: a seat whose login is missing gets a named error, never a
|
|
||||||
# borrowed identity. Joins CI directly; its #1007 exclusion is burned down.
|
|
||||||
- bash packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh
|
|
||||||
# Hermetic regression for issue-view.sh (#1357): mock tea/curl, sandboxed
|
|
||||||
# repo. Pins that comment BODIES render on both paths and that a tea
|
|
||||||
# failure is named as what it was (git-config vs credential).
|
|
||||||
- bash packages/mosaic/framework/tools/git/test-issue-view-comments.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 repo-structure declaration gate (T51 WP5c, spec §5.4 point 2):
|
|
||||||
# .mosaic/repo.json is the machine-readable structure SSOT consumed by git
|
|
||||||
# wrappers and the T32 gate seat; this is its repo-side CI enforcement.
|
|
||||||
# Path-conditional: runs when the declaration, the vendored validator, or this
|
|
||||||
# pipeline config changes (manual runs always include it). Fails the pipeline
|
|
||||||
# on any VALIDATION_ERROR and enforces the schema_version 2 authoring rule
|
|
||||||
# (--require-v2: edited/new declarations may not stay v1). The validator is
|
|
||||||
# vendored into the framework tree (spec §5.1 final home) — provenance in its
|
|
||||||
# header; the hostile-input suite (101 arms, hermetic) runs alongside so the
|
|
||||||
# gate's own instrument ships in the same commit as the gate.
|
|
||||||
structure-declaration:
|
|
||||||
image: *node_image
|
|
||||||
commands:
|
|
||||||
- apk add --no-cache bash git
|
|
||||||
# MOSAIC_HOST_ROOT is a runtime anchor (spec §1.2a: unset fails closed
|
|
||||||
# for managed validation). CI has no host, so the step provisions an
|
|
||||||
# EXPLICIT fixture root — honest configuration for the resolution path,
|
|
||||||
# never a guess about a real host; the per-host containment checks are
|
|
||||||
# runtime concerns and do not run against a fixture. Grammar, schema,
|
|
||||||
# refs, flow, remote normalization, and path grammar all prove here.
|
|
||||||
- mkdir -p /tmp/t51-ci-hostroot
|
|
||||||
- bash packages/mosaic/framework/tools/structure/validate-repo-json.sh .mosaic/repo.json --require-v2
|
|
||||||
- bash packages/mosaic/framework/tools/structure/test-validate-repo-json.sh
|
|
||||||
environment:
|
|
||||||
MOSAIC_HOST_ROOT: /tmp/t51-ci-hostroot
|
|
||||||
when:
|
|
||||||
- event: pull_request
|
|
||||||
path:
|
|
||||||
include:
|
|
||||||
- '.mosaic/repo.json'
|
|
||||||
- 'packages/mosaic/framework/tools/structure/**'
|
|
||||||
- '.woodpecker/ci.yml'
|
|
||||||
- event: manual
|
|
||||||
|
|
||||||
# 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 —
|
||||||
@@ -171,8 +68,6 @@ steps:
|
|||||||
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh
|
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
|
- bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
|
||||||
|
|
||||||
# Canonical verify:release stage `typecheck` — the same `pnpm typecheck`
|
|
||||||
# invocation (which runs the checkout preflight first, then turbo).
|
|
||||||
typecheck:
|
typecheck:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
commands:
|
commands:
|
||||||
@@ -183,8 +78,7 @@ steps:
|
|||||||
- sanitization
|
- sanitization
|
||||||
- upgrade-guard
|
- upgrade-guard
|
||||||
|
|
||||||
# lint, format, and test are independent — run in parallel after typecheck.
|
# lint, format, and test are independent — run in parallel after typecheck
|
||||||
# Each runs exactly its canonical verify:release stage command.
|
|
||||||
lint:
|
lint:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
commands:
|
commands:
|
||||||
@@ -201,12 +95,6 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- typecheck
|
- typecheck
|
||||||
|
|
||||||
# Canonical verify:release stage `test` — the `pnpm test` line below is the
|
|
||||||
# shared command; everything else in this step is PIPELINE-LEVEL
|
|
||||||
# prerequisite the canonical command expects its caller to provide (SDLC-D-034):
|
|
||||||
# the ci-postgres service + pg_isready wait + db:migrate (postgres path),
|
|
||||||
# `apk add openssl`, and the pinned pi install. None of those can move into
|
|
||||||
# the runner (it must also work locally on the PGlite path with no database).
|
|
||||||
test:
|
test:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
+28
-288
@@ -1,29 +1,10 @@
|
|||||||
# 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.
|
||||||
# PINNED to the immutable lock-tag, not :latest (#1328, brain D27): a mutable
|
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
|
||||||
# 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
|
||||||
@@ -32,11 +13,6 @@ variables:
|
|||||||
# non-excluded change still builds, so no transitive dep can silently go stale.
|
# non-excluded change still builds, so no transitive dep can silently go stale.
|
||||||
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
|
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
|
||||||
# the separate `event: tag` entry.)
|
# the separate `event: tag` entry.)
|
||||||
# #1407: ONE shared anchor for all three image steps. A second main-only
|
|
||||||
# anchor previously gated build-web/build-appservice, so next-lane pushes
|
|
||||||
# published gateway sha images with no web/appservice counterpart — no
|
|
||||||
# sha-parity set existed for next-lane containerized deploys. Every image
|
|
||||||
# step now builds on next too (sha-only destinations, enforced per step).
|
|
||||||
- &image_build_when
|
- &image_build_when
|
||||||
- event: tag
|
- event: tag
|
||||||
- event: [push, manual]
|
- event: [push, manual]
|
||||||
@@ -49,6 +25,16 @@ variables:
|
|||||||
- '.woodpecker/**'
|
- '.woodpecker/**'
|
||||||
- event: [push, manual]
|
- event: [push, manual]
|
||||||
branch: next
|
branch: next
|
||||||
|
- &main_image_build_when
|
||||||
|
- event: tag
|
||||||
|
- event: [push, manual]
|
||||||
|
branch: main
|
||||||
|
path:
|
||||||
|
exclude:
|
||||||
|
- 'packages/mosaic/**'
|
||||||
|
- 'docs/**'
|
||||||
|
- '**/*.md'
|
||||||
|
- '.woodpecker/**'
|
||||||
|
|
||||||
when:
|
when:
|
||||||
- branch: [main, next]
|
- branch: [main, next]
|
||||||
@@ -62,67 +48,13 @@ 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
|
|
||||||
environment:
|
|
||||||
# Turbo remote cache (see .woodpecker/ci.yml header comment): org-level
|
|
||||||
# secret, exposed only on trusted events (push/tag/cron/manual/deployment).
|
|
||||||
TURBO_API: https://turbo.mosaicstack.dev
|
|
||||||
TURBO_TEAM: mosaic
|
|
||||||
TURBO_TOKEN:
|
|
||||||
from_secret: turbo_token
|
|
||||||
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
|
||||||
environment:
|
|
||||||
# Turbo remote cache (see .woodpecker/ci.yml header comment): org-level
|
|
||||||
# secret, exposed only on trusted events (push/tag/cron/manual/deployment).
|
|
||||||
TURBO_API: https://turbo.mosaicstack.dev
|
|
||||||
TURBO_TEAM: mosaic
|
|
||||||
TURBO_TOKEN:
|
|
||||||
from_secret: turbo_token
|
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *enable_pnpm
|
||||||
- pnpm build
|
- pnpm build
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
- verify
|
|
||||||
|
|
||||||
publish-npm:
|
publish-npm:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
@@ -182,7 +114,6 @@ steps:
|
|||||||
exit 1
|
exit 1
|
||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
- verify
|
|
||||||
|
|
||||||
publish-next-npm:
|
publish-next-npm:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
@@ -211,20 +142,6 @@ steps:
|
|||||||
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
|
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
|
||||||
DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)"
|
DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)"
|
||||||
DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);'
|
DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);'
|
||||||
# #1404: snapshot every publishable manifest BEFORE the transform so the
|
|
||||||
# workspace can be restored byte-exact after publish. The transform
|
|
||||||
# rewrites package.json in place (needed: pnpm publish reads the
|
|
||||||
# workspace manifests); without restore, later steps in this pipeline
|
|
||||||
# (build-gateway kaniko COPY + pnpm install --frozen-lockfile) see
|
|
||||||
# manifests that no longer match pnpm-lock.yaml and fail
|
|
||||||
# ERR_PNPM_OUTDATED_LOCKFILE. Snapshot dir is step-local tmp.
|
|
||||||
SNAPSHOT_DIR="$(mktemp -d /tmp/publish-next-manifests.XXXXXX)"
|
|
||||||
export SNAPSHOT_DIR
|
|
||||||
find apps packages plugins -name package.json -not -path "*/node_modules/*" -not -path "*/dist/*" | while read -r mf; do
|
|
||||||
mkdir -p "$SNAPSHOT_DIR/$(dirname "$mf")"
|
|
||||||
cp -p "$mf" "$SNAPSHOT_DIR/$mf"
|
|
||||||
done
|
|
||||||
echo "[publish-next] snapshotted $(find "$SNAPSHOT_DIR" -name package.json | wc -l) manifests to $SNAPSHOT_DIR"
|
|
||||||
node <<'NODE'
|
node <<'NODE'
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
@@ -232,38 +149,23 @@ steps:
|
|||||||
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
|
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
|
||||||
const roots = ['apps', 'packages', 'plugins'];
|
const roots = ['apps', 'packages', 'plugins'];
|
||||||
const updated = [];
|
const updated = [];
|
||||||
const exactVersions = new Map(); // name -> bumped next version
|
|
||||||
|
|
||||||
function walk(dir, visit) {
|
function walk(dir) {
|
||||||
if (!fs.existsSync(dir)) return;
|
if (!fs.existsSync(dir)) return;
|
||||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
|
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
|
||||||
const fullPath = path.join(dir, entry.name);
|
const fullPath = path.join(dir, entry.name);
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
const packagePath = path.join(fullPath, 'package.json');
|
const packagePath = path.join(fullPath, 'package.json');
|
||||||
if (fs.existsSync(packagePath)) {
|
if (fs.existsSync(packagePath)) updatePackage(packagePath);
|
||||||
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
walk(fullPath);
|
||||||
if (manifest.name?.startsWith('@mosaicstack/') && !manifest.private) {
|
|
||||||
visit(manifest, packagePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
walk(fullPath, visit);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// #1389: two passes. Pass 1 bumps every publishable manifest to
|
function updatePackage(packagePath) {
|
||||||
// <stable+1>-next.<pipeline> exactly as before, recording name ->
|
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
||||||
// bumped version. Pass 2 rewrites every published manifest's
|
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
|
||||||
// @mosaicstack/* dependency entries (dependencies, devDependencies,
|
|
||||||
// peerDependencies, optionalDependencies) to the EXACT same-pipeline
|
|
||||||
// build. A caret range like ^0.0.3-next.2636 leaves the resolver free
|
|
||||||
// to pick any later build — and on a host with a stale cache, an
|
|
||||||
// installer-side scaffold pinned at stable, or a registry hiccup, that
|
|
||||||
// freedom is how a "next" install ends up executing stable-era code
|
|
||||||
// (web1 evidence: old tier validator, missing migrations). Exact pins
|
|
||||||
// make the defect class unrepresentable regardless of resolver path.
|
|
||||||
function bump(manifest, packagePath) {
|
|
||||||
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
|
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
|
||||||
if (!stableMatch) {
|
if (!stableMatch) {
|
||||||
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
|
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
|
||||||
@@ -272,40 +174,13 @@ steps:
|
|||||||
const oldVersion = manifest.version;
|
const oldVersion = manifest.version;
|
||||||
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
|
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
|
||||||
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
|
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
|
||||||
exactVersions.set(manifest.name, manifest.version);
|
|
||||||
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
|
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEP_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
|
for (const root of roots) walk(root);
|
||||||
let pinnedEntries = 0;
|
|
||||||
function pin(manifest, packagePath) {
|
|
||||||
let changed = false;
|
|
||||||
for (const field of DEP_FIELDS) {
|
|
||||||
const deps = manifest[field];
|
|
||||||
if (!deps || typeof deps !== 'object') continue;
|
|
||||||
for (const [name, range] of Object.entries(deps)) {
|
|
||||||
if (!name.startsWith('@mosaicstack/')) continue;
|
|
||||||
const exact = exactVersions.get(name);
|
|
||||||
if (!exact) {
|
|
||||||
throw new Error(
|
|
||||||
manifest.name + ' depends on ' + name +
|
|
||||||
' which has no bumped version in this publish set — cannot pin');
|
|
||||||
}
|
|
||||||
if (range === exact) continue;
|
|
||||||
deps[name] = exact;
|
|
||||||
pinnedEntries++;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (changed) fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const root of roots) walk(root, bump);
|
|
||||||
for (const root of roots) walk(root, pin);
|
|
||||||
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
|
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
|
||||||
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
|
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
|
||||||
for (const line of updated) console.log('[publish-next] ' + line);
|
for (const line of updated) console.log('[publish-next] ' + line);
|
||||||
console.log('[publish-next] pinned ' + pinnedEntries + ' @mosaicstack/* dep entries to exact same-pipeline versions across ' + updated.length + ' manifests');
|
|
||||||
NODE
|
NODE
|
||||||
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
|
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
|
||||||
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
|
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
|
||||||
@@ -315,97 +190,8 @@ steps:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
|
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
|
||||||
# #1389 post-publish guard: every freshly published manifest must carry
|
|
||||||
# EXACT same-pipeline @mosaicstack/* dep pins (no ranges, no stable
|
|
||||||
# fallback). A leak here fails the pipeline instead of shipping.
|
|
||||||
node <<'GUARD'
|
|
||||||
const { execFileSync } = require('node:child_process');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
|
|
||||||
const registry = 'https://git.mosaicstack.dev/api/packages/mosaicstack/npm/';
|
|
||||||
const roots = ['apps', 'packages', 'plugins'];
|
|
||||||
const published = [];
|
|
||||||
function walk(dir) {
|
|
||||||
if (!fs.existsSync(dir)) return;
|
|
||||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
||||||
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
|
|
||||||
const fullPath = path.join(dir, entry.name);
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
const packagePath = path.join(fullPath, 'package.json');
|
|
||||||
if (fs.existsSync(packagePath)) {
|
|
||||||
const m = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
||||||
if (m.name?.startsWith('@mosaicstack/') && !m.private) published.push(m.name);
|
|
||||||
}
|
|
||||||
walk(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const root of roots) walk(root);
|
|
||||||
let failures = 0;
|
|
||||||
for (const name of published) {
|
|
||||||
let manifest;
|
|
||||||
try {
|
|
||||||
const out = execFileSync('npm', ['view', name + '@next', '--json', '--registry', registry],
|
|
||||||
{ encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
|
|
||||||
const arr = JSON.parse(out);
|
|
||||||
manifest = Array.isArray(arr) ? arr[arr.length - 1] : arr;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[publish-next-guard] FAIL ' + name + ': npm view failed: ' + e.message);
|
|
||||||
failures++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const fields = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
|
|
||||||
for (const field of fields) {
|
|
||||||
const deps = manifest[field];
|
|
||||||
if (!deps || typeof deps !== 'object') continue;
|
|
||||||
for (const [dep, range] of Object.entries(deps)) {
|
|
||||||
if (!dep.startsWith('@mosaicstack/')) continue;
|
|
||||||
const expected = dep === name ? manifest.version : null;
|
|
||||||
const isExactPin = /^\d+\.\d+\.\d+-next\./.test(range);
|
|
||||||
const samePipeline = range.endsWith('-next.' + pipelineNumber);
|
|
||||||
if (!isExactPin) {
|
|
||||||
console.error('[publish-next-guard] FAIL ' + name + ' -> ' + dep + ' range "' + range + '" is not an exact -next pin (stable-leak class, #1389)');
|
|
||||||
failures++;
|
|
||||||
} else if (!samePipeline) {
|
|
||||||
console.error('[publish-next-guard] FAIL ' + name + ' -> ' + dep + ' pinned "' + range + '" but this pipeline published -next.' + pipelineNumber + ' (cross-pipeline pin)');
|
|
||||||
failures++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (failures > 0) {
|
|
||||||
console.error('[publish-next-guard] FATAL: ' + failures + ' dep-pin violation(s) — stable-dep leak into next publish (#1389)');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log('[publish-next-guard] OK: all ' + published.length + ' published manifests carry exact same-pipeline @mosaicstack/* dep pins');
|
|
||||||
GUARD
|
|
||||||
# #1404 restore: put the workspace manifests back byte-exact so later
|
|
||||||
# steps (build-gateway frozen-lockfile install) see the committed tree.
|
|
||||||
RESTORE_FAIL=0
|
|
||||||
while read -r mf; do
|
|
||||||
if [ -f "$SNAPSHOT_DIR/$mf" ]; then
|
|
||||||
cp -p "$SNAPSHOT_DIR/$mf" "$mf"
|
|
||||||
else
|
|
||||||
echo "[publish-next] FATAL: no snapshot for $mf — cannot restore (snapshot incomplete?)" >&2
|
|
||||||
RESTORE_FAIL=1
|
|
||||||
fi
|
|
||||||
done < <(find apps packages plugins -name package.json -not -path "*/node_modules/*" -not -path "*/dist/*")
|
|
||||||
# Pristine guard (#1404 red-first control): the publish step must leave
|
|
||||||
# the workspace byte-identical to the checkout for every manifest.
|
|
||||||
# git diff is the arbiter — any residual mutation fails THIS step
|
|
||||||
# instead of surfacing as ERR_PNPM_OUTDATED_LOCKFILE in build-gateway.
|
|
||||||
if ! git diff --exit-code -- '**/package.json' >/dev/null 2>&1; then
|
|
||||||
echo "[publish-next] FATAL: workspace package.json files still differ from HEAD after restore (#1404 class)" >&2
|
|
||||||
git diff --stat -- '**/package.json' >&2 || true
|
|
||||||
RESTORE_FAIL=1
|
|
||||||
fi
|
|
||||||
rm -rf "$SNAPSHOT_DIR"
|
|
||||||
if [ "$RESTORE_FAIL" -ne 0 ]; then exit 1; fi
|
|
||||||
echo "[publish-next] workspace manifests restored byte-exact (git diff clean); later steps see the committed tree"
|
|
||||||
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:
|
||||||
@@ -419,7 +205,6 @@ steps:
|
|||||||
# - bash scripts/publish-npmjs.sh
|
# - bash scripts/publish-npmjs.sh
|
||||||
# depends_on:
|
# depends_on:
|
||||||
# - build
|
# - build
|
||||||
# - verify
|
|
||||||
# when:
|
# when:
|
||||||
# - event: [tag]
|
# - event: [tag]
|
||||||
|
|
||||||
@@ -428,9 +213,9 @@ steps:
|
|||||||
when: *image_build_when
|
when: *image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: REGISTRY_USERNAME
|
from_secret: gitea_username
|
||||||
REGISTRY_PASS:
|
REGISTRY_PASS:
|
||||||
from_secret: REGISTRY_PASSWORD
|
from_secret: gitea_password
|
||||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||||
@@ -457,24 +242,15 @@ steps:
|
|||||||
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
|
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
|
||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
- verify
|
|
||||||
# #1411: publish-next-npm mutates workspace manifests in place during
|
|
||||||
# its transform window and restores them at step end. Any step that
|
|
||||||
# reads the pipeline workspace (kaniko COPY of manifests, later
|
|
||||||
# installs) must run AFTER publish-next-npm, never concurrently —
|
|
||||||
# pipeline 2648 raced a COPY inside the window and failed
|
|
||||||
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
|
|
||||||
# serialization invariant; add it to every new workspace consumer.
|
|
||||||
- publish-next-npm
|
|
||||||
|
|
||||||
build-appservice:
|
build-appservice:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *image_build_when
|
when: *main_image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: REGISTRY_USERNAME
|
from_secret: gitea_username
|
||||||
REGISTRY_PASS:
|
REGISTRY_PASS:
|
||||||
from_secret: REGISTRY_PASSWORD
|
from_secret: gitea_password
|
||||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||||
@@ -483,17 +259,8 @@ steps:
|
|||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||||
- |
|
- |
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
|
||||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
|
||||||
echo "[publish] FATAL: next appservice publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "[publish] next appservice publish is sha-only"
|
|
||||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
|
||||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
|
||||||
echo "[publish] FATAL: appservice image publish may only run for main, next, or tag events" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
|
||||||
@@ -501,24 +268,15 @@ steps:
|
|||||||
/kaniko/executor --context . --dockerfile docker/appservice.Dockerfile $DESTINATIONS
|
/kaniko/executor --context . --dockerfile docker/appservice.Dockerfile $DESTINATIONS
|
||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
- verify
|
|
||||||
# #1411: publish-next-npm mutates workspace manifests in place during
|
|
||||||
# its transform window and restores them at step end. Any step that
|
|
||||||
# reads the pipeline workspace (kaniko COPY of manifests, later
|
|
||||||
# installs) must run AFTER publish-next-npm, never concurrently —
|
|
||||||
# pipeline 2648 raced a COPY inside the window and failed
|
|
||||||
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
|
|
||||||
# serialization invariant; add it to every new workspace consumer.
|
|
||||||
- publish-next-npm
|
|
||||||
|
|
||||||
build-web:
|
build-web:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *image_build_when
|
when: *main_image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: REGISTRY_USERNAME
|
from_secret: gitea_username
|
||||||
REGISTRY_PASS:
|
REGISTRY_PASS:
|
||||||
from_secret: REGISTRY_PASSWORD
|
from_secret: gitea_password
|
||||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||||
@@ -527,17 +285,8 @@ steps:
|
|||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||||
- |
|
- |
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
|
||||||
if [ "$CI_COMMIT_BRANCH" = "next" ]; then
|
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
|
||||||
echo "[publish] FATAL: next web publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "[publish] next web publish is sha-only"
|
|
||||||
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
|
||||||
elif [ -z "$CI_COMMIT_TAG" ]; then
|
|
||||||
echo "[publish] FATAL: web image publish may only run for main, next, or tag events" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
|
||||||
@@ -545,12 +294,3 @@ steps:
|
|||||||
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
|
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
|
||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
- verify
|
|
||||||
# #1411: publish-next-npm mutates workspace manifests in place during
|
|
||||||
# its transform window and restores them at step end. Any step that
|
|
||||||
# reads the pipeline workspace (kaniko COPY of manifests, later
|
|
||||||
# installs) must run AFTER publish-next-npm, never concurrently —
|
|
||||||
# pipeline 2648 raced a COPY inside the window and failed
|
|
||||||
# ERR_PNPM_OUTDATED_LOCKFILE despite a clean restore. This edge is the
|
|
||||||
# serialization invariant; add it to every new workspace consumer.
|
|
||||||
- publish-next-npm
|
|
||||||
|
|||||||
@@ -81,73 +81,6 @@ 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,14 +74,6 @@ The launcher verifies your config, checks for `SOUL.md`, injects your `AGENTS.md
|
|||||||
|
|
||||||
Pi launches default to a token-lean skill posture: `mosaic pi` passes `--no-skills` so Pi does not preload every global skill description into the system prompt. Use `MOSAIC_PI_SKILL_MODE=all mosaic pi` for the legacy all-skills catalog, or `MOSAIC_PI_SKILL_MODE=discover mosaic pi` to let Pi use its native settings/project skill discovery.
|
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
|
||||||
@@ -146,9 +138,9 @@ mosaic brain tasks
|
|||||||
mosaic brain conversations
|
mosaic brain conversations
|
||||||
|
|
||||||
# Agent forge pipeline
|
# Agent forge pipeline
|
||||||
mosaic forge run [--simulate] # fails closed (FORGE_NO_EXECUTOR) with no executor wired; --simulate for typed simulated runs
|
mosaic forge run
|
||||||
mosaic forge status
|
mosaic forge status
|
||||||
mosaic forge resume [--simulate] # same fail-closed rule as forge run
|
mosaic forge resume
|
||||||
mosaic forge personas
|
mosaic forge personas
|
||||||
|
|
||||||
# Structured logging
|
# Structured logging
|
||||||
@@ -339,7 +331,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 (shipped with the framework package)
|
├── skills/ ← Universal skills (synced from agent-skills repo)
|
||||||
├── 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,13 +190,7 @@ beforeEach((ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
// Cleanup only when the fixture actually installed rows. `handle` is set
|
if (!handle) return;
|
||||||
// before the first query (createDb connects lazily), so on an unreachable
|
|
||||||
// database `handle` is truthy while nothing was inserted — cleanup must
|
|
||||||
// honor `dbAvailable` or the skip path fails the file with ECONNREFUSED in
|
|
||||||
// afterAll (caught live by the publish pipeline's no-DATABASE_URL verify
|
|
||||||
// step, pipeline 2486).
|
|
||||||
if (!handle || !dbAvailable) return;
|
|
||||||
const db = handle.db;
|
const db = handle.db;
|
||||||
|
|
||||||
// Delete in dependency order (FK constraints)
|
// Delete in dependency order (FK constraints)
|
||||||
|
|||||||
@@ -1,332 +0,0 @@
|
|||||||
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,
|
||||||
{ get: vi.fn().mockResolvedValue(null), renew: vi.fn().mockResolvedValue(undefined) } as never,
|
null,
|
||||||
null,
|
null,
|
||||||
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
||||||
operatorMemory as never,
|
operatorMemory as never,
|
||||||
|
|||||||
@@ -132,8 +132,9 @@ 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,
|
private readonly systemOverride: SystemOverrideService | null,
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(PreferencesService)
|
@Inject(PreferencesService)
|
||||||
private readonly preferencesService: PreferencesService | null,
|
private readonly preferencesService: PreferencesService | null,
|
||||||
@@ -708,22 +709,23 @@ 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}`;
|
||||||
const override = await this.systemOverride.get(sessionId, scope);
|
if (this.systemOverride) {
|
||||||
if (override) {
|
const override = await this.systemOverride.get(sessionId, scope);
|
||||||
effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`;
|
if (override) {
|
||||||
await this.systemOverride.renew(sessionId, scope);
|
effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`;
|
||||||
this.logger.debug(`Applied system override for session ${sessionId}`);
|
await this.systemOverride.renew(sessionId, scope);
|
||||||
|
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,10 +80,6 @@ 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: {
|
||||||
@@ -102,7 +98,6 @@ function buildService(
|
|||||||
null,
|
null,
|
||||||
mockChatGateway as never,
|
mockChatGateway as never,
|
||||||
mcpClient as never,
|
mcpClient as never,
|
||||||
allowAuthorization as never,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ 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,
|
private readonly authorization: CommandAuthorizationService | null = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(
|
async execute(
|
||||||
@@ -56,13 +57,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.allowed) {
|
if (authorization && !authorization.allowed) {
|
||||||
return { command, conversationId, success: false, message: authorization.reason };
|
return { command, conversationId, success: false, message: authorization.reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +171,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) return null;
|
if (!def || !this.authorization) return null;
|
||||||
return this.authorization.createApproval(def, payload, scope.userId);
|
return this.authorization.createApproval(def, payload, scope.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,10 +55,6 @@ 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 {
|
||||||
@@ -78,7 +74,6 @@ 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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
|
||||||
|
|
||||||
// The module under test imports @mosaicstack/db at module scope; we replace only the
|
|
||||||
// pieces DatabaseModule uses (partial mock — the real module also exports the
|
|
||||||
// schema the storage adapter's import chain needs) so the test pins the #1392
|
|
||||||
// contract (refuse to start on an incomplete schema) without a live database.
|
|
||||||
vi.mock('@mosaicstack/db', async (importOriginal) => {
|
|
||||||
const actual: object = await importOriginal();
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
createDb: vi.fn(),
|
|
||||||
createPgliteDb: vi.fn(),
|
|
||||||
getMigrationStatus: vi.fn(),
|
|
||||||
runPgliteMigrations: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
import { DatabaseModule } from './database.module.js';
|
|
||||||
import { getMigrationStatus } from '@mosaicstack/db';
|
|
||||||
import type { DbHandle } from '@mosaicstack/db';
|
|
||||||
import type { StorageAdapter } from '@mosaicstack/storage';
|
|
||||||
import type { MosaicConfig } from '@mosaicstack/config';
|
|
||||||
|
|
||||||
function makeModule(storageType: 'postgres' | 'pglite', tier: string) {
|
|
||||||
const storageAdapter = {
|
|
||||||
name: storageType,
|
|
||||||
migrate: vi.fn(),
|
|
||||||
close: vi.fn(),
|
|
||||||
} as unknown as StorageAdapter;
|
|
||||||
const handle = { close: vi.fn() } as unknown as DbHandle;
|
|
||||||
const config = {
|
|
||||||
tier,
|
|
||||||
storage: { type: storageType, url: 'postgresql://x' },
|
|
||||||
} as unknown as MosaicConfig;
|
|
||||||
return {
|
|
||||||
mod: new DatabaseModule(handle, storageAdapter, config),
|
|
||||||
storageAdapter,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('DatabaseModule.onModuleInit — #1392 schema verification', () => {
|
|
||||||
it('refuses to start when the postgres schema is incomplete', async () => {
|
|
||||||
const { mod, storageAdapter } = makeModule('postgres', 'standalone');
|
|
||||||
vi.mocked(getMigrationStatus).mockResolvedValue({
|
|
||||||
appliedCount: 15,
|
|
||||||
expectedCount: 17,
|
|
||||||
expectedLastTag: '0016_salty_morlocks',
|
|
||||||
complete: false,
|
|
||||||
});
|
|
||||||
await expect(mod.onModuleInit()).rejects.toThrow('Database schema incomplete: 15/17');
|
|
||||||
expect(storageAdapter.migrate).toHaveBeenCalled(); // migrations attempted first
|
|
||||||
});
|
|
||||||
|
|
||||||
it('starts normally when the schema is complete', async () => {
|
|
||||||
const { mod } = makeModule('postgres', 'standalone');
|
|
||||||
vi.mocked(getMigrationStatus).mockResolvedValue({
|
|
||||||
appliedCount: 17,
|
|
||||||
expectedCount: 17,
|
|
||||||
expectedLastTag: '0016_salty_morlocks',
|
|
||||||
complete: true,
|
|
||||||
});
|
|
||||||
await expect(mod.onModuleInit()).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not verify postgres status for the local tier (PGlite migrates itself)', async () => {
|
|
||||||
const { mod } = makeModule('pglite', 'local');
|
|
||||||
vi.mocked(getMigrationStatus).mockClear();
|
|
||||||
await expect(mod.onModuleInit()).resolves.toBeUndefined();
|
|
||||||
expect(getMigrationStatus).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
createDb,
|
createDb,
|
||||||
createPgliteDb,
|
createPgliteDb,
|
||||||
getMigrationStatus,
|
|
||||||
runPgliteMigrations,
|
runPgliteMigrations,
|
||||||
type Db,
|
type Db,
|
||||||
type DbHandle,
|
type DbHandle,
|
||||||
@@ -75,11 +74,6 @@ export class DatabaseModule implements OnApplicationShutdown, OnModuleInit {
|
|||||||
// the same DATABASE_URL, so a single call covers both the gateway DB and
|
// the same DATABASE_URL, so a single call covers both the gateway DB and
|
||||||
// the storage tables. We deliberately do NOT call runMigrations() here to
|
// the storage tables. We deliberately do NOT call runMigrations() here to
|
||||||
// avoid opening a second short-lived connection and doubling startup cost.
|
// avoid opening a second short-lived connection and doubling startup cost.
|
||||||
//
|
|
||||||
// #1392: we DO verify afterwards (getMigrationStatus opens one short-lived
|
|
||||||
// connection) and refuse to start on an incomplete schema. A gateway that
|
|
||||||
// boots "healthy" on an empty or partial database is precisely the failure
|
|
||||||
// that shipped in the T63 batch: silent at startup, catastrophic later.
|
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
if (this.config.tier === 'local') {
|
if (this.config.tier === 'local') {
|
||||||
this.logger.log('Applying PGlite schema migrations...');
|
this.logger.log('Applying PGlite schema migrations...');
|
||||||
@@ -87,24 +81,6 @@ export class DatabaseModule implements OnApplicationShutdown, OnModuleInit {
|
|||||||
}
|
}
|
||||||
this.logger.log(`Initializing storage adapter (${this.storageAdapter.name})...`);
|
this.logger.log(`Initializing storage adapter (${this.storageAdapter.name})...`);
|
||||||
await this.storageAdapter.migrate();
|
await this.storageAdapter.migrate();
|
||||||
|
|
||||||
if (this.config.storage.type === 'postgres') {
|
|
||||||
const status = await getMigrationStatus(this.config.storage.url);
|
|
||||||
if (!status.complete) {
|
|
||||||
this.logger.error(
|
|
||||||
`Database schema incomplete: ${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations applied ` +
|
|
||||||
`(last expected: ${status.expectedLastTag}). ` +
|
|
||||||
'Refusing to start on a partial schema — see issues #1392/#1402. ' +
|
|
||||||
"Remediation: re-run 'mosaic gateway install' (it now verifies), or apply migrations manually.",
|
|
||||||
);
|
|
||||||
throw new Error(
|
|
||||||
`Database schema incomplete: ${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations applied`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this.logger.log(
|
|
||||||
`Database schema verified: ${status.appliedCount.toString()}/${status.expectedCount.toString()} migrations applied.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async onApplicationShutdown(): Promise<void> {
|
async onApplicationShutdown(): Promise<void> {
|
||||||
|
|||||||
@@ -245,21 +245,9 @@ 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
|
||||||
// The property under test is CLAMPING: a 9999s request must come back as 900s.
|
expect(expiresMs - before).toBeLessThanOrEqual(900_000 + 100);
|
||||||
// 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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,16 +14,10 @@ import { mountMcpHandler } from './mcp/mcp.controller.js';
|
|||||||
import { McpService } from './mcp/mcp.service.js';
|
import { McpService } from './mcp/mcp.service.js';
|
||||||
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
|
||||||
import { resolveGatewayConfigPath } from './env.js';
|
import { resolveGatewayConfigPath } from './env.js';
|
||||||
import { assertValidationPipeSeesDtoDecorators } from './validation-pipe-check.js';
|
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
const logger = new Logger('Bootstrap');
|
const logger = new Logger('Bootstrap');
|
||||||
|
|
||||||
// Fail loud BEFORE anything else if the global ValidationPipe cannot see
|
|
||||||
// the guarded DTOs' decorated properties (#1391): a broken metatype turns
|
|
||||||
// every request body into a 400 at first use; this surfaces it at boot.
|
|
||||||
assertValidationPipeSeesDtoDecorators();
|
|
||||||
|
|
||||||
if (!process.env['BETTER_AUTH_SECRET']) {
|
if (!process.env['BETTER_AUTH_SECRET']) {
|
||||||
throw new Error('BETTER_AUTH_SECRET is required');
|
throw new Error('BETTER_AUTH_SECRET is required');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,7 +159,6 @@ 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' };
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
/**
|
|
||||||
* Boot-time ValidationPipe metatype self-check (#1391).
|
|
||||||
*
|
|
||||||
* The check exists to fail loud at boot when the global pipe cannot see a
|
|
||||||
* guarded DTO's decorated properties — the #436 class-erasure signature and
|
|
||||||
* its dependency-graph cousins. Red/green arms:
|
|
||||||
*
|
|
||||||
* GREEN real module state: BootstrapSetupDto's three properties are
|
|
||||||
* decorated and visible through the globalThis-shared storage.
|
|
||||||
* RED a control class with NO decorators (the erasure shape): the
|
|
||||||
* check throws PipeMetatypeCheckError naming every property.
|
|
||||||
* RED-2 a control where one property is decorated and two are not: the
|
|
||||||
* error names exactly the missing two — the miss list is precise,
|
|
||||||
* not a blanket failure.
|
|
||||||
*/
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { IsString } from 'class-validator';
|
|
||||||
import {
|
|
||||||
assertValidationPipeSeesDtoDecorators,
|
|
||||||
PipeMetatypeCheckError,
|
|
||||||
} from './validation-pipe-check.js';
|
|
||||||
|
|
||||||
describe('assertValidationPipeSeesDtoDecorators (#1391 boot check)', () => {
|
|
||||||
it('GREEN: passes on real module state (decorated DTO visible to the pipe)', () => {
|
|
||||||
expect(() => assertValidationPipeSeesDtoDecorators()).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('RED control: a class whose properties lost their decorators throws, naming them', async () => {
|
|
||||||
// Simulate metatype erasure: an undecorated class standing where a
|
|
||||||
// decorated DTO should be. Redefine the guard table for the test by
|
|
||||||
// importing the module and pointing its table at the eroded class —
|
|
||||||
// the check reads the table at call time, so a fresh module instance
|
|
||||||
// with a swapped table reproduces the boot failure deterministically.
|
|
||||||
const { PIPE_GUARDED_DTOS } = await import('./validation-pipe-check.js');
|
|
||||||
|
|
||||||
class ErodedDto {
|
|
||||||
name?: string;
|
|
||||||
email?: string;
|
|
||||||
password?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const original = PIPE_GUARDED_DTOS[0];
|
|
||||||
expect(original).toBeDefined();
|
|
||||||
// Swap in the eroded target (same declared properties, zero decorators).
|
|
||||||
(
|
|
||||||
PIPE_GUARDED_DTOS as unknown as Array<{ name: string; target: object; properties: string[] }>
|
|
||||||
).splice(0, PIPE_GUARDED_DTOS.length, {
|
|
||||||
name: 'ErodedDto',
|
|
||||||
target: ErodedDto,
|
|
||||||
properties: ['name', 'email', 'password'],
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
expect(() => assertValidationPipeSeesDtoDecorators()).toThrow(PipeMetatypeCheckError);
|
|
||||||
try {
|
|
||||||
assertValidationPipeSeesDtoDecorators();
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : '';
|
|
||||||
expect(message).toContain('ErodedDto.name');
|
|
||||||
expect(message).toContain('ErodedDto.email');
|
|
||||||
expect(message).toContain('ErodedDto.password');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
// Restore real module state for any later test in this file.
|
|
||||||
(PIPE_GUARDED_DTOS as unknown as unknown[]).splice(0, PIPE_GUARDED_DTOS.length, original);
|
|
||||||
}
|
|
||||||
// And confirm the restore is real.
|
|
||||||
expect(() => assertValidationPipeSeesDtoDecorators()).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('RED-2 control: a partially decorated class names exactly the missing properties', async () => {
|
|
||||||
const { PIPE_GUARDED_DTOS } = await import('./validation-pipe-check.js');
|
|
||||||
|
|
||||||
class HalfErodedDto {
|
|
||||||
@IsString()
|
|
||||||
name?: string;
|
|
||||||
email?: string;
|
|
||||||
password?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const original = PIPE_GUARDED_DTOS[0];
|
|
||||||
(
|
|
||||||
PIPE_GUARDED_DTOS as unknown as Array<{ name: string; target: object; properties: string[] }>
|
|
||||||
).splice(0, PIPE_GUARDED_DTOS.length, {
|
|
||||||
name: 'HalfErodedDto',
|
|
||||||
target: HalfErodedDto,
|
|
||||||
properties: ['name', 'email', 'password'],
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
try {
|
|
||||||
assertValidationPipeSeesDtoDecorators();
|
|
||||||
expect.unreachable('partially decorated DTO must fail the boot check');
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : '';
|
|
||||||
expect(message).toContain('HalfErodedDto.email');
|
|
||||||
expect(message).toContain('HalfErodedDto.password');
|
|
||||||
expect(message).not.toContain('HalfErodedDto.name has no');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
(PIPE_GUARDED_DTOS as unknown as unknown[]).splice(0, PIPE_GUARDED_DTOS.length, original);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { getMetadataStorage } from 'class-validator';
|
|
||||||
import { BootstrapSetupDto } from './admin/bootstrap.dto.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Boot-time self-check: the global ValidationPipe must be able to SEE the
|
|
||||||
* decorated properties of the DTOs it guards (#1391, #436 class).
|
|
||||||
*
|
|
||||||
* WHY THIS EXISTS. When Nest resolves a @Body() metatype to Object — via
|
|
||||||
* `import type` class erasure (#436), or a dependency graph where the
|
|
||||||
* controller's decorators and the application's route enhancers disagree
|
|
||||||
* (#1391's hypothesized dual-@nestjs/common on a mixed install) — the
|
|
||||||
* ValidationPipe's whitelist treats every property as forbidden. The first
|
|
||||||
* symptom is a 400 on the FIRST bootstrap attempt of a fresh install, the
|
|
||||||
* worst place to discover wiring damage: the operator cannot tell a broken
|
|
||||||
* payload from a broken daemon.
|
|
||||||
*
|
|
||||||
* This check fails LOUD at boot instead: if the pipe cannot see the DTO's
|
|
||||||
* decorated properties, the gateway refuses to start with a named cause.
|
|
||||||
* It catches the whole class — erasure, decorator metadata loss — on every
|
|
||||||
* host, at the moment the damage exists rather than at first use.
|
|
||||||
*
|
|
||||||
* Storage sharing note: class-validator keys its metadata storage on
|
|
||||||
* globalThis, so duplicate package copies do NOT hide metadata (measured,
|
|
||||||
* #1391 diagnosis). What hides it is losing the metatype itself, which is
|
|
||||||
* what this asserts against.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DTOs the global pipe guards, mapped to the properties the whitelist must
|
|
||||||
* admit. Target is the CONSTRUCTOR (the object class itself): class-validator
|
|
||||||
* decorators register metadata keyed on the constructor, and its executor
|
|
||||||
* looks up `object.constructor` (ValidationExecutor.js:50) — the probe
|
|
||||||
* through `prototype` returns zero. Extend when adding DTOs to the app.
|
|
||||||
*/
|
|
||||||
export const PIPE_GUARDED_DTOS: Array<{
|
|
||||||
name: string;
|
|
||||||
target: abstract new (...args: never[]) => unknown;
|
|
||||||
properties: string[];
|
|
||||||
}> = [
|
|
||||||
{
|
|
||||||
name: 'BootstrapSetupDto',
|
|
||||||
target: BootstrapSetupDto,
|
|
||||||
properties: ['name', 'email', 'password'],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export class PipeMetatypeCheckError extends Error {
|
|
||||||
constructor(missing: string[]) {
|
|
||||||
super(
|
|
||||||
'ValidationPipe metatype check failed: ' +
|
|
||||||
missing.join('; ') +
|
|
||||||
'. The global ValidationPipe cannot see decorated DTO properties — ' +
|
|
||||||
'every request body would be rejected as non-whitelisted. ' +
|
|
||||||
'Check for import-type erasure or decorator metadata loss in the ' +
|
|
||||||
'dependency graph (see issues #436, #1391).',
|
|
||||||
);
|
|
||||||
this.name = 'PipeMetatypeCheckError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assert the pipe's whitelist can see every guarded DTO's decorated
|
|
||||||
* properties. Throws PipeMetatypeCheckError (fail-loud at boot) listing
|
|
||||||
* each miss. Pure function of module state: no I/O, safe to call twice.
|
|
||||||
*/
|
|
||||||
export function assertValidationPipeSeesDtoDecorators(): void {
|
|
||||||
const storage = getMetadataStorage();
|
|
||||||
const missing: string[] = [];
|
|
||||||
|
|
||||||
for (const dto of PIPE_GUARDED_DTOS) {
|
|
||||||
// class-validator records constraints keyed on the DTO's constructor
|
|
||||||
// (decorators run on the class), and its executor resolves them via
|
|
||||||
// object.constructor. A property with no recorded metadata is invisible
|
|
||||||
// to the whitelist — whatever the cause — and fails here.
|
|
||||||
// Signature mirrors ValidationExecutor.js:50 — (constructor, schema, always,
|
|
||||||
// strictGroups, groups?). No schema, always=true, no groups: every
|
|
||||||
// constraint regardless of grouping, which is what the whitelist sees.
|
|
||||||
const metadatas = storage.getTargetValidationMetadatas(dto.target, '', true, false);
|
|
||||||
const decorated = new Set(metadatas.map((m) => m.propertyName));
|
|
||||||
|
|
||||||
for (const property of dto.properties) {
|
|
||||||
if (!decorated.has(property)) {
|
|
||||||
missing.push(
|
|
||||||
`${dto.name}.${property} has no class-validator constraints visible to the pipe`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (missing.length > 0) {
|
|
||||||
throw new PipeMetatypeCheckError(missing);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { type CanActivate, type ExecutionContext, type INestApplication } from '@nestjs/common';
|
|
||||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import request from 'supertest';
|
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
|
||||||
import { TeamsController } from './teams.controller.js';
|
|
||||||
import { TeamsService } from './teams.service.js';
|
|
||||||
|
|
||||||
const teamAlpha = { id: 'team-alpha', name: 'Alpha' };
|
|
||||||
const teamBeta = { id: 'team-beta', name: 'Beta' };
|
|
||||||
|
|
||||||
// user-1 is a member of team-alpha only; admin-1 has role admin.
|
|
||||||
let currentUser: { id: string; role?: string } = { id: 'user-1' };
|
|
||||||
|
|
||||||
const teamsServiceMock = {
|
|
||||||
findAll: vi.fn(() => Promise.resolve([teamAlpha, teamBeta])),
|
|
||||||
findAllForUser: vi.fn((userId: string) =>
|
|
||||||
Promise.resolve(userId === 'user-1' ? [teamAlpha] : []),
|
|
||||||
),
|
|
||||||
findById: vi.fn((id: string) => Promise.resolve([teamAlpha, teamBeta].find((t) => t.id === id))),
|
|
||||||
listMembers: vi.fn(() => Promise.resolve([{ teamId: 'team-alpha', userId: 'user-1' }])),
|
|
||||||
isMember: vi.fn((teamId: string, userId: string) =>
|
|
||||||
Promise.resolve(teamId === 'team-alpha' && userId === 'user-1'),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
const authGuard: CanActivate = {
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
|
||||||
const requestContext = context
|
|
||||||
.switchToHttp()
|
|
||||||
.getRequest<{ user?: { id: string; role?: string } }>();
|
|
||||||
requestContext.user = currentUser;
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('teams endpoints are scoped to membership', () => {
|
|
||||||
let app: INestApplication;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
|
||||||
controllers: [TeamsController],
|
|
||||||
providers: [{ provide: TeamsService, useValue: teamsServiceMock }],
|
|
||||||
})
|
|
||||||
.overrideGuard(AuthGuard)
|
|
||||||
.useValue(authGuard)
|
|
||||||
.compile();
|
|
||||||
|
|
||||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
|
||||||
await app.init();
|
|
||||||
await app.getHttpAdapter().getInstance().ready();
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
currentUser = { id: 'user-1' };
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await app.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/teams returns only the teams the user belongs to', async () => {
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/teams');
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body).toEqual([teamAlpha]);
|
|
||||||
expect(teamsServiceMock.findAll).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/teams returns every team for an admin', async () => {
|
|
||||||
currentUser = { id: 'admin-1', role: 'admin' };
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/teams');
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body).toEqual([teamAlpha, teamBeta]);
|
|
||||||
expect(teamsServiceMock.findAllForUser).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/teams/:teamId returns 403 for a non-member', async () => {
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta');
|
|
||||||
expect(response.status).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/teams/:teamId returns 404 for a missing team', async () => {
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/teams/team-missing');
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/teams/:teamId returns the team for a member', async () => {
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha');
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body).toEqual(teamAlpha);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/teams/:teamId/members returns 403 for a non-member and members for a member', async () => {
|
|
||||||
const denied = await request(app.getHttpServer()).get('/api/teams/team-beta/members');
|
|
||||||
expect(denied.status).toBe(403);
|
|
||||||
expect(teamsServiceMock.listMembers).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
const allowed = await request(app.getHttpServer()).get('/api/teams/team-alpha/members');
|
|
||||||
expect(allowed.status).toBe(200);
|
|
||||||
expect(allowed.body).toEqual([{ teamId: 'team-alpha', userId: 'user-1' }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/teams/:teamId/members/:userId allows a self-lookup on any team', async () => {
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-1');
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body).toEqual({ isMember: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /api/teams/:teamId/members/:userId denies looking up another user on a foreign team', async () => {
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/teams/team-beta/members/user-2');
|
|
||||||
expect(response.status).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('an admin can look up any membership', async () => {
|
|
||||||
currentUser = { id: 'admin-1', role: 'admin' };
|
|
||||||
const response = await request(app.getHttpServer()).get('/api/teams/team-alpha/members/user-1');
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body).toEqual({ isMember: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,68 +1,30 @@
|
|||||||
import {
|
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||||
Controller,
|
|
||||||
ForbiddenException,
|
|
||||||
Get,
|
|
||||||
NotFoundException,
|
|
||||||
Param,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
|
||||||
import { TeamsService } from './teams.service.js';
|
import { TeamsService } from './teams.service.js';
|
||||||
|
|
||||||
type RequestUser = { id: string; role?: string };
|
|
||||||
|
|
||||||
@Controller('api/teams')
|
@Controller('api/teams')
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
export class TeamsController {
|
export class TeamsController {
|
||||||
constructor(private readonly teams: TeamsService) {}
|
constructor(private readonly teams: TeamsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list(@CurrentUser() user: RequestUser) {
|
async list() {
|
||||||
if (user.role === 'admin') {
|
return this.teams.findAll();
|
||||||
return this.teams.findAll();
|
|
||||||
}
|
|
||||||
return this.teams.findAllForUser(user.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':teamId')
|
@Get(':teamId')
|
||||||
async findOne(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
|
async findOne(@Param('teamId') teamId: string) {
|
||||||
return this.getAccessibleTeam(teamId, user);
|
return this.teams.findById(teamId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':teamId/members')
|
@Get(':teamId/members')
|
||||||
async listMembers(@Param('teamId') teamId: string, @CurrentUser() user: RequestUser) {
|
async listMembers(@Param('teamId') teamId: string) {
|
||||||
await this.getAccessibleTeam(teamId, user);
|
|
||||||
return this.teams.listMembers(teamId);
|
return this.teams.listMembers(teamId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':teamId/members/:userId')
|
@Get(':teamId/members/:userId')
|
||||||
async checkMembership(
|
async checkMembership(@Param('teamId') teamId: string, @Param('userId') userId: string) {
|
||||||
@Param('teamId') teamId: string,
|
|
||||||
@Param('userId') userId: string,
|
|
||||||
@CurrentUser() user: RequestUser,
|
|
||||||
) {
|
|
||||||
// A user may always ask about their own membership; anything else is
|
|
||||||
// team-scoped like the other routes.
|
|
||||||
if (userId !== user.id) {
|
|
||||||
await this.getAccessibleTeam(teamId, user);
|
|
||||||
}
|
|
||||||
const isMember = await this.teams.isMember(teamId, userId);
|
const isMember = await this.teams.isMember(teamId, userId);
|
||||||
return { isMember };
|
return { isMember };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Team-scoped access: admins see any team; everyone else only teams they
|
|
||||||
* are a member of. NotFoundException when the team does not exist and
|
|
||||||
* ForbiddenException when the user lacks access (same convention as the
|
|
||||||
* projects controller).
|
|
||||||
*/
|
|
||||||
private async getAccessibleTeam(teamId: string, user: RequestUser) {
|
|
||||||
const team = await this.teams.findById(teamId);
|
|
||||||
if (!team) throw new NotFoundException('Team not found');
|
|
||||||
if (user.role === 'admin') return team;
|
|
||||||
const isMember = await this.teams.isMember(teamId, user.id);
|
|
||||||
if (!isMember) throw new ForbiddenException('Not a member of this team');
|
|
||||||
return team;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { eq, and, inArray, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
|
import { eq, and, type Db, teams, teamMembers, projects } from '@mosaicstack/db';
|
||||||
import { DB } from '../database/database.module.js';
|
import { DB } from '../database/database.module.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -56,21 +56,6 @@ export class TeamsService {
|
|||||||
return this.db.select().from(teams);
|
return this.db.select().from(teams);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* List only the teams the user is a member of.
|
|
||||||
*/
|
|
||||||
async findAllForUser(userId: string) {
|
|
||||||
const memberRows = await this.db
|
|
||||||
.select({ teamId: teamMembers.teamId })
|
|
||||||
.from(teamMembers)
|
|
||||||
.where(eq(teamMembers.userId, userId));
|
|
||||||
|
|
||||||
const teamIds = memberRows.map((r) => r.teamId);
|
|
||||||
if (teamIds.length === 0) return [];
|
|
||||||
|
|
||||||
return this.db.select().from(teams).where(inArray(teams.id, teamIds));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find a team by ID.
|
* Find a team by ID.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
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' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
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.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,372 +0,0 @@
|
|||||||
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' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -13,9 +13,8 @@ import {
|
|||||||
TasksRouteErrorBoundary,
|
TasksRouteErrorBoundary,
|
||||||
} from '@/spa/pages/resource-route-error-boundaries';
|
} from '@/spa/pages/resource-route-error-boundaries';
|
||||||
import { TasksPage } from '@/spa/pages/tasks';
|
import { TasksPage } from '@/spa/pages/tasks';
|
||||||
import { SettingsPage } from '@/spa/pages/settings';
|
import { AuthGuard, GuestGuard } from '@/spa/guards';
|
||||||
import { AdminPage } from '@/spa/pages/admin';
|
import { Placeholder } from '@/spa/placeholder';
|
||||||
import { AdminGuard, AuthGuard, GuestGuard } from '@/spa/guards';
|
|
||||||
|
|
||||||
function GuestLayout(): ReactElement {
|
function GuestLayout(): ReactElement {
|
||||||
return (
|
return (
|
||||||
@@ -57,11 +56,8 @@ export const routes: RouteObject[] = [
|
|||||||
errorElement: <ProjectDetailRouteErrorBoundary />,
|
errorElement: <ProjectDetailRouteErrorBoundary />,
|
||||||
},
|
},
|
||||||
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
|
{ path: '/tasks', element: <TasksPage />, errorElement: <TasksRouteErrorBoundary /> },
|
||||||
{ path: '/settings', element: <SettingsPage /> },
|
{ path: '/settings', element: <Placeholder title="Settings" /> },
|
||||||
{
|
{ path: '/admin', element: <Placeholder title="Admin" /> },
|
||||||
element: <AdminGuard />,
|
|
||||||
children: [{ path: '/admin', element: <AdminPage /> }],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -21,23 +21,3 @@ export function AuthGuard(): ReactElement {
|
|||||||
|
|
||||||
return session ? <Outlet /> : <Navigate to="/login" replace />;
|
return session ? <Outlet /> : <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminGuard(): ReactElement {
|
|
||||||
const { data: session, isPending } = useSession();
|
|
||||||
|
|
||||||
if (isPending) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
|
||||||
<div className="text-sm text-text-muted">Loading...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return <Navigate to="/login" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = session.user as typeof session.user & { role?: string };
|
|
||||||
|
|
||||||
return user.role === 'admin' ? <Outlet /> : <Navigate to="/" replace />;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,206 +0,0 @@
|
|||||||
import { act } from 'react';
|
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
|
||||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
const { apiMock, useSessionMock } = vi.hoisted(() => ({
|
|
||||||
apiMock: vi.fn(),
|
|
||||||
useSessionMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/api', () => ({
|
|
||||||
api: apiMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/auth-client', () => ({
|
|
||||||
useSession: useSessionMock,
|
|
||||||
authClient: {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { AdminPage } from './admin';
|
|
||||||
import { AdminGuard } from '@/spa/guards';
|
|
||||||
|
|
||||||
const userFixtures = {
|
|
||||||
users: [
|
|
||||||
{
|
|
||||||
id: 'u-admin',
|
|
||||||
name: 'Ada Admin',
|
|
||||||
email: '[email protected]',
|
|
||||||
role: 'admin',
|
|
||||||
banned: false,
|
|
||||||
banReason: null,
|
|
||||||
createdAt: '2026-08-01T00:00:00.000Z',
|
|
||||||
updatedAt: '2026-08-01T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'u-member',
|
|
||||||
name: 'Mel Member',
|
|
||||||
email: '[email protected]',
|
|
||||||
role: 'member',
|
|
||||||
banned: true,
|
|
||||||
banReason: 'spam',
|
|
||||||
createdAt: '2026-08-02T00:00:00.000Z',
|
|
||||||
updatedAt: '2026-08-02T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
total: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLDivElement;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
|
||||||
configurable: true,
|
|
||||||
value: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await act(async () => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
document.body.replaceChildren();
|
|
||||||
root = null;
|
|
||||||
apiMock.mockReset();
|
|
||||||
useSessionMock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function renderAdminRoute(): Promise<void> {
|
|
||||||
const routes: RouteObject[] = [
|
|
||||||
{
|
|
||||||
element: <AdminGuard />,
|
|
||||||
children: [{ path: '/admin', element: <AdminPage /> }],
|
|
||||||
},
|
|
||||||
{ path: '/', element: <div>home page</div> },
|
|
||||||
{ path: '/login', element: <div>login page</div> },
|
|
||||||
];
|
|
||||||
const router = createMemoryRouter(routes, { initialEntries: ['/admin'] });
|
|
||||||
container = document.createElement('div');
|
|
||||||
document.body.append(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root?.render(<RouterProvider router={router} />);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function sessionWithRole(role: string | undefined): { data: unknown; isPending: boolean } {
|
|
||||||
return {
|
|
||||||
data: { user: { id: 'u-1', name: 'Test', email: '[email protected]', role } },
|
|
||||||
isPending: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('AdminGuard', () => {
|
|
||||||
it('redirects unauthenticated visitors to /login', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: null, isPending: false });
|
|
||||||
|
|
||||||
await renderAdminRoute();
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('login page');
|
|
||||||
expect(apiMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('redirects non-admin users to /', async () => {
|
|
||||||
useSessionMock.mockReturnValue(sessionWithRole('member'));
|
|
||||||
|
|
||||||
await renderAdminRoute();
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('home page');
|
|
||||||
expect(apiMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the admin page for admin users', async () => {
|
|
||||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
|
||||||
apiMock.mockResolvedValueOnce(userFixtures);
|
|
||||||
|
|
||||||
await renderAdminRoute();
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Admin Panel');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('AdminPage users tab', () => {
|
|
||||||
it('lists users with role and ban status after load', async () => {
|
|
||||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
|
||||||
apiMock.mockResolvedValueOnce(userFixtures);
|
|
||||||
|
|
||||||
await renderAdminRoute();
|
|
||||||
|
|
||||||
expect(apiMock).toHaveBeenCalledWith('/api/admin/users');
|
|
||||||
expect(container.textContent).toContain('Ada Admin');
|
|
||||||
expect(container.textContent).toContain('Mel Member');
|
|
||||||
expect(container.textContent).toContain('Banned');
|
|
||||||
expect(container.textContent).toContain('2 user(s)');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the load error with a retry control', async () => {
|
|
||||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
|
||||||
apiMock.mockRejectedValueOnce(new Error('gateway unavailable'));
|
|
||||||
|
|
||||||
await renderAdminRoute();
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('gateway unavailable');
|
|
||||||
|
|
||||||
apiMock.mockResolvedValueOnce(userFixtures);
|
|
||||||
const retry = [...container.querySelectorAll('button')].find((b) =>
|
|
||||||
b.textContent?.includes('Retry'),
|
|
||||||
);
|
|
||||||
expect(retry).toBeTruthy();
|
|
||||||
await act(async () => {
|
|
||||||
retry?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Ada Admin');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('posts to the ban endpoint and reloads on Ban', async () => {
|
|
||||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
|
||||||
apiMock.mockResolvedValue(userFixtures);
|
|
||||||
|
|
||||||
await renderAdminRoute();
|
|
||||||
|
|
||||||
const banButton = [...container.querySelectorAll('button')].find(
|
|
||||||
(b) => b.textContent === 'Ban',
|
|
||||||
);
|
|
||||||
expect(banButton).toBeTruthy();
|
|
||||||
await act(async () => {
|
|
||||||
banButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(apiMock).toHaveBeenCalledWith('/api/admin/users/u-admin/ban', { method: 'POST' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('AdminPage health tab', () => {
|
|
||||||
it('loads health status when the tab is opened', async () => {
|
|
||||||
useSessionMock.mockReturnValue(sessionWithRole('admin'));
|
|
||||||
apiMock.mockResolvedValueOnce(userFixtures).mockResolvedValueOnce({
|
|
||||||
status: 'ok',
|
|
||||||
database: { status: 'ok', latencyMs: 3 },
|
|
||||||
cache: { status: 'ok', latencyMs: 1 },
|
|
||||||
agentPool: { activeSessions: 2 },
|
|
||||||
providers: [{ id: 'ollama', name: 'Ollama', available: true, modelCount: 4 }],
|
|
||||||
checkedAt: '2026-08-26T00:00:00.000Z',
|
|
||||||
});
|
|
||||||
|
|
||||||
await renderAdminRoute();
|
|
||||||
|
|
||||||
const healthTab = [...container.querySelectorAll('button')].find((b) =>
|
|
||||||
b.textContent?.includes('System Health'),
|
|
||||||
);
|
|
||||||
await act(async () => {
|
|
||||||
healthTab?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(apiMock).toHaveBeenCalledWith('/api/admin/health');
|
|
||||||
expect(container.textContent).toContain('Database (PostgreSQL)');
|
|
||||||
expect(container.textContent).toContain('Active sessions: 2');
|
|
||||||
expect(container.textContent).toContain('4 models');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,522 +0,0 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { cn } from '@/lib/cn';
|
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface UserDto {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
role: string;
|
|
||||||
banned: boolean;
|
|
||||||
banReason: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserListDto {
|
|
||||||
users: UserDto[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ServiceStatusDto {
|
|
||||||
status: 'ok' | 'error';
|
|
||||||
latencyMs?: number;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProviderStatusDto {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
available: boolean;
|
|
||||||
modelCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HealthStatusDto {
|
|
||||||
status: 'ok' | 'degraded' | 'error';
|
|
||||||
database: ServiceStatusDto;
|
|
||||||
cache: ServiceStatusDto;
|
|
||||||
agentPool: { activeSessions: number };
|
|
||||||
providers: ProviderStatusDto[];
|
|
||||||
checkedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Admin Page ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// Route-level access control lives in AdminGuard (spa/guards.tsx); this page
|
|
||||||
// assumes an authenticated admin session.
|
|
||||||
export function AdminPage(): React.ReactElement {
|
|
||||||
const [activeTab, setActiveTab] = useState<'users' | 'health'>('users');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-5xl space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h1 className="text-2xl font-semibold text-text-primary">Admin Panel</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-1 border-b border-surface-border">
|
|
||||||
{(['users', 'health'] as const).map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveTab(tab)}
|
|
||||||
className={cn(
|
|
||||||
'px-4 py-2 text-sm font-medium capitalize transition-colors',
|
|
||||||
activeTab === tab
|
|
||||||
? 'border-b-2 border-blue-500 text-blue-400'
|
|
||||||
: 'text-text-secondary hover:text-text-primary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tab === 'users' ? 'User Management' : 'System Health'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === 'users' ? <UsersTab /> : <HealthTab />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Users Tab ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function UsersTab(): React.ReactElement {
|
|
||||||
const [users, setUsers] = useState<UserDto[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
|
||||||
|
|
||||||
const loadUsers = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const data = await api<UserListDto>('/api/admin/users');
|
|
||||||
setUsers(data.users);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load users');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadUsers();
|
|
||||||
}, [loadUsers]);
|
|
||||||
|
|
||||||
async function handleRoleToggle(user: UserDto): Promise<void> {
|
|
||||||
const newRole = user.role === 'admin' ? 'member' : 'admin';
|
|
||||||
try {
|
|
||||||
await api(`/api/admin/users/${user.id}/role`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: { role: newRole },
|
|
||||||
});
|
|
||||||
await loadUsers();
|
|
||||||
} catch (err) {
|
|
||||||
alert(err instanceof Error ? err.message : 'Failed to update role');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleBanToggle(user: UserDto): Promise<void> {
|
|
||||||
const endpoint = user.banned ? 'unban' : 'ban';
|
|
||||||
try {
|
|
||||||
await api(`/api/admin/users/${user.id}/${endpoint}`, { method: 'POST' });
|
|
||||||
await loadUsers();
|
|
||||||
} catch (err) {
|
|
||||||
alert(err instanceof Error ? err.message : 'Failed to update ban status');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(user: UserDto): Promise<void> {
|
|
||||||
if (!confirm(`Delete user ${user.email}? This cannot be undone.`)) return;
|
|
||||||
try {
|
|
||||||
await api(`/api/admin/users/${user.id}`, { method: 'DELETE' });
|
|
||||||
await loadUsers();
|
|
||||||
} catch (err) {
|
|
||||||
alert(err instanceof Error ? err.message : 'Failed to delete user');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <p className="text-sm text-text-muted">Loading users...</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-4">
|
|
||||||
<p className="text-sm text-red-400">{error}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void loadUsers()}
|
|
||||||
className="mt-2 text-xs text-red-300 underline hover:no-underline"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<p className="text-sm text-text-muted">{users.length} user(s)</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowCreate(true)}
|
|
||||||
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white transition-colors hover:bg-blue-700"
|
|
||||||
>
|
|
||||||
+ New User
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showCreate && (
|
|
||||||
<CreateUserForm
|
|
||||||
onCancel={() => setShowCreate(false)}
|
|
||||||
onCreated={() => {
|
|
||||||
setShowCreate(false);
|
|
||||||
void loadUsers();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{users.length === 0 ? (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 text-center">
|
|
||||||
<p className="text-sm text-text-muted">No users found</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-hidden rounded-lg border border-surface-border">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-surface-border bg-surface-elevated text-left text-xs text-text-muted">
|
|
||||||
<th className="px-4 py-2 font-medium">Name / Email</th>
|
|
||||||
<th className="px-4 py-2 font-medium">Role</th>
|
|
||||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Status</th>
|
|
||||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Created</th>
|
|
||||||
<th className="px-4 py-2 font-medium">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{users.map((user) => (
|
|
||||||
<tr key={user.id} className="border-b border-surface-border last:border-b-0">
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="text-sm font-medium text-text-primary">{user.name}</div>
|
|
||||||
<div className="text-xs text-text-muted">{user.email}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'inline-flex rounded-full px-2 py-0.5 text-xs font-medium',
|
|
||||||
user.role === 'admin'
|
|
||||||
? 'bg-purple-500/20 text-purple-400'
|
|
||||||
: 'bg-surface-elevated text-text-secondary',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{user.role}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="hidden px-4 py-3 md:table-cell">
|
|
||||||
{user.banned ? (
|
|
||||||
<span className="inline-flex rounded-full bg-red-500/20 px-2 py-0.5 text-xs font-medium text-red-400">
|
|
||||||
Banned
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex rounded-full bg-green-500/20 px-2 py-0.5 text-xs font-medium text-green-400">
|
|
||||||
Active
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="hidden px-4 py-3 text-xs text-text-muted md:table-cell">
|
|
||||||
{new Date(user.createdAt).toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleRoleToggle(user)}
|
|
||||||
className="text-xs text-blue-400 hover:text-blue-300"
|
|
||||||
title={user.role === 'admin' ? 'Demote to member' : 'Promote to admin'}
|
|
||||||
>
|
|
||||||
{user.role === 'admin' ? 'Demote' : 'Promote'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleBanToggle(user)}
|
|
||||||
className={cn(
|
|
||||||
'text-xs',
|
|
||||||
user.banned
|
|
||||||
? 'text-green-400 hover:text-green-300'
|
|
||||||
: 'text-yellow-400 hover:text-yellow-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{user.banned ? 'Unban' : 'Ban'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleDelete(user)}
|
|
||||||
className="text-xs text-red-400 hover:text-red-300"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Create User Form ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface CreateUserFormProps {
|
|
||||||
onCancel: () => void;
|
|
||||||
onCreated: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CreateUserForm({ onCancel, onCreated }: CreateUserFormProps): React.ReactElement {
|
|
||||||
const [name, setName] = useState('');
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [role, setRole] = useState('member');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent): Promise<void> {
|
|
||||||
e.preventDefault();
|
|
||||||
setSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await api('/api/admin/users', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { name, email, password, role },
|
|
||||||
});
|
|
||||||
onCreated();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to create user');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
|
||||||
<h3 className="mb-3 text-sm font-medium text-text-primary">Create New User</h3>
|
|
||||||
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-3">
|
|
||||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs text-text-muted">Name</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs text-text-muted">Email</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
required
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs text-text-muted">Password</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
required
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs text-text-muted">Role</label>
|
|
||||||
<select
|
|
||||||
value={role}
|
|
||||||
onChange={(e) => setRole(e.target.value)}
|
|
||||||
className="w-full rounded-md border border-surface-border bg-surface-elevated px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<option value="member">member</option>
|
|
||||||
<option value="admin">admin</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onCancel}
|
|
||||||
className="rounded-md px-3 py-1.5 text-sm text-text-muted hover:text-text-primary"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={submitting}
|
|
||||||
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{submitting ? 'Creating...' : 'Create'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Health Tab ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function HealthTab(): React.ReactElement {
|
|
||||||
const [health, setHealth] = useState<HealthStatusDto | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const loadHealth = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const data = await api<HealthStatusDto>('/api/admin/health');
|
|
||||||
setHealth(data);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load health');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadHealth();
|
|
||||||
}, [loadHealth]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <p className="text-sm text-text-muted">Loading health status...</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-red-500/30 bg-red-500/10 p-4">
|
|
||||||
<p className="text-sm text-red-400">{error}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void loadHealth()}
|
|
||||||
className="mt-2 text-xs text-red-300 underline hover:no-underline"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!health) return <></>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<StatusBadge status={health.status} />
|
|
||||||
<span className="text-sm text-text-muted">
|
|
||||||
Last checked: {new Date(health.checkedAt).toLocaleTimeString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void loadHealth()}
|
|
||||||
className="text-xs text-blue-400 hover:text-blue-300"
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
{/* Database */}
|
|
||||||
<HealthCard title="Database (PostgreSQL)" status={health.database.status}>
|
|
||||||
{health.database.latencyMs !== undefined && (
|
|
||||||
<p className="text-xs text-text-muted">Latency: {health.database.latencyMs}ms</p>
|
|
||||||
)}
|
|
||||||
{health.database.error && <p className="text-xs text-red-400">{health.database.error}</p>}
|
|
||||||
</HealthCard>
|
|
||||||
|
|
||||||
{/* Cache */}
|
|
||||||
<HealthCard title="Cache (Valkey)" status={health.cache.status}>
|
|
||||||
{health.cache.latencyMs !== undefined && (
|
|
||||||
<p className="text-xs text-text-muted">Latency: {health.cache.latencyMs}ms</p>
|
|
||||||
)}
|
|
||||||
{health.cache.error && <p className="text-xs text-red-400">{health.cache.error}</p>}
|
|
||||||
</HealthCard>
|
|
||||||
|
|
||||||
{/* Agent Pool */}
|
|
||||||
<HealthCard title="Agent Pool" status="ok">
|
|
||||||
<p className="text-xs text-text-muted">
|
|
||||||
Active sessions: {health.agentPool.activeSessions}
|
|
||||||
</p>
|
|
||||||
</HealthCard>
|
|
||||||
|
|
||||||
{/* Providers */}
|
|
||||||
<HealthCard
|
|
||||||
title="LLM Providers"
|
|
||||||
status={health.providers.some((p) => p.available) ? 'ok' : 'error'}
|
|
||||||
>
|
|
||||||
{health.providers.length === 0 ? (
|
|
||||||
<p className="text-xs text-text-muted">No providers configured</p>
|
|
||||||
) : (
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{health.providers.map((p) => (
|
|
||||||
<li key={p.id} className="flex items-center justify-between text-xs">
|
|
||||||
<span className="text-text-secondary">{p.name}</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'rounded-full px-1.5 py-0.5',
|
|
||||||
p.available ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{p.available ? `${p.modelCount} models` : 'unavailable'}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</HealthCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helper Components ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: 'ok' | 'degraded' | 'error' }): React.ReactElement {
|
|
||||||
const map = {
|
|
||||||
ok: 'bg-green-500/20 text-green-400',
|
|
||||||
degraded: 'bg-yellow-500/20 text-yellow-400',
|
|
||||||
error: 'bg-red-500/20 text-red-400',
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<span className={cn('rounded-full px-2 py-0.5 text-xs font-medium capitalize', map[status])}>
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HealthCardProps {
|
|
||||||
title: string;
|
|
||||||
status: 'ok' | 'error';
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function HealthCard({ title, status, children }: HealthCardProps): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<h3 className="text-sm font-medium text-text-primary">{title}</h3>
|
|
||||||
<span
|
|
||||||
className={cn('h-2 w-2 rounded-full', status === 'ok' ? 'bg-green-400' : 'bg-red-400')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -35,7 +35,6 @@ 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>> {
|
||||||
@@ -65,49 +64,21 @@ 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 () => {
|
||||||
mockHealthyLoad();
|
apiMock
|
||||||
|
.mockResolvedValueOnce(projectFixtures[0])
|
||||||
|
.mockResolvedValueOnce(missionFixtures)
|
||||||
|
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
||||||
|
|
||||||
await renderProjectDetailPage();
|
await renderProjectDetailPage();
|
||||||
|
|
||||||
expect(apiMock.mock.calls.map((call) => call[0])).toEqual([
|
expect(apiMock.mock.calls).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');
|
||||||
@@ -130,7 +101,10 @@ 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 () => {
|
||||||
mockHealthyLoad();
|
apiMock
|
||||||
|
.mockResolvedValueOnce(projectFixtures[0])
|
||||||
|
.mockResolvedValueOnce(missionFixtures)
|
||||||
|
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
||||||
|
|
||||||
await renderProjectDetailPage();
|
await renderProjectDetailPage();
|
||||||
|
|
||||||
@@ -160,153 +134,35 @@ describe('ProjectDetailPage', () => {
|
|||||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows verified completion verdicts when the task collection is current', async () => {
|
it('renders the project with an empty missions tab when the missions request fails', async () => {
|
||||||
mockHealthyLoad();
|
|
||||||
|
|
||||||
await renderProjectDetailPage();
|
|
||||||
|
|
||||||
const doneCard = [...container.querySelectorAll('div')].find(
|
|
||||||
(candidate) => candidate.textContent === 'Done1',
|
|
||||||
);
|
|
||||||
expect(doneCard).toBeTruthy();
|
|
||||||
const inProgressCard = [...container.querySelectorAll('div')].find(
|
|
||||||
(candidate) => candidate.textContent === 'In Progress1',
|
|
||||||
);
|
|
||||||
expect(inProgressCard).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders an explicit unavailable missions tab when the missions request fails (partial, not empty)', async () => {
|
|
||||||
apiMock
|
apiMock
|
||||||
.mockResolvedValueOnce(projectFixtures[0])
|
.mockResolvedValueOnce(projectFixtures[0])
|
||||||
.mockRejectedValueOnce(new Error('Missions request failed'))
|
.mockRejectedValueOnce(new Error('Missions request failed'))
|
||||||
.mockResolvedValueOnce(projectOneTasks);
|
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
||||||
|
|
||||||
await renderProjectDetailPage();
|
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');
|
expect(container.textContent).toContain('Mosaic Stack');
|
||||||
const partial = container.querySelector('[role="status"]');
|
expect(container.querySelector('[role="alert"]')).toBeNull();
|
||||||
expect(partial?.textContent).toContain('Missions');
|
|
||||||
expect(partial?.textContent).toContain('unavailable');
|
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
clickButtonByText('Missions (?)');
|
clickButtonByText('Missions (0)');
|
||||||
});
|
});
|
||||||
|
|
||||||
const alert = container.querySelector('[role="alert"]');
|
expect(container.textContent).toContain('No missions for this project');
|
||||||
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 () => {
|
it('renders a visible alert when the project request fails and lets the user navigate back', 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(projectOneTasks);
|
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
|
||||||
|
|
||||||
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,30 +1,14 @@
|
|||||||
import { useState, type ReactElement } from 'react';
|
import { useEffect, 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 {
|
import { getErrorMessage } from './page-errors';
|
||||||
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';
|
||||||
|
|
||||||
@@ -67,75 +51,55 @@ 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 enabled = id.length > 0;
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
|
const [missions, setMissions] = useState<Mission[]>([]);
|
||||||
// Primary collection gates the surface; missions and tasks are secondaries
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
// whose failures degrade the surface to `partial` instead of rendering
|
const [loading, setLoading] = useState(true);
|
||||||
// empty healthy lists.
|
const [error, setError] = useState<string | null>(null);
|
||||||
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);
|
||||||
|
|
||||||
const surface = combineFreshness(project.freshness, [missions.freshness, tasks.freshness]);
|
useEffect(() => {
|
||||||
const tasksVerified = tasks.freshness === 'current';
|
if (!id) {
|
||||||
const projectMissions = missions.data?.filter((mission) => mission.projectId === id) ?? null;
|
setError('Project id is missing.');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const retryAll = (): void => {
|
let cancelled = false;
|
||||||
void Promise.all([project.revalidate(), missions.revalidate(), tasks.revalidate()]);
|
setLoading(true);
|
||||||
};
|
setError(null);
|
||||||
|
|
||||||
if (!enabled) {
|
void Promise.all([
|
||||||
return (
|
api<Project>('/api/projects/' + id),
|
||||||
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
api<Mission[]>('/api/missions').catch(() => [] as Mission[]),
|
||||||
<header className="mb-6 border-b px-1 pb-3">
|
api<Task[]>('/api/tasks?projectId=' + id).catch(() => [] as Task[]),
|
||||||
<h1 className="text-2xl font-semibold">Project</h1>
|
])
|
||||||
</header>
|
.then(([loadedProject, allMissions, loadedTasks]) => {
|
||||||
<div role="alert" className="rounded-lg border border-error/40 px-4 py-3 text-sm">
|
if (cancelled) return;
|
||||||
Project id is missing.
|
setProject(loadedProject);
|
||||||
</div>
|
setMissions(allMissions.filter((mission) => mission.projectId === id));
|
||||||
<button
|
setTasks(loadedTasks);
|
||||||
type="button"
|
})
|
||||||
onClick={() => navigate('/projects')}
|
.catch((caught: unknown) => {
|
||||||
className="mt-4 w-fit text-sm underline"
|
if (cancelled) return;
|
||||||
>
|
setError(getErrorMessage(caught, 'Failed to load project.'));
|
||||||
Back to projects
|
})
|
||||||
</button>
|
.finally(() => {
|
||||||
</div>
|
if (cancelled) return;
|
||||||
);
|
setLoading(false);
|
||||||
}
|
});
|
||||||
|
|
||||||
if (project.freshness === 'unknown') {
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
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">
|
||||||
@@ -146,17 +110,15 @@ function ProjectDetail({ id }: { id: string }): ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (project.freshness === 'unavailable' || project.data === null) {
|
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>
|
||||||
<UnavailableDataNotice
|
<div role="alert" className="rounded-lg border border-error/40 px-4 py-3 text-sm">
|
||||||
title="This project"
|
{error ?? 'Project not found.'}
|
||||||
detail={describeFailure(project.failure)}
|
</div>
|
||||||
onRetry={retryAll}
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate('/projects')}
|
onClick={() => navigate('/projects')}
|
||||||
@@ -168,48 +130,18 @@ function ProjectDetail({ id }: { id: string }): ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectTasks = tasks.data ?? null;
|
|
||||||
const filteredTasks =
|
const filteredTasks =
|
||||||
projectTasks === null
|
taskFilter === 'all' ? tasks : tasks.filter((task) => task.status === taskFilter);
|
||||||
? []
|
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: 'tasks',
|
{ id: 'missions', label: `Missions (${missions.length})` },
|
||||||
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 data-freshness={surface} 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">
|
||||||
<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
|
||||||
@@ -220,64 +152,49 @@ function ProjectDetail({ id }: { id: string }): ReactElement {
|
|||||||
Projects
|
Projects
|
||||||
</button>
|
</button>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-text-primary">{project.data.name}</span>
|
<span className="text-text-primary">{project.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.data.name}</h1>
|
<h1 className="text-2xl font-semibold text-text-primary">{project.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.data.status] ?? 'bg-gray-600/20 text-gray-400',
|
projectStatusColors[project.status] ?? 'bg-gray-600/20 text-gray-400',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{project.data.status}
|
{project.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{project.data.description ? (
|
{project.description ? (
|
||||||
<p className="mt-1 text-sm text-text-muted">{project.data.description}</p>
|
<p className="mt-1 text-sm text-text-muted">{project.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.data.createdAt).toLocaleDateString()} · Updated{' '}
|
Created {new Date(project.createdAt).toLocaleDateString()} · Updated{' '}
|
||||||
{new Date(project.data.updatedAt).toLocaleDateString()}
|
{new Date(project.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
|
<StatCard label="Tasks" value={String(tasks.length)} />
|
||||||
label="Tasks"
|
|
||||||
value={projectTasks === null ? UNKNOWN_VERDICT : String(projectTasks.length)}
|
|
||||||
/>
|
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Done"
|
label="Done"
|
||||||
value={verdictValue(tasksVerified, String(doneCount))}
|
value={String(tasks.filter((task) => task.status === 'done').length)}
|
||||||
valueClass={tasksVerified ? 'text-success' : undefined}
|
valueClass="text-success"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="In Progress"
|
label="In Progress"
|
||||||
value={verdictValue(tasksVerified, String(inProgressCount))}
|
value={String(tasks.filter((task) => task.status === 'in-progress').length)}
|
||||||
valueClass={tasksVerified ? 'text-blue-400' : undefined}
|
valueClass="text-blue-400"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Blocked"
|
label="Blocked"
|
||||||
value={verdictValue(tasksVerified, String(blockedCount))}
|
value={String(tasks.filter((task) => task.status === 'blocked').length)}
|
||||||
valueClass={tasksVerified && blockedCount > 0 ? 'text-error' : undefined}
|
valueClass={tasks.some((task) => task.status === 'blocked') ? 'text-error' : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -294,43 +211,23 @@ function ProjectDetail({ id }: { id: string }): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === 'overview' ? (
|
{activeTab === 'overview' ? (
|
||||||
<OverviewTab project={project.data} missions={projectMissions} tasks={projectTasks} />
|
<OverviewTab project={project} missions={missions} tasks={tasks} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{activeTab === 'tasks' ? (
|
{activeTab === 'tasks' ? (
|
||||||
<div>
|
<div>
|
||||||
{projectTasks === null ? (
|
<div className="mb-4">
|
||||||
<UnavailableDataNotice
|
<TaskStatusSummary
|
||||||
title="Tasks"
|
tasks={tasks}
|
||||||
detail={describeFailure(tasks.failure)}
|
activeFilter={taskFilter}
|
||||||
onRetry={retryAll}
|
onFilterChange={setTaskFilter}
|
||||||
/>
|
/>
|
||||||
) : (
|
</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' ? (
|
{activeTab === 'missions' ? <MissionTimeline missions={missions} /> : null}
|
||||||
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">
|
||||||
@@ -351,26 +248,18 @@ function OverviewTab({
|
|||||||
tasks,
|
tasks,
|
||||||
}: {
|
}: {
|
||||||
project: Project;
|
project: Project;
|
||||||
missions: Mission[] | null;
|
missions: Mission[];
|
||||||
tasks: Task[] | null;
|
tasks: Task[];
|
||||||
}): ReactElement {
|
}): ReactElement {
|
||||||
const recentTasks =
|
const recentTasks = [...tasks]
|
||||||
tasks === null
|
.sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime())
|
||||||
? null
|
.slice(0, 5);
|
||||||
: [...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 === null ? (
|
{recentTasks.length === 0 ? (
|
||||||
<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>
|
||||||
@@ -398,9 +287,7 @@ 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 === null ? (
|
{missions.length === 0 ? (
|
||||||
<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,7 +51,6 @@ 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>> {
|
||||||
@@ -72,22 +71,6 @@ 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>();
|
||||||
@@ -108,7 +91,7 @@ describe('ProjectsPage', () => {
|
|||||||
|
|
||||||
const router = await renderProjectsPage();
|
const router = await renderProjectsPage();
|
||||||
|
|
||||||
expect(apiMock.mock.calls[0]?.[0]).toBe('/api/projects');
|
expect(apiMock).toHaveBeenCalledWith('/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');
|
||||||
|
|
||||||
@@ -125,7 +108,7 @@ describe('ProjectsPage', () => {
|
|||||||
expect(container.textContent).toContain('Project detail target');
|
expect(container.textContent).toContain('Project detail target');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the empty state only for a verified empty collection', async () => {
|
it('renders the empty state when the API returns no projects', async () => {
|
||||||
apiMock.mockResolvedValueOnce([]);
|
apiMock.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
await renderProjectsPage();
|
await renderProjectsPage();
|
||||||
@@ -134,12 +117,9 @@ 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 failed fetch as an explicit unavailable state, never an empty collection', async () => {
|
it('renders a visible alert when the projects request fails', async () => {
|
||||||
apiMock.mockRejectedValueOnce(new Error('Projects are unavailable'));
|
apiMock.mockRejectedValueOnce(new Error('Projects are unavailable'));
|
||||||
|
|
||||||
await renderProjectsPage();
|
await renderProjectsPage();
|
||||||
@@ -147,51 +127,5 @@ 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,51 +1,53 @@
|
|||||||
import { type ReactElement } from 'react';
|
import { useEffect, useState, 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 { useFreshCollection, describeFailure } from '@/lib/freshness/use-fresh-collection';
|
import { getErrorMessage } from './page-errors';
|
||||||
import { validateProjectCollection } from '@/lib/freshness/validators';
|
|
||||||
|
|
||||||
export function ProjectsPage(): ReactElement {
|
export function ProjectsPage(): ReactElement {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const projects = useFreshCollection<Project[]>({
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
source: 'gateway:/api/projects',
|
const [loading, setLoading] = useState(true);
|
||||||
fetcher: (signal) => api<unknown>('/api/projects', { signal }),
|
const [error, setError] = useState<string | null>(null);
|
||||||
validate: validateProjectCollection,
|
|
||||||
// Projects carry workspace identity (userId) that is only knowable from
|
useEffect(() => {
|
||||||
// the payload itself, so a restored entry cannot be scope-checked before
|
let cancelled = false;
|
||||||
// display. Conservative choice: no last-known restore for this surface;
|
|
||||||
// cross-workspace switching is still invalidated at verification time.
|
void api<Project[]>('/api/projects')
|
||||||
});
|
.then((response) => {
|
||||||
const retry = (): void => {
|
if (cancelled) return;
|
||||||
void projects.revalidate();
|
setProjects(response);
|
||||||
};
|
})
|
||||||
|
.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
|
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
|
||||||
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>
|
||||||
|
|
||||||
{projects.freshness === 'stale' && projects.snapshot ? (
|
{error ? (
|
||||||
<div className="mb-6">
|
<div role="alert" className="mb-6 rounded-lg border border-error/40 px-4 py-3 text-sm">
|
||||||
<StaleDataNotice label={projects.snapshot} onRetry={retry} />
|
{error}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{projects.freshness === 'unknown' ? (
|
{loading ? (
|
||||||
<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.freshness === 'unavailable' ? (
|
) : projects.length === 0 ? (
|
||||||
<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">
|
||||||
@@ -54,7 +56,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.data ?? []).map((project) => (
|
{projects.map((project) => (
|
||||||
<ProjectCard
|
<ProjectCard
|
||||||
key={project.id}
|
key={project.id}
|
||||||
project={project}
|
project={project}
|
||||||
|
|||||||
@@ -1,178 +0,0 @@
|
|||||||
import { act } from 'react';
|
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
|
||||||
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
|
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
const { apiMock, useSessionMock, updateUserMock } = vi.hoisted(() => ({
|
|
||||||
apiMock: vi.fn(),
|
|
||||||
useSessionMock: vi.fn(),
|
|
||||||
updateUserMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/api', () => ({
|
|
||||||
api: apiMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@/lib/auth-client', () => ({
|
|
||||||
useSession: useSessionMock,
|
|
||||||
authClient: { updateUser: updateUserMock },
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { SettingsPage } from './settings';
|
|
||||||
|
|
||||||
let root: Root | null = null;
|
|
||||||
let container: HTMLDivElement;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
|
|
||||||
configurable: true,
|
|
||||||
value: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await act(async () => {
|
|
||||||
root?.unmount();
|
|
||||||
});
|
|
||||||
document.body.replaceChildren();
|
|
||||||
root = null;
|
|
||||||
apiMock.mockReset();
|
|
||||||
useSessionMock.mockReset();
|
|
||||||
updateUserMock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function renderSettingsPage(): Promise<void> {
|
|
||||||
const routes: RouteObject[] = [{ path: '/settings', element: <SettingsPage /> }];
|
|
||||||
const router = createMemoryRouter(routes, { initialEntries: ['/settings'] });
|
|
||||||
container = document.createElement('div');
|
|
||||||
document.body.append(container);
|
|
||||||
root = createRoot(container);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
root?.render(<RouterProvider router={router} />);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function clickButtonByText(text: string): Promise<void> {
|
|
||||||
const button = [...container.querySelectorAll('button')].find((candidate) =>
|
|
||||||
candidate.textContent?.includes(text),
|
|
||||||
);
|
|
||||||
if (!button) {
|
|
||||||
throw new Error(`Button containing "${text}" not found`);
|
|
||||||
}
|
|
||||||
return act(async () => {
|
|
||||||
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = {
|
|
||||||
user: { id: 'u-1', name: 'Test User', email: '[email protected]', image: null },
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('SettingsPage profile tab', () => {
|
|
||||||
it('renders the profile form from the session and saves via authClient', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: session, isPending: false });
|
|
||||||
updateUserMock.mockResolvedValue({});
|
|
||||||
|
|
||||||
await renderSettingsPage();
|
|
||||||
|
|
||||||
const nameInput = container.querySelector<HTMLInputElement>('#profile-name');
|
|
||||||
const emailInput = container.querySelector<HTMLInputElement>('#profile-email');
|
|
||||||
expect(nameInput?.value).toBe('Test User');
|
|
||||||
expect(emailInput?.value).toBe('[email protected]');
|
|
||||||
expect(emailInput?.disabled).toBe(true);
|
|
||||||
|
|
||||||
await clickButtonByText('Save changes');
|
|
||||||
|
|
||||||
expect(updateUserMock).toHaveBeenCalledWith({ name: 'Test User', image: null });
|
|
||||||
expect(container.textContent).toContain('Saved!');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('surfaces an update failure without clearing the form', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: session, isPending: false });
|
|
||||||
updateUserMock.mockResolvedValue({ error: { message: 'name rejected' } });
|
|
||||||
|
|
||||||
await renderSettingsPage();
|
|
||||||
await clickButtonByText('Save changes');
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('name rejected');
|
|
||||||
expect(container.querySelector<HTMLInputElement>('#profile-name')?.value).toBe('Test User');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('SettingsPage appearance tab', () => {
|
|
||||||
it('loads preferences and posts each changed preference on save', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: session, isPending: false });
|
|
||||||
apiMock.mockImplementation((path: string) =>
|
|
||||||
path.startsWith('/api/memory/preferences?')
|
|
||||||
? Promise.resolve([{ key: 'ui.theme', value: 'dark', category: 'appearance' }])
|
|
||||||
: Promise.resolve({}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await renderSettingsPage();
|
|
||||||
await clickButtonByText('Appearance');
|
|
||||||
|
|
||||||
expect(apiMock).toHaveBeenCalledWith('/api/memory/preferences?category=appearance');
|
|
||||||
|
|
||||||
await clickButtonByText('Save changes');
|
|
||||||
|
|
||||||
expect(apiMock).toHaveBeenCalledWith('/api/memory/preferences', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { key: 'ui.theme', value: 'dark', category: 'appearance', source: 'user' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('SettingsPage providers tab', () => {
|
|
||||||
it('loads LLM and SSO providers and runs a connection test', async () => {
|
|
||||||
useSessionMock.mockReturnValue({ data: session, isPending: false });
|
|
||||||
apiMock.mockImplementation((path: string, opts?: { method?: string }) => {
|
|
||||||
if (path === '/api/providers' && opts === undefined) {
|
|
||||||
return Promise.resolve([
|
|
||||||
{
|
|
||||||
id: 'ollama',
|
|
||||||
name: 'Ollama',
|
|
||||||
available: true,
|
|
||||||
models: [
|
|
||||||
{
|
|
||||||
id: 'llama3.2',
|
|
||||||
provider: 'ollama',
|
|
||||||
name: 'Llama 3.2',
|
|
||||||
reasoning: false,
|
|
||||||
contextWindow: 128_000,
|
|
||||||
maxTokens: 4096,
|
|
||||||
inputTypes: ['text'],
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
if (path === '/api/sso/providers') {
|
|
||||||
return Promise.resolve([]);
|
|
||||||
}
|
|
||||||
if (path === '/api/providers/test') {
|
|
||||||
return Promise.resolve({ providerId: 'ollama', reachable: true, latencyMs: 12 });
|
|
||||||
}
|
|
||||||
return Promise.resolve([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
await renderSettingsPage();
|
|
||||||
await clickButtonByText('Providers');
|
|
||||||
|
|
||||||
expect(container.textContent).toContain('Ollama');
|
|
||||||
expect(container.textContent).toContain('1 model');
|
|
||||||
|
|
||||||
await clickButtonByText('Test');
|
|
||||||
|
|
||||||
expect(apiMock).toHaveBeenCalledWith('/api/providers/test', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { providerId: 'ollama' },
|
|
||||||
});
|
|
||||||
expect(container.textContent).toContain('Reachable');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,826 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { authClient, useSession } from '@/lib/auth-client';
|
|
||||||
import type { SsoProviderDiscovery } from '@/lib/sso';
|
|
||||||
import { SsoProviderSection } from '@/components/settings/sso-provider-section';
|
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
interface ModelInfo {
|
|
||||||
id: string;
|
|
||||||
provider: string;
|
|
||||||
name: string;
|
|
||||||
reasoning: boolean;
|
|
||||||
contextWindow: number;
|
|
||||||
maxTokens: number;
|
|
||||||
inputTypes: ('text' | 'image')[];
|
|
||||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProviderInfo {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
available: boolean;
|
|
||||||
models: ModelInfo[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TestConnectionResult {
|
|
||||||
providerId: string;
|
|
||||||
reachable: boolean;
|
|
||||||
latencyMs?: number;
|
|
||||||
error?: string;
|
|
||||||
discoveredModels?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type TestState = 'idle' | 'testing' | 'success' | 'error';
|
|
||||||
|
|
||||||
interface ProviderTestStatus {
|
|
||||||
state: TestState;
|
|
||||||
result?: TestConnectionResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Preference {
|
|
||||||
key: string;
|
|
||||||
value: unknown;
|
|
||||||
category: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Theme = 'light' | 'dark' | 'system';
|
|
||||||
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
|
||||||
type Tab = 'profile' | 'appearance' | 'notifications' | 'providers';
|
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function prefValue<T>(prefs: Preference[], key: string, fallback: T): T {
|
|
||||||
const p = prefs.find((x) => x.key === key);
|
|
||||||
if (p === undefined) return fallback;
|
|
||||||
return p.value as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function SettingsPage(): React.ReactElement {
|
|
||||||
const { data: session } = useSession();
|
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('profile');
|
|
||||||
|
|
||||||
const tabs: { id: Tab; label: string }[] = [
|
|
||||||
{ id: 'profile', label: 'Profile' },
|
|
||||||
{ id: 'appearance', label: 'Appearance' },
|
|
||||||
{ id: 'notifications', label: 'Notifications' },
|
|
||||||
{ id: 'providers', label: 'Providers' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-3xl space-y-6">
|
|
||||||
<h1 className="text-2xl font-semibold">Settings</h1>
|
|
||||||
|
|
||||||
{/* Tab bar */}
|
|
||||||
<div className="flex gap-1 border-b border-surface-border">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
|
||||||
activeTab === tab.id
|
|
||||||
? 'border-b-2 border-accent text-accent'
|
|
||||||
: 'text-text-secondary hover:text-text-primary'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === 'profile' && <ProfileTab session={session} />}
|
|
||||||
{activeTab === 'appearance' && <AppearanceTab />}
|
|
||||||
{activeTab === 'notifications' && <NotificationsTab />}
|
|
||||||
{activeTab === 'providers' && <ProvidersTab />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Profile Tab ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ProfileTab({
|
|
||||||
session,
|
|
||||||
}: {
|
|
||||||
session: { user: { id: string; name: string; email: string; image?: string | null } } | null;
|
|
||||||
}): React.ReactElement {
|
|
||||||
const [name, setName] = useState(session?.user.name ?? '');
|
|
||||||
const [image, setImage] = useState(session?.user.image ?? '');
|
|
||||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
|
||||||
|
|
||||||
// Sync from session when it loads
|
|
||||||
useEffect(() => {
|
|
||||||
if (session?.user) {
|
|
||||||
setName(session.user.name ?? '');
|
|
||||||
setImage(session.user.image ?? '');
|
|
||||||
}
|
|
||||||
}, [session]);
|
|
||||||
|
|
||||||
const handleSave = async (): Promise<void> => {
|
|
||||||
setSaveState('saving');
|
|
||||||
setErrorMsg('');
|
|
||||||
try {
|
|
||||||
const result = await authClient.updateUser({ name, image: image || null });
|
|
||||||
if (result.error) {
|
|
||||||
setErrorMsg(result.error.message ?? 'Failed to update profile');
|
|
||||||
setSaveState('error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaveState('saved');
|
|
||||||
setTimeout(() => setSaveState('idle'), 2000);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Failed to update profile';
|
|
||||||
setErrorMsg(message);
|
|
||||||
setSaveState('error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="space-y-4">
|
|
||||||
<h2 className="text-lg font-medium text-text-secondary">Profile</h2>
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-4">
|
|
||||||
<FormField label="Display Name" id="profile-name">
|
|
||||||
<input
|
|
||||||
id="profile-name"
|
|
||||||
type="text"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="Your name"
|
|
||||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Email" id="profile-email">
|
|
||||||
<input
|
|
||||||
id="profile-email"
|
|
||||||
type="email"
|
|
||||||
value={session?.user.email ?? ''}
|
|
||||||
disabled
|
|
||||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-muted opacity-60 cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-text-muted">Email cannot be changed here.</p>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="Avatar URL" id="profile-image">
|
|
||||||
<input
|
|
||||||
id="profile-image"
|
|
||||||
type="url"
|
|
||||||
value={image}
|
|
||||||
onChange={(e) => setImage(e.target.value)}
|
|
||||||
placeholder="https://example.com/avatar.png"
|
|
||||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
|
||||||
<SaveButton state={saveState} onClick={handleSave} />
|
|
||||||
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Appearance Tab ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function AppearanceTab(): React.ReactElement {
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [theme, setTheme] = useState<Theme>('system');
|
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
||||||
const [defaultModel, setDefaultModel] = useState('');
|
|
||||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api<Preference[]>('/api/memory/preferences?category=appearance')
|
|
||||||
.catch(() => [] as Preference[])
|
|
||||||
.then((p) => {
|
|
||||||
setTheme(prefValue<Theme>(p, 'ui.theme', 'system'));
|
|
||||||
setSidebarCollapsed(prefValue<boolean>(p, 'ui.sidebar_collapsed', false));
|
|
||||||
setDefaultModel(prefValue<string>(p, 'ui.default_model', ''));
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSave = async (): Promise<void> => {
|
|
||||||
setSaveState('saving');
|
|
||||||
setErrorMsg('');
|
|
||||||
try {
|
|
||||||
await Promise.all([
|
|
||||||
api('/api/memory/preferences', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { key: 'ui.theme', value: theme, category: 'appearance', source: 'user' },
|
|
||||||
}),
|
|
||||||
api('/api/memory/preferences', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
key: 'ui.sidebar_collapsed',
|
|
||||||
value: sidebarCollapsed,
|
|
||||||
category: 'appearance',
|
|
||||||
source: 'user',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
...(defaultModel
|
|
||||||
? [
|
|
||||||
api('/api/memory/preferences', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
key: 'ui.default_model',
|
|
||||||
value: defaultModel,
|
|
||||||
category: 'appearance',
|
|
||||||
source: 'user',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
]);
|
|
||||||
setSaveState('saved');
|
|
||||||
setTimeout(() => setSaveState('idle'), 2000);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
|
||||||
setErrorMsg(message);
|
|
||||||
setSaveState('error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<h2 className="mb-4 text-lg font-medium text-text-secondary">Appearance</h2>
|
|
||||||
<p className="text-sm text-text-muted">Loading preferences...</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="space-y-4">
|
|
||||||
<h2 className="text-lg font-medium text-text-secondary">Appearance</h2>
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-6">
|
|
||||||
{/* Theme */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-text-primary mb-2">Theme</label>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
{(['system', 'light', 'dark'] as Theme[]).map((t) => (
|
|
||||||
<button
|
|
||||||
key={t}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setTheme(t)}
|
|
||||||
className={`rounded-lg border px-4 py-2 text-sm capitalize transition-colors ${
|
|
||||||
theme === t
|
|
||||||
? 'border-accent bg-accent/10 text-accent'
|
|
||||||
: 'border-surface-border bg-surface-elevated text-text-secondary hover:border-accent/50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sidebar collapsed default */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-text-primary">Collapse sidebar by default</p>
|
|
||||||
<p className="text-xs text-text-muted">Start with sidebar collapsed on page load</p>
|
|
||||||
</div>
|
|
||||||
<Toggle checked={sidebarCollapsed} onChange={setSidebarCollapsed} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Default model */}
|
|
||||||
<FormField label="Default Model" id="default-model">
|
|
||||||
<input
|
|
||||||
id="default-model"
|
|
||||||
type="text"
|
|
||||||
value={defaultModel}
|
|
||||||
onChange={(e) => setDefaultModel(e.target.value)}
|
|
||||||
placeholder="e.g. ollama/llama3.2"
|
|
||||||
className="mt-1 block w-full rounded-lg border border-surface-border bg-surface-elevated px-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-text-muted">
|
|
||||||
Model ID to pre-select for new conversations.
|
|
||||||
</p>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
|
||||||
<SaveButton state={saveState} onClick={handleSave} />
|
|
||||||
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Notifications Tab ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function NotificationsTab(): React.ReactElement {
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [emailAgentComplete, setEmailAgentComplete] = useState(false);
|
|
||||||
const [emailMentions, setEmailMentions] = useState(true);
|
|
||||||
const [emailDigest, setEmailDigest] = useState(false);
|
|
||||||
const [saveState, setSaveState] = useState<SaveState>('idle');
|
|
||||||
const [errorMsg, setErrorMsg] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api<Preference[]>('/api/memory/preferences?category=communication')
|
|
||||||
.catch(() => [] as Preference[])
|
|
||||||
.then((p) => {
|
|
||||||
setEmailAgentComplete(prefValue<boolean>(p, 'notify.email_agent_complete', false));
|
|
||||||
setEmailMentions(prefValue<boolean>(p, 'notify.email_mentions', true));
|
|
||||||
setEmailDigest(prefValue<boolean>(p, 'notify.email_digest', false));
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSave = async (): Promise<void> => {
|
|
||||||
setSaveState('saving');
|
|
||||||
setErrorMsg('');
|
|
||||||
try {
|
|
||||||
await Promise.all([
|
|
||||||
api('/api/memory/preferences', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
key: 'notify.email_agent_complete',
|
|
||||||
value: emailAgentComplete,
|
|
||||||
category: 'communication',
|
|
||||||
source: 'user',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
api('/api/memory/preferences', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
key: 'notify.email_mentions',
|
|
||||||
value: emailMentions,
|
|
||||||
category: 'communication',
|
|
||||||
source: 'user',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
api('/api/memory/preferences', {
|
|
||||||
method: 'POST',
|
|
||||||
body: {
|
|
||||||
key: 'notify.email_digest',
|
|
||||||
value: emailDigest,
|
|
||||||
category: 'communication',
|
|
||||||
source: 'user',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
setSaveState('saved');
|
|
||||||
setTimeout(() => setSaveState('idle'), 2000);
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Failed to save preferences';
|
|
||||||
setErrorMsg(message);
|
|
||||||
setSaveState('error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<h2 className="mb-4 text-lg font-medium text-text-secondary">Notifications</h2>
|
|
||||||
<p className="text-sm text-text-muted">Loading preferences...</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="space-y-4">
|
|
||||||
<h2 className="text-lg font-medium text-text-secondary">Notifications</h2>
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-6 space-y-6">
|
|
||||||
<p className="text-xs text-text-muted">Configure when you receive email notifications.</p>
|
|
||||||
|
|
||||||
<NotifyRow
|
|
||||||
label="Agent task completed"
|
|
||||||
description="Email when an agent finishes a task"
|
|
||||||
checked={emailAgentComplete}
|
|
||||||
onChange={setEmailAgentComplete}
|
|
||||||
/>
|
|
||||||
<NotifyRow
|
|
||||||
label="Mentions"
|
|
||||||
description="Email when you are mentioned in a conversation"
|
|
||||||
checked={emailMentions}
|
|
||||||
onChange={setEmailMentions}
|
|
||||||
/>
|
|
||||||
<NotifyRow
|
|
||||||
label="Weekly digest"
|
|
||||||
description="Weekly summary of activity"
|
|
||||||
checked={emailDigest}
|
|
||||||
onChange={setEmailDigest}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 pt-2">
|
|
||||||
<SaveButton state={saveState} onClick={handleSave} />
|
|
||||||
{saveState === 'error' && errorMsg && <p className="text-sm text-error">{errorMsg}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Providers Tab ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ProvidersTab(): React.ReactElement {
|
|
||||||
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
|
||||||
const [ssoProviders, setSsoProviders] = useState<SsoProviderDiscovery[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [ssoLoading, setSsoLoading] = useState(true);
|
|
||||||
const [testStatuses, setTestStatuses] = useState<Record<string, ProviderTestStatus>>({});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api<ProviderInfo[]>('/api/providers')
|
|
||||||
.catch(() => [] as ProviderInfo[])
|
|
||||||
.then((p) => setProviders(p))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api<SsoProviderDiscovery[]>('/api/sso/providers')
|
|
||||||
.catch(() => [] as SsoProviderDiscovery[])
|
|
||||||
.then((providers) => setSsoProviders(providers))
|
|
||||||
.finally(() => setSsoLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const testConnection = useCallback(async (providerId: string): Promise<void> => {
|
|
||||||
setTestStatuses((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[providerId]: { state: 'testing' },
|
|
||||||
}));
|
|
||||||
try {
|
|
||||||
const result = await api<TestConnectionResult>('/api/providers/test', {
|
|
||||||
method: 'POST',
|
|
||||||
body: { providerId },
|
|
||||||
});
|
|
||||||
setTestStatuses((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[providerId]: { state: result.reachable ? 'success' : 'error', result },
|
|
||||||
}));
|
|
||||||
} catch {
|
|
||||||
setTestStatuses((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[providerId]: {
|
|
||||||
state: 'error',
|
|
||||||
result: { providerId, reachable: false, error: 'Request failed' },
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const defaultModel: ModelInfo | undefined = providers
|
|
||||||
.flatMap((p) => p.models)
|
|
||||||
.find((m) => providers.find((p) => p.id === m.provider)?.available);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="space-y-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h2 className="text-lg font-medium text-text-secondary">SSO Providers</h2>
|
|
||||||
<SsoProviderSection providers={ssoProviders} loading={ssoLoading} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h2 className="text-lg font-medium text-text-secondary">LLM Providers</h2>
|
|
||||||
{loading ? (
|
|
||||||
<p className="text-sm text-text-muted">Loading providers...</p>
|
|
||||||
) : providers.length === 0 ? (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card p-4">
|
|
||||||
<p className="text-sm text-text-muted">
|
|
||||||
No providers configured. Set{' '}
|
|
||||||
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
|
|
||||||
OLLAMA_BASE_URL
|
|
||||||
</code>{' '}
|
|
||||||
or{' '}
|
|
||||||
<code className="rounded bg-surface-elevated px-1 py-0.5 text-xs">
|
|
||||||
MOSAIC_CUSTOM_PROVIDERS
|
|
||||||
</code>{' '}
|
|
||||||
to add providers.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{providers.map((provider) => (
|
|
||||||
<ProviderCard
|
|
||||||
key={provider.id}
|
|
||||||
provider={provider}
|
|
||||||
defaultModel={defaultModel}
|
|
||||||
testStatus={testStatuses[provider.id] ?? { state: 'idle' }}
|
|
||||||
onTest={() => void testConnection(provider.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Shared UI Components ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function FormField({
|
|
||||||
label,
|
|
||||||
id,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
id: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<label htmlFor={id} className="block text-sm font-medium text-text-primary">
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Toggle({
|
|
||||||
checked,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
checked: boolean;
|
|
||||||
onChange: (v: boolean) => void;
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
aria-checked={checked}
|
|
||||||
onClick={() => onChange(!checked)}
|
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-surface-card ${
|
|
||||||
checked ? 'bg-accent' : 'bg-surface-border'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
||||||
checked ? 'translate-x-6' : 'translate-x-1'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NotifyRow({
|
|
||||||
label,
|
|
||||||
description,
|
|
||||||
checked,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
checked: boolean;
|
|
||||||
onChange: (v: boolean) => void;
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-text-primary">{label}</p>
|
|
||||||
<p className="text-xs text-text-muted">{description}</p>
|
|
||||||
</div>
|
|
||||||
<Toggle checked={checked} onChange={onChange} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SaveButton({
|
|
||||||
state,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
state: SaveState;
|
|
||||||
onClick: () => void;
|
|
||||||
}): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={state === 'saving'}
|
|
||||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent/90 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{state === 'saving' ? 'Saving...' : state === 'saved' ? 'Saved!' : 'Save changes'}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Provider Card (from original page) ──────────────────────────────────────
|
|
||||||
|
|
||||||
interface ProviderCardProps {
|
|
||||||
provider: ProviderInfo;
|
|
||||||
defaultModel: ModelInfo | undefined;
|
|
||||||
testStatus: ProviderTestStatus;
|
|
||||||
onTest: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProviderCard({
|
|
||||||
provider,
|
|
||||||
defaultModel,
|
|
||||||
testStatus,
|
|
||||||
onTest,
|
|
||||||
}: ProviderCardProps): React.ReactElement {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg border border-surface-border bg-surface-card">
|
|
||||||
{/* Header row */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<ProviderAvatar id={provider.id} />
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium text-text-primary">{provider.name}</span>
|
|
||||||
<ProviderStatusBadge available={provider.available} />
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-text-muted">
|
|
||||||
{provider.models.length} model{provider.models.length !== 1 ? 's' : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<TestConnectionButton status={testStatus} onTest={onTest} />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setExpanded((v) => !v)}
|
|
||||||
className="rounded px-2 py-1 text-xs text-text-muted transition-colors hover:bg-surface-elevated hover:text-text-primary"
|
|
||||||
aria-expanded={expanded}
|
|
||||||
aria-label={expanded ? 'Collapse models' : 'Expand models'}
|
|
||||||
>
|
|
||||||
{expanded ? '▲ Hide' : '▼ Models'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Test result banner */}
|
|
||||||
{testStatus.state !== 'idle' && testStatus.state !== 'testing' && testStatus.result && (
|
|
||||||
<TestResultBanner result={testStatus.result} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Model list */}
|
|
||||||
{expanded && (
|
|
||||||
<div className="border-t border-surface-border">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-surface-elevated text-left text-xs text-text-muted">
|
|
||||||
<th className="px-4 py-2 font-medium">Model</th>
|
|
||||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Capabilities</th>
|
|
||||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Context</th>
|
|
||||||
<th className="hidden px-4 py-2 font-medium md:table-cell">Cost (in/out)</th>
|
|
||||||
<th className="px-4 py-2 font-medium">Default</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{provider.models.map((model) => (
|
|
||||||
<ModelRow
|
|
||||||
key={model.id}
|
|
||||||
model={model}
|
|
||||||
isDefault={
|
|
||||||
defaultModel?.id === model.id && defaultModel?.provider === model.provider
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ModelRowProps {
|
|
||||||
model: ModelInfo;
|
|
||||||
isDefault: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModelRow({ model, isDefault }: ModelRowProps): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<tr className="border-t border-surface-border">
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
<span className="text-sm text-text-primary">{model.name}</span>
|
|
||||||
</td>
|
|
||||||
<td className="hidden px-4 py-2 md:table-cell">
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
<CapabilityBadge label="chat" />
|
|
||||||
{model.reasoning && <CapabilityBadge label="reasoning" color="purple" />}
|
|
||||||
{model.inputTypes.includes('image') && <CapabilityBadge label="vision" color="blue" />}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
|
|
||||||
{formatContext(model.contextWindow)}
|
|
||||||
</td>
|
|
||||||
<td className="hidden px-4 py-2 text-xs text-text-muted md:table-cell">
|
|
||||||
{model.cost.input === 0 && model.cost.output === 0
|
|
||||||
? 'free'
|
|
||||||
: `$${model.cost.input} / $${model.cost.output}`}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2 text-center">
|
|
||||||
{isDefault && (
|
|
||||||
<span
|
|
||||||
className="inline-block rounded-full bg-accent/20 px-2 py-0.5 text-xs font-medium text-accent"
|
|
||||||
title="Default model used for new sessions"
|
|
||||||
>
|
|
||||||
default
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProviderAvatar({ id }: { id: string }): React.ReactElement {
|
|
||||||
const letter = id.charAt(0).toUpperCase();
|
|
||||||
return (
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-surface-elevated text-sm font-semibold text-text-secondary">
|
|
||||||
{letter}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProviderStatusBadge({ available }: { available: boolean }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
|
||||||
available ? 'bg-success/20 text-success' : 'bg-surface-elevated text-text-muted'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{available ? 'Active' : 'Inactive'}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TestConnectionButtonProps {
|
|
||||||
status: ProviderTestStatus;
|
|
||||||
onTest: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TestConnectionButton({ status, onTest }: TestConnectionButtonProps): React.ReactElement {
|
|
||||||
const isTesting = status.state === 'testing';
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onTest}
|
|
||||||
disabled={isTesting}
|
|
||||||
className="rounded px-2 py-1 text-xs transition-colors hover:bg-surface-elevated disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
title="Test connection"
|
|
||||||
>
|
|
||||||
{isTesting ? (
|
|
||||||
<span className="text-text-muted">Testing…</span>
|
|
||||||
) : status.state === 'success' ? (
|
|
||||||
<span className="text-success">✓ Reachable</span>
|
|
||||||
) : status.state === 'error' ? (
|
|
||||||
<span className="text-error">✗ Unreachable</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-text-muted">Test</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TestResultBanner({ result }: { result: TestConnectionResult }): React.ReactElement {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`px-4 py-2 text-xs ${
|
|
||||||
result.reachable ? 'bg-success/10 text-success' : 'bg-error/10 text-error'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{result.reachable ? (
|
|
||||||
<>
|
|
||||||
Connected
|
|
||||||
{result.latencyMs !== undefined && (
|
|
||||||
<span className="ml-1 opacity-70">({result.latencyMs}ms)</span>
|
|
||||||
)}
|
|
||||||
{result.discoveredModels && result.discoveredModels.length > 0 && (
|
|
||||||
<span className="ml-2 opacity-70">
|
|
||||||
— {result.discoveredModels.length} model
|
|
||||||
{result.discoveredModels.length !== 1 ? 's' : ''} discovered
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>Connection failed{result.error ? `: ${result.error}` : ''}</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CapabilityBadge({
|
|
||||||
label,
|
|
||||||
color = 'default',
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
color?: 'default' | 'purple' | 'blue';
|
|
||||||
}): React.ReactElement {
|
|
||||||
const colorClass =
|
|
||||||
color === 'purple'
|
|
||||||
? 'bg-purple-500/20 text-purple-400'
|
|
||||||
: color === 'blue'
|
|
||||||
? 'bg-blue-500/20 text-blue-400'
|
|
||||||
: 'bg-surface-elevated text-text-muted';
|
|
||||||
return <span className={`rounded px-1.5 py-0.5 text-xs ${colorClass}`}>{label}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatContext(tokens: number): string {
|
|
||||||
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
|
|
||||||
if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`;
|
|
||||||
return String(tokens);
|
|
||||||
}
|
|
||||||
@@ -3,9 +3,6 @@ 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(),
|
||||||
@@ -51,7 +48,6 @@ 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> {
|
||||||
@@ -76,13 +72,6 @@ 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>();
|
||||||
@@ -143,7 +132,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 failed fetch as an explicit unavailable state, never an empty healthy board', async () => {
|
it('renders a visible alert when the tasks request fails', async () => {
|
||||||
apiMock.mockRejectedValueOnce(new Error('Tasks request failed'));
|
apiMock.mockRejectedValueOnce(new Error('Tasks request failed'));
|
||||||
|
|
||||||
await renderTasksPage();
|
await renderTasksPage();
|
||||||
@@ -151,80 +140,5 @@ 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,32 +1,45 @@
|
|||||||
import { useState, type ReactElement } from 'react';
|
import { useEffect, 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 { useFreshCollection, describeFailure } from '@/lib/freshness/use-fresh-collection';
|
import { getErrorMessage } from './page-errors';
|
||||||
import { validateTaskCollection } from '@/lib/freshness/validators';
|
|
||||||
|
|
||||||
type ViewMode = 'list' | 'kanban';
|
type ViewMode = 'list' | 'kanban';
|
||||||
|
|
||||||
export function TasksPage(): ReactElement {
|
export function TasksPage(): ReactElement {
|
||||||
const tasks = useFreshCollection<Task[]>({
|
const [tasks, setTasks] = useState<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);
|
||||||
|
|
||||||
const retry = (): void => {
|
useEffect(() => {
|
||||||
void tasks.revalidate();
|
let cancelled = false;
|
||||||
};
|
|
||||||
|
void api<Task[]>('/api/tasks')
|
||||||
|
.then((response) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setTasks(response);
|
||||||
|
})
|
||||||
|
.catch((caught: unknown) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setError(getErrorMessage(caught, 'Failed to load tasks.'));
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-freshness={tasks.freshness} 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 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">
|
||||||
@@ -57,24 +70,18 @@ export function TasksPage(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{tasks.freshness === 'stale' && tasks.snapshot ? (
|
{error ? (
|
||||||
<div className="mb-6">
|
<div role="alert" className="mb-6 rounded-lg border border-error/40 px-4 py-3 text-sm">
|
||||||
<StaleDataNotice label={tasks.snapshot} onRetry={retry} />
|
{error}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{tasks.freshness === 'unknown' ? (
|
{loading ? (
|
||||||
<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.data ?? []} onTaskClick={setSelectedTask} />
|
<KanbanBoard tasks={tasks} onTaskClick={setSelectedTask} />
|
||||||
) : (
|
) : (
|
||||||
<TaskListView tasks={tasks.data ?? []} onTaskClick={setSelectedTask} />
|
<TaskListView tasks={tasks} onTaskClick={setSelectedTask} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedTask ? (
|
{selectedTask ? (
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,8 +1,3 @@
|
|||||||
---
|
|
||||||
kind: guide
|
|
||||||
status: active
|
|
||||||
---
|
|
||||||
|
|
||||||
# Mos Connector Lease Operations — M1
|
# Mos Connector Lease Operations — M1
|
||||||
|
|
||||||
> **Status:** Held / non-operative.
|
> **Status:** Held / non-operative.
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,8 +1,3 @@
|
|||||||
---
|
|
||||||
kind: guide
|
|
||||||
status: active
|
|
||||||
---
|
|
||||||
|
|
||||||
# Authenticated external lease broker protocol
|
# Authenticated external lease broker protocol
|
||||||
|
|
||||||
> **Status:** Current contract reference.
|
> **Status:** Current contract reference.
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,8 +1,3 @@
|
|||||||
---
|
|
||||||
kind: guide
|
|
||||||
status: active
|
|
||||||
---
|
|
||||||
|
|
||||||
# Whole mutator-class lease gate
|
# Whole mutator-class lease gate
|
||||||
|
|
||||||
> **Status:** Current contract reference.
|
> **Status:** Current contract reference.
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,8 +1,3 @@
|
|||||||
---
|
|
||||||
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.
|
||||||
|
|||||||
+972
-486
File diff suppressed because it is too large
Load Diff
+8
-44
@@ -1,9 +1,3 @@
|
|||||||
---
|
|
||||||
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.
|
||||||
@@ -152,51 +146,21 @@ 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.
|
||||||
|
|
||||||
Required front matter for every canonical page:
|
Recommended front matter for canonical pages:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
kind: tracking | projection | spec | guide | record | superseded
|
title: Human-readable page title
|
||||||
status: active # or: completed | superseded-by: <path>
|
type: guide
|
||||||
source_of_truth: false # optional, defaults false
|
audience: developer
|
||||||
audience: developer # optional: user | admin | developer | all
|
status: current
|
||||||
title: Human-readable page title # optional
|
source_of_truth: false
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
`kind` says what the document **is**. One value, required, and it follows the document's content,
|
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`.
|
||||||
never its filename: a file named `TASKS.md` whose body says "this is a build plan, not a task
|
|
||||||
tracker" is a `spec`.
|
|
||||||
|
|
||||||
| kind | rule |
|
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.
|
||||||
| ---------- | ---------------------------------------------------------------- |
|
|
||||||
| 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
|
||||||
|
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
---
|
|
||||||
kind: spec
|
|
||||||
status: active
|
|
||||||
---
|
|
||||||
|
|
||||||
# Mosaic Stack Roadmap
|
|
||||||
|
|
||||||
Companion to [docs/PRD.md](./PRD.md). Governed by the D11 rule: **every planned
|
|
||||||
phase appears here from day one, even as a placeholder** — nothing exists only
|
|
||||||
in heads. A phase marked _placeholder_ is a commitment to design it, not a
|
|
||||||
design; scoping one requires its own PRD section or requirements doc plus
|
|
||||||
review.
|
|
||||||
|
|
||||||
Phases are product phases. The in-flight platform workstreams (KBN-100/101
|
|
||||||
kanban SOT implementation, FCM #758, FCOM #766, TESS, RI #1275, and the other
|
|
||||||
Part II contracts in the PRD) run as parallel tracks under their own issues
|
|
||||||
and are prerequisites where noted.
|
|
||||||
|
|
||||||
| Phase | Scope | Status |
|
|
||||||
| ----- | ------------------------------------------------------------------------------ | ----------------------------------------- |
|
|
||||||
| P0 | Current state on `next`: read-only dashboard, chat, auth/SSO login, admin tabs | shipped, evolving |
|
|
||||||
| P1 | **v1 slice** (PRD Part I §9) | next up |
|
|
||||||
| P2 | Connectors + comms + wizard expansion | placeholder |
|
|
||||||
| P3 | Full onboarding profile + M365 | placeholder |
|
|
||||||
| P4 | Enterprise mode + one-way conversion | placeholder |
|
|
||||||
| P5 | Federation | placeholder (deliberately undesigned, D3) |
|
|
||||||
|
|
||||||
## P0 — current state
|
|
||||||
|
|
||||||
What exists on `next` today: web dashboard (login/register/SSO, chat,
|
|
||||||
read-only projects/tasks, settings, admin user/system-health tabs), the
|
|
||||||
Gateway, the CLI-first framework tooling, and the fleet control plane. The
|
|
||||||
webUI audit (USC estate, webui-audit lane) measures the gap between this and
|
|
||||||
P1.
|
|
||||||
|
|
||||||
## P1 — v1 slice (D11)
|
|
||||||
|
|
||||||
1. Standalone onboarding wizard: system/company name, component choices,
|
|
||||||
initial user, initial estate + project, seeded examples, re-runnable.
|
|
||||||
2. Hierarchy core: company → estate → project → workspace → kanban, read-only
|
|
||||||
task bubble-up (kanban SOT Amendment A1 is the schema contract).
|
|
||||||
3. Basic RBAC on the hierarchy.
|
|
||||||
4. Minimal agent enrollment: one harness, API key, name/persona.
|
|
||||||
|
|
||||||
Prerequisites: KBN-100/101 schema foundation; the D8 tool inventory and
|
|
||||||
webUI→tool mapping (any missing tool is built first, D12).
|
|
||||||
|
|
||||||
## P2 — connectors + comms + wizard expansion (placeholder)
|
|
||||||
|
|
||||||
Email and drive connectors (Gmail/IMAP, Google Drive/OneDrive/Dropbox) with
|
|
||||||
granular agentic-access consent; comms integrations (Matrix/Discord/Slack)
|
|
||||||
including agent auto-enroll. Wizard gains the corresponding tabs (D4), plus
|
|
||||||
the D4 capabilities deferred out of P1's minimal slice: expanded agent
|
|
||||||
enrollment (OAuth login, multi-account, model choice with recommendation,
|
|
||||||
account assignment, comms auto-enroll) and the Standalone SSO/OIDC
|
|
||||||
configuration tab.
|
|
||||||
|
|
||||||
## P3 — full onboarding profile + M365 (placeholder)
|
|
||||||
|
|
||||||
Complete user onboarding profile (communication-style capture, optional
|
|
||||||
voice-matching interview) under the D14 custody rule; M365 connectors,
|
|
||||||
available to both deployment modes as ordinary connectors (same consent model
|
|
||||||
as the P2 connector class). The Enterprise install flow's M365 prominence
|
|
||||||
(D4) arrives with the Enterprise phase, P4.
|
|
||||||
|
|
||||||
## P4 — Enterprise mode + conversion (placeholder)
|
|
||||||
|
|
||||||
Enterprise install flow (org chart, RBAC focus, immediate OIDC, SSO
|
|
||||||
prominent); per-user brains with architectural isolation (D14); Vault
|
|
||||||
required; the one-way Standalone → Enterprise conversion (D3).
|
|
||||||
|
|
||||||
## P5 — federation (placeholder)
|
|
||||||
|
|
||||||
Connecting deployments: system-level config, assigned users, rights and
|
|
||||||
data-access control, trusts with boundaries, strict data access, exfiltration
|
|
||||||
monitoring. Explicitly not designed yet (D3); nothing in earlier phases may
|
|
||||||
foreclose it. Requires its own PRD + threat model before any scoping.
|
|
||||||
+1
-15
@@ -1,19 +1,5 @@
|
|||||||
# 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
|
||||||
@@ -122,7 +108,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/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.
|
- 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.
|
||||||
|
|
||||||
## #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,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,8 +7,7 @@
|
|||||||
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. [Pi Goal Loop Operations](#pi-goal-loop-operations)
|
6. [Local Fleet Canary](./fleet-local-canary.md)
|
||||||
7. [Local Fleet Canary](./fleet-local-canary.md)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -265,16 +264,6 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
|
|||||||
| `AGENT_SYSTEM_PROMPT` | — | Platform-level system prompt injected into all sessions |
|
| `AGENT_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 |
|
||||||
@@ -385,29 +374,3 @@ 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,10 +8,9 @@
|
|||||||
4. [Tasks](#tasks)
|
4. [Tasks](#tasks)
|
||||||
5. [Settings](#settings)
|
5. [Settings](#settings)
|
||||||
6. [CLI Usage](#cli-usage)
|
6. [CLI Usage](#cli-usage)
|
||||||
7. [Pi Persistent Goals](#pi-persistent-goals)
|
7. [Sub-package Commands](#sub-package-commands)
|
||||||
8. [Sub-package Commands](#sub-package-commands)
|
8. [Telemetry](#telemetry)
|
||||||
9. [Telemetry](#telemetry)
|
9. [Local Fleet Canary](./fleet-local-canary.md)
|
||||||
10. [Local Fleet Canary](./fleet-local-canary.md)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -318,57 +317,6 @@ 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
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,21 +1,5 @@
|
|||||||
# 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
|
||||||
|
|||||||
+10
-13
@@ -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; Git identity is derived from the exact agent name. |
|
| Roster | `fleet/roster.yaml` supplies the agent name, class, supported runtime, model, reasoning, tool policy, workdir, and tmux socket. |
|
||||||
| Projection writer | Renders deterministic fleet/agents/<name>.env.generated from the roster. |
|
| 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,7 +24,6 @@ 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>
|
||||||
@@ -34,10 +33,8 @@ MOSAIC_AGENT_WORKDIR=<absolute roster work directory>
|
|||||||
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
MOSAIC_TMUX_SOCKET=<roster socket or empty>
|
||||||
```
|
```
|
||||||
|
|
||||||
`MOSAIC_GIT_IDENTITY` is not independently configurable: it must equal `MOSAIC_AGENT_NAME`, preventing
|
The generated launch contract supports `claude`, `codex`, `opencode`, and `pi`. mosaic fleet add
|
||||||
split runtime and repository identity authority. The generated launch contract supports `claude`,
|
rejects another runtime before it writes the roster or modifies generated, local, or quarantine state.
|
||||||
`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.
|
||||||
|
|
||||||
|
|||||||
+25
-45
@@ -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.
|
> Edit the YAML, then regenerate. Self-contained Mosaic — no Hermes dependency.
|
||||||
|
|
||||||
## 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).
|
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.
|
||||||
|
|
||||||
## Standing objectives
|
## Standing objectives
|
||||||
|
|
||||||
@@ -24,18 +24,16 @@ 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-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-1** — The supervisor keeps a two-agent floor (1 orchestrator + >=1 enhancer) healthy across reboot.
|
||||||
- **AC-NS-1** (tier 1) — The supervisor keeps a two-agent floor (1 orchestrator + >=1 enhancer) healthy across reboot.
|
- **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-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-3** — No PR merges with failure/error/no-status/timeout CI, and none bypass pr-merge.sh.
|
||||||
- **AC-NS-3** (tier 1) — No PR merges with failure/error/no-status/timeout CI, and none bypass pr-merge.sh.
|
- **AC-NS-4** — TTL is enforced on claims; token caps remain advisory until a real meter exists.
|
||||||
- **AC-NS-4** (tier 1) — TTL is enforced on claims; token caps remain advisory until a real meter exists.
|
- **AC-NS-5** — Flipping fleet/run/PAUSED halts dispatch and merges within one tick.
|
||||||
- **AC-NS-5** (tier 1) — Flipping fleet/run/PAUSED halts dispatch and merges within one tick.
|
- **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-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** — A user-customized persona (edited or added via the orchestrator) survives mosaic update: baseline reseed never clobbers user overrides.
|
||||||
- **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
|
||||||
|
|
||||||
@@ -47,44 +45,26 @@ 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 | tier | phase | priority | depends_on |
|
| id | title | phase | priority | depends_on |
|
||||||
| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---- | ----- | ----------- | -------------- |
|
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ----------- | ---------- |
|
||||||
| A1 | Machine-readable NORTH_STAR.yaml + Markdown projection | 1 | 1 | must-have | — |
|
| A1 | Machine-readable NORTH_STAR.yaml + Markdown projection | 1 | must-have | — |
|
||||||
| A2 | Mosaic Backlog schema + storage-service card store (drizzle/PGlite) | 1 | 1 | must-have | A1 |
|
| A2 | Mosaic Backlog schema + storage-service card store (drizzle/PGlite) | 1 | must-have | A1 |
|
||||||
| A3a | Card lifecycle — create/claim/release with stable ids + depends_on DAG | 1 | 1 | must-have | A2 |
|
| A3a | Card lifecycle — create/claim/release with stable ids + depends_on DAG | 1 | must-have | A2 |
|
||||||
| A3b | TTL-bounded claim enforcement (wall-clock) on cards | 1 | 1 | must-have | A3a |
|
| A3b | TTL-bounded claim enforcement (wall-clock) on cards | 1 | must-have | A3a |
|
||||||
| A4 | Advisory spend projection per card (degrades to TTL, no real meter) | 1 | 1 | should-have | A3a |
|
| A4 | Advisory spend projection per card (degrades to TTL, no real meter) | 1 | should-have | A3a |
|
||||||
| B1 | Supervisor tick — readiness scan, two-agent-floor health check | 1 | 2 | must-have | A3a |
|
| B1 | Supervisor tick — readiness scan, two-agent-floor health check | 2 | must-have | A3a |
|
||||||
| B2 | Native dispatch/claim — assign ready dependency-satisfied work | 1 | 2 | must-have | A3b, B1 |
|
| B2 | Native dispatch/claim — assign ready dependency-satisfied work | 2 | must-have | A3b, B1 |
|
||||||
| B3a | Planner decompose — goal added to YAML → cards | 1 | 2 | must-have | A2, B1 |
|
| B3a | Planner decompose — goal added to YAML → cards | 2 | must-have | A2, B1 |
|
||||||
| B3b | Replan request on empty backlog; escalate on no-decompose | 1 | 2 | should-have | B3a |
|
| B3b | Replan request on empty backlog; escalate on no-decompose | 2 | should-have | B3a |
|
||||||
| G1 | PAUSE kill-switch + merge-gate honored before dispatch and merge | 1 | 2 | must-have | B2 |
|
| G1 | PAUSE kill-switch + merge-gate honored before dispatch and merge | 2 | must-have | B2 |
|
||||||
| H1 | Cross-domain baseline persona library (exec, marketing, ops, research, assistant + engineering roles) | 2 | 1 | must-have | A1 |
|
| H1 | Cross-domain baseline persona library (exec, marketing, ops, research, assistant + engineering roles) | 1 | must-have | A1 |
|
||||||
| H2 | System-type profiles — declarative mapping of system type to persona roster + topology | 2 | 2 | must-have | H1 |
|
| H2 | System-type profiles — declarative mapping of system type to persona roster + topology | 2 | must-have | H1 |
|
||||||
| H3 | System-type provisioning — user declares type; orchestrator instantiates the matching roster + structure | 2 | 2 | must-have | H2 |
|
| H3 | System-type provisioning — user declares type; orchestrator instantiates the matching roster + structure | 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 | 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 | 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)
|
||||||
|
|
||||||
|
|||||||
+2
-129
@@ -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. The backlog of record is
|
# Self-contained Mosaic. NO Hermes runtime dependency. 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).
|
PGlite-embedded by default, full Postgres by config). NOT Hermes.
|
||||||
|
|
||||||
standing_objectives:
|
standing_objectives:
|
||||||
- id: NS-1
|
- id: NS-1
|
||||||
@@ -69,53 +69,32 @@ 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.
|
||||||
@@ -133,186 +112,80 @@ 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,12 +1,7 @@
|
|||||||
---
|
|
||||||
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/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
|
> **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
|
||||||
> **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.
|
||||||
|
|
||||||
|
|||||||
+1
-6
@@ -1,12 +1,7 @@
|
|||||||
---
|
|
||||||
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/FLEET-DOCTRINE.md](./FLEET-DOCTRINE.md)
|
> **North star:** [docs/fleet/north-star.md](./north-star.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.
|
||||||
|
|
||||||
|
|||||||
+1
-16
@@ -1,25 +1,10 @@
|
|||||||
# 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/FLEET-DOCTRINE.md](./FLEET-DOCTRINE.md)
|
> Mission: `mvp-20260312` · PRD: [docs/fleet/PRD.md](./PRD.md) · North star: [docs/fleet/north-star.md](./north-star.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,8 +1,3 @@
|
|||||||
---
|
|
||||||
kind: guide
|
|
||||||
status: active
|
|
||||||
---
|
|
||||||
|
|
||||||
# Desired, Derived, and Observed Fleet State
|
# Desired, Derived, and Observed Fleet State
|
||||||
|
|
||||||
## One writable authority
|
## One writable authority
|
||||||
|
|||||||
@@ -1,19 +1,13 @@
|
|||||||
---
|
|
||||||
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, including `MOSAIC_GIT_IDENTITY` derived exactly from the roster agent name.
|
2. Render deterministic <name>.env.generated data from that roster.
|
||||||
3. Parse optional <name>.env.local through a strict allowlist.
|
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. Reject a Git identity that is unsafe or differs from the generated agent name.
|
5. Derive the runtime command from validated runtime/model/reasoning data.
|
||||||
6. Derive the runtime command from validated runtime/model/reasoning data and pass every generated projection entry through the clean process environment boundary.
|
6. Target only the exact configured tmux socket and roster session after ownership checks.
|
||||||
7. Target only the exact configured tmux socket and roster session after ownership checks.
|
|
||||||
|
|
||||||
## File precedence and ownership
|
## File precedence and ownership
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,8 +1,3 @@
|
|||||||
---
|
|
||||||
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/FLEET-DOCTRINE.md` (#613) — orchestrator-chat-connector decision.
|
> **Issue:** #616 · **Doctrine:** `docs/fleet/north-star.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,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,26 +1,9 @@
|
|||||||
---
|
# 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)
|
> **Umbrella:** [docs/MISSION-MANIFEST.md](../MISSION-MANIFEST.md) · [docs/PRD.md](../PRD.md) (Mosaic Stack v0.1.0)
|
||||||
> **Authored:** 2026-06-20. Owner: Fleet workstream lead.
|
> **Status:** doctrine — authored 2026-06-20. Owner of this file: Fleet workstream lead.
|
||||||
> This document does **not** modify the MVP rollup.
|
> This document does **not** modify the MVP rollup; a rollup row is proposed, not written here.
|
||||||
|
|
||||||
## Vision
|
## Vision
|
||||||
|
|
||||||
@@ -281,17 +264,15 @@ 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 — SUPERSEDED
|
## Phased roadmap
|
||||||
|
|
||||||
Superseded 2026-08-20 by [`NORTH_STAR.yaml`](./NORTH_STAR.yaml), whose `goals` carry both
|
| Phase | Outcome | Status |
|
||||||
a `phase` (build order) and a `tier` (which promise the goal delivers). The five-phase
|
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
|
||||||
table that stood here could not express those as separate axes, and its "Phase 2 —
|
| 0–1 | tmux PoC, hardening, published CLI v0.0.34 (#565–#568) | ✅ done |
|
||||||
Observability ▶ now" row stayed unfalsified for two months because a phase has no exit
|
| **2 — Observability** | fleet ps (host+tenant aware join), heartbeat protocol + dogfood stub answers it, agent watch (read-only), agent send --verify receipts | ▶ now |
|
||||||
test. Tiers do: see `AC-NS-0` through `AC-NS-7`.
|
| 3 — Real runtimes | claude/codex/pi/opencode answer heartbeat; **hybrid lifecycle** (core always-on: **orchestrator + enhancer**; ephemeral workers per lane) | planned |
|
||||||
|
| 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 |
|
||||||
The phase-2 content itself is not lost — it is specified in
|
| 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 |
|
||||||
[`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)
|
||||||
|
|
||||||
@@ -1,8 +1,3 @@
|
|||||||
---
|
|
||||||
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
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user