Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
513b6b0d01 |
@@ -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."
|
|
||||||
}
|
|
||||||
+4
-49
@@ -38,12 +38,10 @@ when:
|
|||||||
- 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:
|
||||||
@@ -93,15 +91,6 @@ steps:
|
|||||||
# and sandboxes a throwaway git repo, so it resolves no real credentials and
|
# and sandboxes a throwaway git repo, so it resolves no real credentials and
|
||||||
# joins CI directly rather than the exclusions file.
|
# joins CI directly rather than the exclusions file.
|
||||||
- bash packages/mosaic/framework/tools/git/test-issue-close-fail-closed.sh
|
- 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
|
# Hermetic behavioural regression for the PreToolUse wrapper guard: proves
|
||||||
# it still blocks the three mistakes AND still lets reads, unwrapped
|
# it still blocks the three mistakes AND still lets reads, unwrapped
|
||||||
# endpoints and ordinary commands through. Both directions are asserted —
|
# endpoints and ordinary commands through. Both directions are asserted —
|
||||||
@@ -115,40 +104,6 @@ steps:
|
|||||||
# stub supplies the scale instead of the host's own checkout.
|
# stub supplies the scale instead of the host's own checkout.
|
||||||
- bash packages/mosaic/framework/tools/git/test-mosaic-worktree-large-repo.sh
|
- 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`.
|
# 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
|
||||||
|
|||||||
+21
-216
@@ -32,11 +32,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 +44,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]
|
||||||
@@ -68,13 +73,6 @@ steps:
|
|||||||
# being empty) and on any incomplete verification.
|
# being empty) and on any incomplete verification.
|
||||||
verify:
|
verify:
|
||||||
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
|
||||||
# (a) Commit identity: the provider's claimed SHA must equal the actual
|
# (a) Commit identity: the provider's claimed SHA must equal the actual
|
||||||
@@ -110,13 +108,6 @@ steps:
|
|||||||
|
|
||||||
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
|
||||||
@@ -211,20 +202,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 +209,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 +234,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,94 +250,6 @@ 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
|
- verify
|
||||||
@@ -458,18 +305,10 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
- verify
|
- 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: REGISTRY_USERNAME
|
||||||
@@ -483,17 +322,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"
|
||||||
@@ -502,18 +332,10 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
- verify
|
- 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: REGISTRY_USERNAME
|
||||||
@@ -527,17 +349,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"
|
||||||
@@ -546,11 +359,3 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
- verify
|
- 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.
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -451,15 +451,24 @@ describe('AppModule federation gating', (): void => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
it(
|
it(
|
||||||
'attributes an invalid monorepo-root dotenv tier to the default',
|
'rejects an invalid explicit monorepo-root dotenv tier with a typed startup refusal',
|
||||||
async (): Promise<void> => {
|
async (): Promise<void> => {
|
||||||
const graph = await loadModuleGraphFromDotenv({
|
const failure = await loadModuleGraphFromDotenv({
|
||||||
rootEnvContents: 'MOSAIC_STORAGE_TIER=invalid\n',
|
rootEnvContents: 'MOSAIC_STORAGE_TIER=invalid\n',
|
||||||
expectedProcessTier: 'invalid',
|
expectedProcessTier: 'invalid',
|
||||||
});
|
}).then(
|
||||||
|
(): undefined => undefined,
|
||||||
|
(error: unknown): unknown => error,
|
||||||
|
);
|
||||||
|
|
||||||
expect(graph.imports).not.toContain(graph.federationModule);
|
expect(failure).toBeInstanceOf(Error);
|
||||||
expectBootLogLine(graph.bootLogLines, 'local', 'default');
|
expect(failure).toMatchObject({
|
||||||
|
name: 'MosaicConfigEnvironmentError',
|
||||||
|
code: 'invalid_storage_tier',
|
||||||
|
});
|
||||||
|
expect((failure as Error).message).toBe(
|
||||||
|
'Invalid MOSAIC_STORAGE_TIER; expected "local", "standalone", or "federated".',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
MODULE_IMPORT_TIMEOUT_MS,
|
MODULE_IMPORT_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { resolveChatRuntimeMode } from './chat-runtime.js';
|
||||||
|
|
||||||
|
interface TypedSelectionFailure {
|
||||||
|
readonly name: string;
|
||||||
|
readonly code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('#1182 fail closed — a wrong answer must not be read as no answer', () => {
|
||||||
|
it('FL-07 rejects an invalid explicit CHAT_HARNESS_RUNTIME before embedded construction', () => {
|
||||||
|
let embeddedConstructionCount = 0;
|
||||||
|
let failure: unknown;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mode = resolveChatRuntimeMode({ CHAT_HARNESS_RUNTIME: 'pi-rpc-typo' });
|
||||||
|
if (mode === 'legacy') {
|
||||||
|
embeddedConstructionCount += 1;
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
failure = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect
|
||||||
|
.soft(failure, 'invalid explicit runtime must produce a typed selection failure')
|
||||||
|
.toMatchObject({
|
||||||
|
name: 'ChatRuntimeConfigurationError',
|
||||||
|
code: 'invalid_chat_harness_runtime',
|
||||||
|
} satisfies TypedSelectionFailure);
|
||||||
|
expect(
|
||||||
|
embeddedConstructionCount,
|
||||||
|
'invalid explicit runtime must fail before the embedded runtime is constructed',
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([{}, { CHAT_HARNESS_RUNTIME: '' }])(
|
||||||
|
'preserves the documented transitional legacy default for true absence: %j',
|
||||||
|
(env) => {
|
||||||
|
expect(resolveChatRuntimeMode(env)).toBe('legacy');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('anti-drift: runtime selection has no invalid-enum-to-legacy catch-all', () => {
|
||||||
|
const source = readFileSync(new URL('./chat-runtime.ts', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
source.includes("env['CHAT_HARNESS_RUNTIME'] === 'pi-rpc' ? 'pi-rpc' : 'legacy'"),
|
||||||
|
'closed runtime enums must distinguish invalid explicit input from absence',
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,14 +41,28 @@ export class ChatRuntimeUnavailableError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Typed startup refusal for an invalid explicit chat-runtime selection. */
|
||||||
|
export class ChatRuntimeConfigurationError extends Error {
|
||||||
|
readonly code = 'invalid_chat_harness_runtime' as const;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('Invalid CHAT_HARNESS_RUNTIME; expected "legacy" or "pi-rpc".');
|
||||||
|
this.name = 'ChatRuntimeConfigurationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the process-wide chat runtime mode from the environment. Anything other
|
* Resolves the process-wide chat runtime mode from the environment. Only true
|
||||||
* than the exact opt-in token `pi-rpc` keeps the legacy embedded runtime.
|
* absence (unset or empty) retains the documented transitional legacy default;
|
||||||
|
* any other explicit value must be a member of the closed runtime enum.
|
||||||
*/
|
*/
|
||||||
export function resolveChatRuntimeMode(
|
export function resolveChatRuntimeMode(
|
||||||
env: Record<string, string | undefined> = process.env,
|
env: Record<string, string | undefined> = process.env,
|
||||||
): ChatRuntimeMode {
|
): ChatRuntimeMode {
|
||||||
return env['CHAT_HARNESS_RUNTIME'] === 'pi-rpc' ? 'pi-rpc' : 'legacy';
|
const runtime = env['CHAT_HARNESS_RUNTIME'];
|
||||||
|
if (runtime === undefined || runtime === '') return 'legacy';
|
||||||
|
if (runtime === 'legacy' || runtime === 'pi-rpc') return runtime;
|
||||||
|
throw new ChatRuntimeConfigurationError();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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.
|
||||||
|
|||||||
+1014
-247
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.
|
||||||
|
|||||||
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
|
||||||
|
|||||||
+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,8 +1,3 @@
|
|||||||
---
|
|
||||||
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,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
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
---
|
|
||||||
kind: guide
|
|
||||||
status: active
|
|
||||||
---
|
|
||||||
|
|
||||||
# Environment Quarantine Operations
|
# Environment Quarantine Operations
|
||||||
|
|
||||||
Legacy <name>.env is input evidence, never current launch authority. Projection preparation classifies it deterministically:
|
Legacy <name>.env is input evidence, never current launch authority. Projection preparation classifies it deterministically:
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
---
|
|
||||||
kind: guide
|
|
||||||
status: active
|
|
||||||
---
|
|
||||||
|
|
||||||
# Reconcile and Recover a Local Fleet
|
# Reconcile and Recover a Local Fleet
|
||||||
|
|
||||||
## Safe sequence
|
## Safe sequence
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
---
|
|
||||||
kind: guide
|
|
||||||
status: active
|
|
||||||
---
|
|
||||||
|
|
||||||
# Systemd and tmux Troubleshooting
|
# Systemd and tmux Troubleshooting
|
||||||
|
|
||||||
Start with read-only mosaic fleet status, `doctor`, and `verify`. Do not manually adopt, rename, terminate, or recreate sessions while ownership is ambiguous.
|
Start with read-only mosaic fleet status, `doctor`, and `verify`. Do not manually adopt, rename, terminate, or recreate sessions while ownership is ambiguous.
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
---
|
|
||||||
kind: guide
|
|
||||||
status: active
|
|
||||||
---
|
|
||||||
|
|
||||||
# Upgrade and Installed-Asset Drift
|
# Upgrade and Installed-Asset Drift
|
||||||
|
|
||||||
Fleet source assets and installed assets can differ after an update, but FCM-M5-001 does not add a trustworthy source-versus-installed revision detector or refresh command. Do not infer freshness from checkout presence, timestamps, generated environment files, running sessions, or a ready migration preview.
|
Fleet source assets and installed assets can differ after an update, but FCM-M5-001 does not add a trustworthy source-versus-installed revision detector or refresh command. Do not infer freshness from checkout presence, timestamps, generated environment files, running sessions, or a ready migration preview.
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
---
|
|
||||||
kind: guide
|
|
||||||
status: active
|
|
||||||
---
|
|
||||||
|
|
||||||
# Local Fleet Agent Mutations
|
# Local Fleet Agent Mutations
|
||||||
|
|
||||||
FCM-M2-002 provides local roster-v2 create, get, update, delete, and plan operations. They only change desired state and derived environment projections. They never start, stop, inspect, reconcile, or otherwise act on runtimes, systemd units, tmux sessions, or heartbeats.
|
FCM-M2-002 provides local roster-v2 create, get, update, delete, and plan operations. They only change desired state and derived environment projections. They never start, stop, inspect, reconcile, or otherwise act on runtimes, systemd units, tmux sessions, or heartbeats.
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
---
|
|
||||||
kind: guide
|
|
||||||
status: active
|
|
||||||
---
|
|
||||||
|
|
||||||
# Fleet Control-Plane CLI
|
# Fleet Control-Plane CLI
|
||||||
|
|
||||||
The local desired-state surface is mosaic fleet. It is distinct from the gateway-backed mosaic agent catalog and from legacy compatibility commands that act on roster v1.
|
The local desired-state surface is mosaic fleet. It is distinct from the gateway-backed mosaic agent catalog and from legacy compatibility commands that act on roster v1.
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
---
|
|
||||||
kind: guide
|
|
||||||
status: active
|
|
||||||
---
|
|
||||||
|
|
||||||
# Fleet Generated Environment Boundary
|
# Fleet Generated Environment Boundary
|
||||||
|
|
||||||
**Card:** FCM-M2-001 · **Issue:** #758 · **Status:** merged contract
|
**Card:** FCM-M2-001 · **Issue:** #758 · **Status:** merged contract
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
---
|
|
||||||
kind: guide
|
|
||||||
status: active
|
|
||||||
---
|
|
||||||
|
|
||||||
# Local Fleet Lifecycle Transitions
|
# Local Fleet Lifecycle Transitions
|
||||||
|
|
||||||
Roster-v2 `lifecycle.enabled` and `lifecycle.desired_state` are the only persisted lifecycle authority. Systemd, tmux, generated environment, and heartbeat state are derived or observed.
|
Roster-v2 `lifecycle.enabled` and `lifecycle.desired_state` are the only persisted lifecycle authority. Systemd, tmux, generated environment, and heartbeat state are derived or observed.
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
---
|
|
||||||
kind: guide
|
|
||||||
status: active
|
|
||||||
---
|
|
||||||
|
|
||||||
# Fleet Role Classes and Authority
|
# Fleet Role Classes and Authority
|
||||||
|
|
||||||
A fleet role class is a machine identity resolved from the persona library. Resolution uses the
|
A fleet role class is a machine identity resolved from the persona library. Resolution uses the
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
---
|
|
||||||
kind: guide
|
|
||||||
status: active
|
|
||||||
---
|
|
||||||
|
|
||||||
# Fleet Roster v2 Structural Contract
|
# Fleet Roster v2 Structural Contract
|
||||||
|
|
||||||
**Status:** FCM-M1-001 local-tmux structural compiler contract. This document describes parsing,
|
**Status:** FCM-M1-001 local-tmux structural compiler contract. This document describes parsing,
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
---
|
|
||||||
kind: guide
|
|
||||||
status: active
|
|
||||||
---
|
|
||||||
|
|
||||||
# Local Fleet Status and Drift
|
# Local Fleet Status and Drift
|
||||||
|
|
||||||
mosaic fleet status [<name>], `verify`, and `doctor` are observational roster-v2 commands. They emit one JSON result and do not write projections, mutate desired state, operate lifecycle, or change tmux.
|
mosaic fleet status [<name>], `verify`, and `doctor` are observational roster-v2 commands. They emit one JSON result and do not write projections, mutate desired state, operate lifecycle, or change tmux.
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
---
|
|
||||||
kind: guide
|
|
||||||
status: active
|
|
||||||
---
|
|
||||||
|
|
||||||
# Deployment Guide
|
# Deployment Guide
|
||||||
|
|
||||||
> **Status: non-operative for PostgreSQL, federated, and bare-metal production.** The checked-in
|
> **Status: non-operative for PostgreSQL, federated, and bare-metal production.** The checked-in
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
---
|
|
||||||
kind: guide
|
|
||||||
status: active
|
|
||||||
---
|
|
||||||
|
|
||||||
# Mosaic Stack — Developer Guide
|
# Mosaic Stack — Developer Guide
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
---
|
|
||||||
kind: guide
|
|
||||||
status: active
|
|
||||||
---
|
|
||||||
|
|
||||||
# Local Fleet Canary
|
# Local Fleet Canary
|
||||||
|
|
||||||
The local fleet canary runs a small tmux-backed Mosaic agent fleet on an
|
The local fleet canary runs a small tmux-backed Mosaic agent fleet on an
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
---
|
|
||||||
kind: guide
|
|
||||||
status: active
|
|
||||||
---
|
|
||||||
|
|
||||||
# Migrating to the Federated Tier
|
# Migrating to the Federated Tier
|
||||||
|
|
||||||
> **KBN-101-07 ownership:** This active documentation is a **non-operative KBN-101
|
> **KBN-101-07 ownership:** This active documentation is a **non-operative KBN-101
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
---
|
|
||||||
kind: tracking
|
|
||||||
status: active
|
|
||||||
---
|
|
||||||
|
|
||||||
# Mission Manifest — Mosaic Native Kanban and Canonical Task SOT P0–P3
|
# Mission Manifest — Mosaic Native Kanban and Canonical Task SOT P0–P3
|
||||||
|
|
||||||
**Mission status:** CANON INDEPENDENTLY APPROVED; publication in progress under issue [#751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
|
**Mission status:** CANON INDEPENDENTLY APPROVED; publication in progress under issue [#751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
|
||||||
|
|||||||
@@ -1,24 +1,10 @@
|
|||||||
---
|
|
||||||
kind: spec
|
|
||||||
status: active
|
|
||||||
---
|
|
||||||
|
|
||||||
# Native Kanban/SOT P0–P3 — Dependency-Ordered Build Slices
|
# Native Kanban/SOT P0–P3 — Dependency-Ordered Build Slices
|
||||||
|
|
||||||
**Status:** CANON INDEPENDENTLY APPROVED; PUBLICATION IN PROGRESS
|
**Status:** CANON INDEPENDENTLY APPROVED; PUBLICATION IN PROGRESS
|
||||||
**Tracking:** [Mosaic Stack issue #751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
|
**Tracking:** [Mosaic Stack issue #751](https://git.mosaicstack.dev/mosaicstack/stack/issues/751)
|
||||||
**Execution:** USC web1 only; collision-free GPT coder2/3/4/5 lanes
|
**Execution:** USC web1 only; collision-free GPT coder2/3/4/5 lanes
|
||||||
**Contract:** `SHARED-CONTRACT.md` + four `contracts/*.v1.ts` files
|
**Contract:** `SHARED-CONTRACT.md` + four `contracts/*.v1.ts` files
|
||||||
|
**Implementation hold:** no feature slice starts until the canon PR is merged to `main` with terminal-green CI; after merge, each slice remains held until every declared KBN prerequisite is complete.
|
||||||
> **HOLD LIFTED — 2026-08-20.** The stated condition ("no feature slice starts until the
|
|
||||||
> canon PR is merged to `main` with terminal-green CI") was **met on 2026-07-14** by
|
|
||||||
> `49e8a541` — _docs(#751): Publish native Kanban/SOT canon (#752)_ — which is on both
|
|
||||||
> `main` and `next`, and `docs/requirements/native-kanban-sot.md` is present on `main`.
|
|
||||||
> The line below was never updated, so for five weeks this file advertised a blocker that
|
|
||||||
> did not exist. The status line above ("PUBLICATION IN PROGRESS") is stale for the same
|
|
||||||
> reason. This file is a build plan, not a task tracker, and is NOT superseded.
|
|
||||||
|
|
||||||
**Implementation hold (SATISFIED — see note above):** no feature slice starts until the canon PR is merged to `main` with terminal-green CI; after merge, each slice remains held until every declared KBN prerequisite is complete.
|
|
||||||
|
|
||||||
> This publication file is not a runtime task authority. After cutover, repository `TASKS.md` is generated read-only and never imported.
|
> This publication file is not a runtime task authority. After cutover, repository `TASKS.md` is generated read-only and never imported.
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
---
|
|
||||||
kind: spec
|
|
||||||
status: active
|
|
||||||
---
|
|
||||||
|
|
||||||
# CI Queue Guard Purpose Semantics
|
# CI Queue Guard Purpose Semantics
|
||||||
|
|
||||||
- **Issue:** #1146
|
- **Issue:** #1146
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
---
|
|
||||||
kind: spec
|
|
||||||
status: completed
|
|
||||||
---
|
|
||||||
|
|
||||||
# CI Queue Guard Purpose Semantics Implementation Plan
|
# CI Queue Guard Purpose Semantics Implementation Plan
|
||||||
|
|
||||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user