Compare commits
1 Commits
draft/mosa
...
feat/mosai
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b9d19347f |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -12,13 +12,3 @@ docs/reports/
|
|||||||
|
|
||||||
# Step-CA dev password — real file is gitignored; commit only the .example
|
# Step-CA dev password — real file is gitignored; commit only the .example
|
||||||
infra/step-ca/dev-password
|
infra/step-ca/dev-password
|
||||||
|
|
||||||
# Scratch dirs created by the framework git-wrapper shell test harnesses
|
|
||||||
.mosaic-test-work/
|
|
||||||
|
|
||||||
# Transient config files vite/vitest/esbuild write next to a *.config.ts while
|
|
||||||
# loading it, then unlink. They are untracked but were not ignored, so turbo's
|
|
||||||
# package traversal hashed them and intermittently failed CI with "Package
|
|
||||||
# traversal error: ... .timestamp-*.mjs: No such file or directory" when the
|
|
||||||
# file vanished mid-scan. Ignoring them removes the race.
|
|
||||||
*.timestamp-*.mjs
|
|
||||||
|
|||||||
4
.npmrc
4
.npmrc
@@ -1,5 +1 @@
|
|||||||
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
||||||
# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci),
|
|
||||||
# so the pipeline `pnpm install --prefer-offline` consumes the baked store
|
|
||||||
# instead of repopulating a fresh one.
|
|
||||||
store-dir=/root/.local/share/pnpm/store
|
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
# Build & push the pre-baked CI base image (Dockerfile.ci) to the Gitea
|
|
||||||
# registry CI already publishes to. Reuses the exact kaniko + auth pattern
|
|
||||||
# from publish.yml (REGISTRY_USER/REGISTRY_PASS from_secret, /kaniko/.docker
|
|
||||||
# config.json). Other pipelines (ci.yml, publish.yml) pull `ci-base:latest`
|
|
||||||
# for their install step.
|
|
||||||
#
|
|
||||||
# Rebuild ONLY when the dependency set or the image recipe changes — a normal
|
|
||||||
# code push must not trigger a 25-min image build. `path` applies to push/PR
|
|
||||||
# events; `event: tag` (releases) rebuilds unconditionally so a tagged release
|
|
||||||
# always ships a fresh base.
|
|
||||||
when:
|
|
||||||
- event: tag
|
|
||||||
- event: [push, manual]
|
|
||||||
branch: main
|
|
||||||
path:
|
|
||||||
include:
|
|
||||||
- 'pnpm-lock.yaml'
|
|
||||||
- 'Dockerfile.ci'
|
|
||||||
|
|
||||||
steps:
|
|
||||||
build-ci-base:
|
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
|
||||||
environment:
|
|
||||||
REGISTRY_USER:
|
|
||||||
from_secret: gitea_username
|
|
||||||
REGISTRY_PASS:
|
|
||||||
from_secret: gitea_password
|
|
||||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
|
||||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
|
||||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
|
||||||
commands:
|
|
||||||
- mkdir -p /kaniko/.docker
|
|
||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
|
||||||
- |
|
|
||||||
# Lockfile-hash tag: an immutable identity for the exact dep set baked
|
|
||||||
# into this image. `:latest` is the mutable pointer pipelines consume.
|
|
||||||
LOCK_HASH=$(sha256sum pnpm-lock.yaml | cut -c1-12)
|
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/ci-base:latest"
|
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/ci-base:lock-$LOCK_HASH"
|
|
||||||
/kaniko/executor --context . --dockerfile Dockerfile.ci $DESTINATIONS
|
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
# &node_image is the pre-baked CI base built by .woodpecker/ci-image.yml:
|
|
||||||
# node:24-alpine + python3/make/g++/postgresql-client + pnpm + a warm pnpm
|
|
||||||
# store. The install step resolves from the baked store (--prefer-offline)
|
|
||||||
# instead of paying a ~731s cold fetch + native compile every run.
|
|
||||||
variables:
|
variables:
|
||||||
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
|
- &node_image 'node:22-alpine'
|
||||||
- &enable_pnpm 'corepack enable'
|
- &enable_pnpm 'corepack enable'
|
||||||
|
|
||||||
when:
|
when:
|
||||||
@@ -19,21 +15,8 @@ steps:
|
|||||||
image: *node_image
|
image: *node_image
|
||||||
commands:
|
commands:
|
||||||
- corepack enable
|
- corepack enable
|
||||||
# python3/make/g++ are baked into ci-base; --prefer-offline resolves from
|
- apk add --no-cache python3 make g++
|
||||||
# the baked pnpm store.
|
- pnpm install --frozen-lockfile
|
||||||
- pnpm install --frozen-lockfile --prefer-offline
|
|
||||||
|
|
||||||
# Blocking gate: public framework package must contain no operator-specific
|
|
||||||
# personal data or private $HOME defaults. Runs early (no node_modules needed).
|
|
||||||
sanitization:
|
|
||||||
image: *node_image
|
|
||||||
commands:
|
|
||||||
- apk add --no-cache bash
|
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh
|
|
||||||
# Resident line-count ceiling over framework-owned resident files
|
|
||||||
# (Constitution + dispatcher + each RUNTIME.md slice). See DESIGN §7 / R9.
|
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test
|
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
|
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
@@ -42,7 +25,6 @@ steps:
|
|||||||
- pnpm typecheck
|
- pnpm typecheck
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
- sanitization
|
|
||||||
|
|
||||||
# lint, format, and test are independent — run in parallel after typecheck
|
# lint, format, and test are independent — run in parallel after typecheck
|
||||||
lint:
|
lint:
|
||||||
@@ -69,7 +51,8 @@ steps:
|
|||||||
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
|
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
|
||||||
commands:
|
commands:
|
||||||
- *enable_pnpm
|
- *enable_pnpm
|
||||||
# postgresql-client (pg_isready) is baked into ci-base.
|
# Install postgresql-client for pg_isready
|
||||||
|
- apk add --no-cache postgresql-client
|
||||||
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
|
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
|
||||||
- |
|
- |
|
||||||
ready=0
|
ready=0
|
||||||
|
|||||||
@@ -2,27 +2,8 @@
|
|||||||
# Runs only on main branch push/tag
|
# Runs only on main branch push/tag
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
|
- &node_image 'node:22-alpine'
|
||||||
# toolchain + warm pnpm store. Kills the second cold install publish pays.
|
|
||||||
- &node_image 'git.mosaicstack.dev/mosaicstack/stack/ci-base:latest'
|
|
||||||
- &enable_pnpm 'corepack enable'
|
- &enable_pnpm 'corepack enable'
|
||||||
# Heavy kaniko image builds (~25 min) — gate them so a merge that only touches
|
|
||||||
# the npm-only CLI (@mosaicstack/mosaic) or docs does NOT rebuild the platform
|
|
||||||
# images (gateway/appservice/web do not depend on @mosaicstack/mosaic). Releases
|
|
||||||
# (tags) always build everything. Exclude-list keeps the default SAFE: any
|
|
||||||
# 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
|
|
||||||
# the separate `event: tag` entry.)
|
|
||||||
- &image_build_when
|
|
||||||
- event: tag
|
|
||||||
- event: [push, manual]
|
|
||||||
branch: main
|
|
||||||
path:
|
|
||||||
exclude:
|
|
||||||
- 'packages/mosaic/**'
|
|
||||||
- 'docs/**'
|
|
||||||
- '**/*.md'
|
|
||||||
- '.woodpecker/**'
|
|
||||||
|
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main]
|
||||||
@@ -33,8 +14,7 @@ steps:
|
|||||||
image: *node_image
|
image: *node_image
|
||||||
commands:
|
commands:
|
||||||
- corepack enable
|
- corepack enable
|
||||||
# Resolve from the baked pnpm store instead of a cold network fetch.
|
- pnpm install --frozen-lockfile
|
||||||
- pnpm install --frozen-lockfile --prefer-offline
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
@@ -46,15 +26,6 @@ steps:
|
|||||||
|
|
||||||
publish-npm:
|
publish-npm:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
# Publish only when a publishable package changed (or on a release tag); a
|
|
||||||
# pure-docs merge runs no publish. Cheap step, but gated for cleanliness.
|
|
||||||
when:
|
|
||||||
- event: tag
|
|
||||||
- event: [push, manual]
|
|
||||||
branch: main
|
|
||||||
path:
|
|
||||||
include:
|
|
||||||
- 'packages/**'
|
|
||||||
environment:
|
environment:
|
||||||
NPM_TOKEN:
|
NPM_TOKEN:
|
||||||
from_secret: gitea_token
|
from_secret: gitea_token
|
||||||
@@ -120,7 +91,6 @@ steps:
|
|||||||
|
|
||||||
build-gateway:
|
build-gateway:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *image_build_when
|
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
@@ -144,35 +114,8 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
|
|
||||||
build-appservice:
|
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
|
||||||
when: *image_build_when
|
|
||||||
environment:
|
|
||||||
REGISTRY_USER:
|
|
||||||
from_secret: gitea_username
|
|
||||||
REGISTRY_PASS:
|
|
||||||
from_secret: gitea_password
|
|
||||||
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
|
||||||
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
|
||||||
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
|
||||||
commands:
|
|
||||||
- mkdir -p /kaniko/.docker
|
|
||||||
- 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}"
|
|
||||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
|
|
||||||
fi
|
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
|
|
||||||
fi
|
|
||||||
/kaniko/executor --context . --dockerfile docker/appservice.Dockerfile $DESTINATIONS
|
|
||||||
depends_on:
|
|
||||||
- build
|
|
||||||
|
|
||||||
build-web:
|
build-web:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *image_build_when
|
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
# Pre-baked CI base image for Woodpecker pipelines.
|
|
||||||
#
|
|
||||||
# Purpose: eliminate the cold `pnpm install` that dominates every pipeline
|
|
||||||
# (~731s median). This image ships the native toolchain (no per-run `apk add`)
|
|
||||||
# AND a warm, content-addressable pnpm store with the dependency-tree tarballs
|
|
||||||
# already fetched at build time. `pnpm fetch` only populates the store from the
|
|
||||||
# lockfile — it does NOT run the native node-gyp builds (better-sqlite3,
|
|
||||||
# node-pty, sqlite3, canvas, sharp); those still compile at `pnpm install`,
|
|
||||||
# which is exactly why the musl toolchain stays baked into this image. A
|
|
||||||
# pipeline `pnpm install --frozen-lockfile --prefer-offline` then resolves
|
|
||||||
# tarballs from local hard-links (no network) and compiles natives against the
|
|
||||||
# already-present toolchain, in tens of seconds instead of ~731s.
|
|
||||||
#
|
|
||||||
# Rebuilt only when `pnpm-lock.yaml` or this Dockerfile change
|
|
||||||
# (see .woodpecker/ci-image.yml).
|
|
||||||
#
|
|
||||||
# Node version is pinned to 24 (Active LTS). This is the follow-up bump from
|
|
||||||
# node:22 — sequenced AFTER the CI cache work landed so the runtime change
|
|
||||||
# carries zero cache variables. node:26 stays held until it reaches LTS
|
|
||||||
# (Oct 2026); the Current line risks native-module (node-gyp) breakage on a
|
|
||||||
# runner that compiles better-sqlite3 / canvas / sharp / node-pty from source.
|
|
||||||
FROM node:24-alpine
|
|
||||||
|
|
||||||
# Native toolchain required to compile node-gyp deps on musl, plus the
|
|
||||||
# postgresql-client used by the test step's pg_isready readiness probe. `bash`
|
|
||||||
# is baked here too — the sanitization step in ci.yml otherwise does a per-run
|
|
||||||
# `apk add bash`.
|
|
||||||
RUN apk add --no-cache python3 make g++ postgresql-client bash
|
|
||||||
|
|
||||||
# Pin pnpm to the repo's packageManager version via corepack.
|
|
||||||
RUN corepack enable && corepack prepare pnpm@10.6.2 --activate
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Pin the store location so the pipeline can point `store-dir` at the same path.
|
|
||||||
ENV PNPM_HOME=/root/.local/share/pnpm
|
|
||||||
RUN pnpm config set store-dir /root/.local/share/pnpm/store
|
|
||||||
|
|
||||||
# Warm the store. `pnpm fetch` populates the content-addressable store with the
|
|
||||||
# dependency tarballs directly from the lockfile (no package.json / workspace
|
|
||||||
# needed), so a baked store stays valid until the lockfile changes. Note:
|
|
||||||
# `fetch` does NOT compile native modules — that happens later at `pnpm install`
|
|
||||||
# in the pipeline, against the toolchain baked above.
|
|
||||||
COPY pnpm-lock.yaml ./
|
|
||||||
RUN pnpm fetch --frozen-lockfile
|
|
||||||
21
LICENSE
21
LICENSE
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 Mosaic Stack
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@mosaicstack/mosaic-as",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"type": "module",
|
|
||||||
"private": true,
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
|
||||||
"directory": "apps/appservice"
|
|
||||||
},
|
|
||||||
"main": "dist/main.js",
|
|
||||||
"bin": {
|
|
||||||
"mosaic-as": "dist/main.js",
|
|
||||||
"mosaic-as-registration": "dist/registration-main.js"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"lint": "eslint src",
|
|
||||||
"typecheck": "tsc --noEmit",
|
|
||||||
"test": "vitest run --passWithNoTests",
|
|
||||||
"dev": "tsx watch src/main.ts"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@mosaicstack/appservice": "workspace:*"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^22.0.0",
|
|
||||||
"tsx": "^4.19.0",
|
|
||||||
"typescript": "^5.8.0",
|
|
||||||
"vitest": "^2.0.0"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,388 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { AppserviceDaemon } from '../server.js';
|
|
||||||
import type { DaemonConfig, DaemonRequest } from '../server.js';
|
|
||||||
|
|
||||||
const AGENTS_TYPE = 'org.uscllc.mosaic_as.agents';
|
|
||||||
|
|
||||||
const cfg: DaemonConfig = {
|
|
||||||
homeserverUrl: 'https://hs.example',
|
|
||||||
domain: 'hs.example',
|
|
||||||
asToken: 'as-secret',
|
|
||||||
hsToken: 'hs-secret',
|
|
||||||
bridgeTokens: ['bridge-secret'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const jsonResponse = (status: number, body: unknown): Response =>
|
|
||||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
|
||||||
|
|
||||||
const request = (overrides: Partial<DaemonRequest>): DaemonRequest => ({
|
|
||||||
method: 'GET',
|
|
||||||
path: '/',
|
|
||||||
searchParams: new URLSearchParams(),
|
|
||||||
body: undefined,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
const makeDaemon = () => {
|
|
||||||
const fetchMock = vi.fn(async (_input: URL | string) => jsonResponse(200, { event_id: '$sent' }));
|
|
||||||
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
|
|
||||||
return { daemon, fetchMock };
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('AppserviceDaemon routing', () => {
|
|
||||||
it('serves health unauthenticated', async () => {
|
|
||||||
const { daemon } = makeDaemon();
|
|
||||||
expect((await daemon.handle(request({ path: '/health' }))).status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('404s unknown paths', async () => {
|
|
||||||
const { daemon } = makeDaemon();
|
|
||||||
expect((await daemon.handle(request({ path: '/nope' }))).status).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('transactions require the hs_token', async () => {
|
|
||||||
const { daemon } = makeDaemon();
|
|
||||||
const bad = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'PUT',
|
|
||||||
path: '/_matrix/app/v1/transactions/t1',
|
|
||||||
authorizationHeader: 'Bearer wrong',
|
|
||||||
body: { events: [] },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(bad.status).toBe(403);
|
|
||||||
const ok = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'PUT',
|
|
||||||
path: '/_matrix/app/v1/transactions/t1',
|
|
||||||
authorizationHeader: 'Bearer hs-secret',
|
|
||||||
body: { events: [{ type: 'm.room.message', event_id: '$e' }] },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(ok.status).toBe(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('bridge requires a bridge token (hs/as tokens do not work)', async () => {
|
|
||||||
const { daemon } = makeDaemon();
|
|
||||||
for (const token of [undefined, 'Bearer hs-secret', 'Bearer as-secret', 'Bearer nope']) {
|
|
||||||
const res = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/messages',
|
|
||||||
authorizationHeader: token,
|
|
||||||
body: {},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(res.status).toBe(403);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('bridge message sends as the agent and returns the event id', async () => {
|
|
||||||
const { daemon, fetchMock } = makeDaemon();
|
|
||||||
const res = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/messages',
|
|
||||||
authorizationHeader: 'Bearer bridge-secret',
|
|
||||||
body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi', thread_root: '$req' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.event_id).toBe('$sent');
|
|
||||||
const sendCall = fetchMock.mock.calls
|
|
||||||
.map((c) => new URL(String(c[0])))
|
|
||||||
.find((u) => u.pathname.includes('/send/m.room.message/'));
|
|
||||||
expect(sendCall).toBeDefined();
|
|
||||||
expect(sendCall!.searchParams.get('user_id')).toBe('@agent-pi0-web1:hs.example');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('bridge rejects invalid payloads with 400', async () => {
|
|
||||||
const { daemon } = makeDaemon();
|
|
||||||
const res = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/messages',
|
|
||||||
authorizationHeader: 'Bearer bridge-secret',
|
|
||||||
body: { room_id: 'bad', agent: 'pi0', body: 'x' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('bridge typing endpoint works', async () => {
|
|
||||||
const { daemon, fetchMock } = makeDaemon();
|
|
||||||
const res = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/typing',
|
|
||||||
authorizationHeader: 'Bearer bridge-secret',
|
|
||||||
body: { room_id: '!r:hs.example', agent: 'pi0-web1', typing: true },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const typingCall = fetchMock.mock.calls
|
|
||||||
.map((c) => new URL(String(c[0])))
|
|
||||||
.find((u) => u.pathname.includes('/typing/'));
|
|
||||||
expect(typingCall).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('authenticated unknown bridge sub-paths return 405, never fall through', async () => {
|
|
||||||
const { daemon } = makeDaemon();
|
|
||||||
const res = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'GET',
|
|
||||||
path: '/bridge/v1/unknown',
|
|
||||||
authorizationHeader: 'Bearer bridge-secret',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(res.status).toBe(405);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('provisions a room as the AS sender with space linking', async () => {
|
|
||||||
const calls: Array<{ url: URL; body: unknown }> = [];
|
|
||||||
const fetchMock = vi.fn(async (input: URL | string, init?: RequestInit) => {
|
|
||||||
const url = new URL(String(input));
|
|
||||||
calls.push({ url, body: init?.body ? JSON.parse(String(init.body)) : undefined });
|
|
||||||
if (url.pathname.endsWith('/createRoom'))
|
|
||||||
return jsonResponse(200, { room_id: '!new:hs.example' });
|
|
||||||
return jsonResponse(200, {});
|
|
||||||
});
|
|
||||||
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
|
|
||||||
const res = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/provision/rooms',
|
|
||||||
authorizationHeader: 'Bearer bridge-secret',
|
|
||||||
body: {
|
|
||||||
name: 'proj-x',
|
|
||||||
alias: 'mosaic-proj-x',
|
|
||||||
invite: ['@jason.woltje:hs.example'],
|
|
||||||
space_id: '!space:hs.example',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.room_id).toBe('!new:hs.example');
|
|
||||||
expect(res.body.space_linked).toBe(true);
|
|
||||||
const create = calls.find((c) => c.url.pathname.endsWith('/createRoom'));
|
|
||||||
expect(create!.url.searchParams.get('user_id')).toBe('@mosaic-as:hs.example');
|
|
||||||
const body = create!.body as Record<string, unknown>;
|
|
||||||
expect(body.room_alias_name).toBe('mosaic-proj-x');
|
|
||||||
expect((body.power_level_content_override as Record<string, unknown>).users).toEqual({
|
|
||||||
'@mosaic-as:hs.example': 100,
|
|
||||||
});
|
|
||||||
expect(calls.some((c) => c.url.pathname.includes('/state/m.space.child/'))).toBe(true);
|
|
||||||
expect(calls.some((c) => c.url.pathname.includes('/state/m.space.parent/'))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('space-link failure still returns the room id (no orphan)', async () => {
|
|
||||||
const fetchMock = vi.fn(async (input: URL | string) => {
|
|
||||||
const url = new URL(String(input));
|
|
||||||
if (url.pathname.endsWith('/createRoom'))
|
|
||||||
return jsonResponse(200, { room_id: '!new:hs.example' });
|
|
||||||
if (url.pathname.includes('/state/m.space.child/'))
|
|
||||||
return jsonResponse(403, { errcode: 'M_FORBIDDEN', error: 'no PL in space' });
|
|
||||||
return jsonResponse(200, {});
|
|
||||||
});
|
|
||||||
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
|
|
||||||
const res = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/provision/rooms',
|
|
||||||
authorizationHeader: 'Bearer bridge-secret',
|
|
||||||
body: { name: 'proj-x', space_id: '!space:hs.example' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.room_id).toBe('!new:hs.example');
|
|
||||||
expect(res.body.space_linked).toBe(false);
|
|
||||||
expect(String(res.body.space_error)).toContain('403');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('invite list cap enforced', async () => {
|
|
||||||
const { daemon } = makeDaemon();
|
|
||||||
const res = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/provision/rooms',
|
|
||||||
authorizationHeader: 'Bearer bridge-secret',
|
|
||||||
body: { name: 'x', invite: Array.from({ length: 51 }, (_, i) => `@u${i}:hs`) },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(res.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('provision rejects bad payloads and requires auth', async () => {
|
|
||||||
const { daemon } = makeDaemon();
|
|
||||||
const noAuth = await daemon.handle(
|
|
||||||
request({ method: 'POST', path: '/bridge/v1/provision/rooms', body: { name: 'x' } }),
|
|
||||||
);
|
|
||||||
expect(noAuth.status).toBe(403);
|
|
||||||
const bad = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/provision/rooms',
|
|
||||||
authorizationHeader: 'Bearer bridge-secret',
|
|
||||||
body: { name: '', alias: 'BAD ALIAS' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(bad.status).toBe(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
// A daemon whose fetch mock backs account_data with a mutable in-test object,
|
|
||||||
// so register/verify/revoke round-trip through the (faked) homeserver.
|
|
||||||
const makeAgentDaemon = () => {
|
|
||||||
const accountData: { value: Record<string, unknown> | null } = { value: null };
|
|
||||||
const fetchMock = vi.fn(async (input: URL | string, init?: RequestInit) => {
|
|
||||||
const url = new URL(String(input));
|
|
||||||
const path = url.pathname;
|
|
||||||
if (path.includes(`/account_data/${AGENTS_TYPE}`)) {
|
|
||||||
if (init?.method === 'PUT') {
|
|
||||||
accountData.value = JSON.parse(String(init.body)) as Record<string, unknown>;
|
|
||||||
return jsonResponse(200, {});
|
|
||||||
}
|
|
||||||
if (accountData.value === null) {
|
|
||||||
return jsonResponse(404, { errcode: 'M_NOT_FOUND', error: 'not found' });
|
|
||||||
}
|
|
||||||
return jsonResponse(200, accountData.value);
|
|
||||||
}
|
|
||||||
if (path.endsWith('/register')) return jsonResponse(200, { user_id: 'whatever' });
|
|
||||||
if (path.includes('/send/m.room.message/')) return jsonResponse(200, { event_id: '$sent' });
|
|
||||||
return jsonResponse(200, {});
|
|
||||||
});
|
|
||||||
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
|
|
||||||
return { daemon, fetchMock };
|
|
||||||
};
|
|
||||||
|
|
||||||
const registerAgent = async (
|
|
||||||
daemon: AppserviceDaemon,
|
|
||||||
body: Record<string, unknown> = { alias: 'pi0', host: 'web1' },
|
|
||||||
) =>
|
|
||||||
daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/agents',
|
|
||||||
authorizationHeader: 'Bearer bridge-secret',
|
|
||||||
body,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
it('host token registers an agent and returns agent_user_id + bridge_token', async () => {
|
|
||||||
const { daemon, fetchMock } = makeAgentDaemon();
|
|
||||||
const res = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
expect(res.body.agent_user_id).toBe('@agent-pi0-web1:hs.example');
|
|
||||||
expect(String(res.body.bridge_token).startsWith('magt_')).toBe(true);
|
|
||||||
const registerCall = fetchMock.mock.calls
|
|
||||||
.map((c) => new URL(String(c[0])))
|
|
||||||
.find((u) => u.pathname.endsWith('/register'));
|
|
||||||
expect(registerCall).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('register requires a HOST token (agent token and no token are 403)', async () => {
|
|
||||||
const { daemon } = makeAgentDaemon();
|
|
||||||
const minted = await registerAgent(daemon);
|
|
||||||
const agentToken = String(minted.body.bridge_token);
|
|
||||||
|
|
||||||
const asAgent = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/agents',
|
|
||||||
authorizationHeader: `Bearer ${agentToken}`,
|
|
||||||
body: { alias: 'pi1', host: 'web2' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(asAgent.status).toBe(403);
|
|
||||||
|
|
||||||
const noAuth = await daemon.handle(
|
|
||||||
request({ method: 'POST', path: '/bridge/v1/agents', body: { alias: 'pi1', host: 'web2' } }),
|
|
||||||
);
|
|
||||||
expect(noAuth.status).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('agent-scoped token may send as itself but not as another agent', async () => {
|
|
||||||
const { daemon } = makeAgentDaemon();
|
|
||||||
const minted = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
|
|
||||||
const agentToken = String(minted.body.bridge_token);
|
|
||||||
|
|
||||||
const self = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/messages',
|
|
||||||
authorizationHeader: `Bearer ${agentToken}`,
|
|
||||||
body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(self.status).toBe(200);
|
|
||||||
|
|
||||||
const other = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/messages',
|
|
||||||
authorizationHeader: `Bearer ${agentToken}`,
|
|
||||||
body: { room_id: '!r:hs.example', agent: 'pi9-web9', body: 'hi' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(other.status).toBe(403);
|
|
||||||
expect(other.body.error).toBe('token not scoped to this agent');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('revoked agent token is rejected on messages', async () => {
|
|
||||||
const { daemon } = makeAgentDaemon();
|
|
||||||
const minted = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
|
|
||||||
const agentToken = String(minted.body.bridge_token);
|
|
||||||
|
|
||||||
const revoke = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/agents/revoke',
|
|
||||||
authorizationHeader: 'Bearer bridge-secret',
|
|
||||||
body: { agent_user_id: '@agent-pi0-web1:hs.example' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(revoke.status).toBe(200);
|
|
||||||
expect(revoke.body.revoked).toBe(1);
|
|
||||||
|
|
||||||
const afterRevoke = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/messages',
|
|
||||||
authorizationHeader: `Bearer ${agentToken}`,
|
|
||||||
body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(afterRevoke.status).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /bridge/v1/agents lists registered agents (host only)', async () => {
|
|
||||||
const { daemon } = makeAgentDaemon();
|
|
||||||
await registerAgent(daemon, { alias: 'pi0', host: 'web1', display_name: 'Pi Zero' });
|
|
||||||
|
|
||||||
const res = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'GET',
|
|
||||||
path: '/bridge/v1/agents',
|
|
||||||
authorizationHeader: 'Bearer bridge-secret',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(res.status).toBe(200);
|
|
||||||
const agents = res.body.agents as Array<Record<string, unknown>>;
|
|
||||||
expect(agents).toHaveLength(1);
|
|
||||||
expect(agents[0]?.agent_user_id).toBe('@agent-pi0-web1:hs.example');
|
|
||||||
expect(agents[0]?.display_name).toBe('Pi Zero');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('empty bridge token list denies everything', async () => {
|
|
||||||
const daemon = new AppserviceDaemon({ ...cfg, bridgeTokens: [] }, undefined, () => {});
|
|
||||||
const res = await daemon.handle(
|
|
||||||
request({
|
|
||||||
method: 'POST',
|
|
||||||
path: '/bridge/v1/typing',
|
|
||||||
authorizationHeader: 'Bearer bridge-secret',
|
|
||||||
body: {},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(res.status).toBe(403);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { DaemonConfig } from './server.js';
|
|
||||||
|
|
||||||
const required = (name: string): string => {
|
|
||||||
const value = process.env[name];
|
|
||||||
if (!value) throw new Error(`missing required env var ${name}`);
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function configFromEnv(): DaemonConfig & { port: number } {
|
|
||||||
return {
|
|
||||||
homeserverUrl: required('MOSAIC_AS_HOMESERVER_URL'),
|
|
||||||
domain: required('MOSAIC_AS_DOMAIN'),
|
|
||||||
asToken: required('MOSAIC_AS_TOKEN'),
|
|
||||||
hsToken: required('MOSAIC_HS_TOKEN'),
|
|
||||||
userPrefix: process.env.MOSAIC_AS_USER_PREFIX ?? 'agent-',
|
|
||||||
senderLocalpart: process.env.MOSAIC_AS_SENDER_LOCALPART ?? 'mosaic-as',
|
|
||||||
bridgeTokens: (process.env.MOSAIC_AS_BRIDGE_TOKENS ?? '')
|
|
||||||
.split(',')
|
|
||||||
.map((t) => t.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
port: Number(process.env.MOSAIC_AS_PORT ?? 8008),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import http from 'node:http';
|
|
||||||
|
|
||||||
import { configFromEnv } from './config.js';
|
|
||||||
import { AppserviceDaemon } from './server.js';
|
|
||||||
|
|
||||||
const cfg = configFromEnv();
|
|
||||||
const daemon = new AppserviceDaemon(cfg);
|
|
||||||
|
|
||||||
const MAX_BODY_BYTES = 1024 * 1024;
|
|
||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
let received = 0;
|
|
||||||
let rejected = false;
|
|
||||||
req.on('data', (chunk: Buffer) => {
|
|
||||||
received += chunk.length;
|
|
||||||
if (received > MAX_BODY_BYTES) {
|
|
||||||
rejected = true;
|
|
||||||
res.writeHead(413, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ errcode: 'M_TOO_LARGE', error: 'request body too large' }));
|
|
||||||
req.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
chunks.push(chunk);
|
|
||||||
});
|
|
||||||
req.on('end', () => {
|
|
||||||
if (rejected) return;
|
|
||||||
void (async () => {
|
|
||||||
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
||||||
let body: unknown;
|
|
||||||
try {
|
|
||||||
const raw = Buffer.concat(chunks).toString();
|
|
||||||
body = raw ? JSON.parse(raw) : undefined;
|
|
||||||
} catch {
|
|
||||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ errcode: 'M_NOT_JSON', error: 'invalid json' }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = await daemon.handle({
|
|
||||||
method: req.method ?? 'GET',
|
|
||||||
path: url.pathname,
|
|
||||||
searchParams: url.searchParams,
|
|
||||||
authorizationHeader: req.headers.authorization,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
res.writeHead(result.status, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify(result.body));
|
|
||||||
})().catch((error: unknown) => {
|
|
||||||
console.error('request failed:', error);
|
|
||||||
if (res.headersSent) {
|
|
||||||
res.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ error: 'internal error' }));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
server.listen(cfg.port, () => {
|
|
||||||
console.log(
|
|
||||||
`mosaic-as listening on :${cfg.port} (homeserver ${cfg.homeserverUrl}, domain ${cfg.domain})`,
|
|
||||||
);
|
|
||||||
if (cfg.bridgeTokens.length === 0) {
|
|
||||||
console.warn('WARNING: MOSAIC_AS_BRIDGE_TOKENS is empty — bridge API will deny all requests');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { buildRegistration, registrationToYaml } from '@mosaicstack/appservice';
|
|
||||||
|
|
||||||
import { configFromEnv } from './config.js';
|
|
||||||
|
|
||||||
// Prints the Synapse registration YAML (mosaic-as.yaml) for the current env.
|
|
||||||
// Usage: MOSAIC_AS_URL=http://mosaic-as:8008 mosaic-as-registration > mosaic-as.yaml
|
|
||||||
const cfg = configFromEnv();
|
|
||||||
const url = process.env.MOSAIC_AS_URL;
|
|
||||||
if (!url) throw new Error('missing required env var MOSAIC_AS_URL');
|
|
||||||
process.stdout.write(registrationToYaml(buildRegistration(cfg, { url })));
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
||||||
|
|
||||||
import {
|
|
||||||
AgentTokenStore,
|
|
||||||
AppserviceIntent,
|
|
||||||
TransactionHandler,
|
|
||||||
validateBridgeMessage,
|
|
||||||
validateBridgeTyping,
|
|
||||||
validateProvisionRoom,
|
|
||||||
validateRegisterAgent,
|
|
||||||
validateRevokeAgent,
|
|
||||||
} from '@mosaicstack/appservice';
|
|
||||||
import type { AppserviceConfig, MatrixEvent } from '@mosaicstack/appservice';
|
|
||||||
|
|
||||||
export interface DaemonConfig extends AppserviceConfig {
|
|
||||||
/** Bearer tokens accepted on /bridge/v1/* (one per agent-comms host daemon). */
|
|
||||||
bridgeTokens: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DaemonRequest {
|
|
||||||
method: string;
|
|
||||||
/** URL path without query string. */
|
|
||||||
path: string;
|
|
||||||
searchParams: URLSearchParams;
|
|
||||||
authorizationHeader?: string;
|
|
||||||
body: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DaemonResponse {
|
|
||||||
status: number;
|
|
||||||
body: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare equal-length HMAC digests so neither content nor LENGTH of the
|
|
||||||
// stored secret is observable through timing.
|
|
||||||
const HMAC_KEY = randomBytes(32);
|
|
||||||
const digest = (value: string): Buffer => createHmac('sha256', HMAC_KEY).update(value).digest();
|
|
||||||
|
|
||||||
const safeEqual = (a: string, b: string): boolean => timingSafeEqual(digest(a), digest(b));
|
|
||||||
|
|
||||||
const TXN_PATH = /^\/_matrix\/app\/v1\/transactions\/([^/]+)$/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolved identity for an authenticated /bridge/v1/* caller. Host principals
|
|
||||||
* (the agent-comms host daemons) are unrestricted; agent principals are scoped
|
|
||||||
* to a single virtual user and may only act as themselves.
|
|
||||||
*/
|
|
||||||
export type BridgePrincipal = { kind: 'host' } | { kind: 'agent'; agentUserId: string } | null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* HTTP-framework-agnostic request router for the mosaic-as daemon: the
|
|
||||||
* Application Service transactions endpoint (Synapse-facing) plus the
|
|
||||||
* internal bridge API v1 (agent-comms daemon-facing). main.ts binds this to
|
|
||||||
* node:http; tests drive it directly.
|
|
||||||
*/
|
|
||||||
export class AppserviceDaemon {
|
|
||||||
readonly intent: AppserviceIntent;
|
|
||||||
private readonly transactions: TransactionHandler;
|
|
||||||
private readonly agents: AgentTokenStore;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly cfg: DaemonConfig,
|
|
||||||
fetchImpl?: typeof fetch,
|
|
||||||
private readonly log: (line: string) => void = (line) => console.log(line),
|
|
||||||
) {
|
|
||||||
this.intent = new AppserviceIntent(cfg, fetchImpl);
|
|
||||||
this.agents = new AgentTokenStore(this.intent);
|
|
||||||
this.transactions = new TransactionHandler({
|
|
||||||
hsToken: cfg.hsToken,
|
|
||||||
onEvent: (event) => this.onEvent(event),
|
|
||||||
onError: (error, txnId) => this.log(`txn ${txnId} handler error: ${String(error)}`),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** v1: the daemon only observes; room logic lives in the agent-comms daemons. */
|
|
||||||
private onEvent(event: MatrixEvent): void {
|
|
||||||
if (event.type === 'm.room.message') {
|
|
||||||
this.log(
|
|
||||||
`event ${event.event_id ?? '?'} in ${event.room_id ?? '?'} from ${event.sender ?? '?'}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolve the calling principal, or null when unauthorized. Fail-closed:
|
|
||||||
* host tokens win (timing-safe compare); otherwise a magt_* bearer is looked
|
|
||||||
* up in the agent token store; anything else is rejected. */
|
|
||||||
private async bridgeAuthorized(
|
|
||||||
authorizationHeader: string | undefined,
|
|
||||||
): Promise<BridgePrincipal> {
|
|
||||||
if (!authorizationHeader?.startsWith('Bearer ')) return null;
|
|
||||||
const presented = authorizationHeader.slice('Bearer '.length);
|
|
||||||
if (this.cfg.bridgeTokens.some((token) => safeEqual(presented, token))) {
|
|
||||||
return { kind: 'host' };
|
|
||||||
}
|
|
||||||
const agentUserId = await this.agents.verifyToken(presented);
|
|
||||||
if (agentUserId) return { kind: 'agent', agentUserId };
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async handle(req: DaemonRequest): Promise<DaemonResponse> {
|
|
||||||
if (req.method === 'GET' && req.path === '/health') {
|
|
||||||
return { status: 200, body: { ok: true } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const txnMatch = req.method === 'PUT' ? TXN_PATH.exec(req.path) : null;
|
|
||||||
if (txnMatch?.[1] !== undefined) {
|
|
||||||
return this.transactions.handle(txnMatch[1], req.body, {
|
|
||||||
authorizationHeader: req.authorizationHeader,
|
|
||||||
accessTokenParam: req.searchParams.get('access_token') ?? undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.path.startsWith('/bridge/v1/')) {
|
|
||||||
const principal = await this.bridgeAuthorized(req.authorizationHeader);
|
|
||||||
if (!principal) {
|
|
||||||
return { status: 403, body: { errcode: 'M_FORBIDDEN', error: 'bad bridge token' } };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (req.method === 'POST' && req.path === '/bridge/v1/agents') {
|
|
||||||
if (principal.kind !== 'host') {
|
|
||||||
return {
|
|
||||||
status: 403,
|
|
||||||
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot register agents' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
validateRegisterAgent(req.body);
|
|
||||||
const { agentUserId, token } = await this.agents.register({
|
|
||||||
alias: req.body.alias,
|
|
||||||
host: req.body.host,
|
|
||||||
displayName: req.body.display_name,
|
|
||||||
});
|
|
||||||
this.log(`registered agent ${agentUserId}`);
|
|
||||||
return { status: 200, body: { agent_user_id: agentUserId, bridge_token: token } };
|
|
||||||
}
|
|
||||||
if (req.method === 'POST' && req.path === '/bridge/v1/agents/revoke') {
|
|
||||||
if (principal.kind !== 'host') {
|
|
||||||
return {
|
|
||||||
status: 403,
|
|
||||||
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot revoke agents' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
validateRevokeAgent(req.body);
|
|
||||||
const revoked = await this.agents.revoke(req.body.agent_user_id);
|
|
||||||
this.log(`revoked ${revoked} token(s) for ${req.body.agent_user_id}`);
|
|
||||||
return { status: 200, body: { revoked } };
|
|
||||||
}
|
|
||||||
if (req.method === 'GET' && req.path === '/bridge/v1/agents') {
|
|
||||||
if (principal.kind !== 'host') {
|
|
||||||
return {
|
|
||||||
status: 403,
|
|
||||||
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot list agents' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const agents = await this.agents.list();
|
|
||||||
return { status: 200, body: { agents } };
|
|
||||||
}
|
|
||||||
if (req.method === 'POST' && req.path === '/bridge/v1/messages') {
|
|
||||||
validateBridgeMessage(req.body);
|
|
||||||
if (
|
|
||||||
principal.kind === 'agent' &&
|
|
||||||
this.intent.agentUserId(req.body.agent) !== principal.agentUserId
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
status: 403,
|
|
||||||
body: { errcode: 'M_FORBIDDEN', error: 'token not scoped to this agent' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const eventId = await this.intent.sendAsAgent({
|
|
||||||
roomId: req.body.room_id,
|
|
||||||
agent: req.body.agent,
|
|
||||||
body: req.body.body,
|
|
||||||
threadRoot: req.body.thread_root,
|
|
||||||
msgtype: req.body.msgtype,
|
|
||||||
extraContent: req.body.extra_content,
|
|
||||||
});
|
|
||||||
return { status: 200, body: { event_id: eventId ?? null } };
|
|
||||||
}
|
|
||||||
if (req.method === 'POST' && req.path === '/bridge/v1/typing') {
|
|
||||||
validateBridgeTyping(req.body);
|
|
||||||
if (
|
|
||||||
principal.kind === 'agent' &&
|
|
||||||
this.intent.agentUserId(req.body.agent) !== principal.agentUserId
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
status: 403,
|
|
||||||
body: { errcode: 'M_FORBIDDEN', error: 'token not scoped to this agent' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
await this.intent.setTyping(req.body.room_id, req.body.agent, req.body.typing);
|
|
||||||
return { status: 200, body: {} };
|
|
||||||
}
|
|
||||||
if (req.method === 'POST' && req.path === '/bridge/v1/provision/rooms') {
|
|
||||||
validateProvisionRoom(req.body);
|
|
||||||
const result = await this.intent.createRoom({
|
|
||||||
name: req.body.name,
|
|
||||||
alias: req.body.alias,
|
|
||||||
topic: req.body.topic,
|
|
||||||
invite: req.body.invite,
|
|
||||||
spaceId: req.body.space_id,
|
|
||||||
});
|
|
||||||
this.log(
|
|
||||||
`provisioned room ${result.roomId} (${req.body.name}) space_linked=${result.spaceLinked}`,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
status: 200,
|
|
||||||
body: {
|
|
||||||
room_id: result.roomId,
|
|
||||||
space_linked: result.spaceLinked,
|
|
||||||
...(result.spaceError ? { space_error: result.spaceError } : {}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
this.log(`bridge error ${req.method} ${req.path}: ${message}`);
|
|
||||||
return { status: 400, body: { error: message } };
|
|
||||||
}
|
|
||||||
// Explicit: never fall out of the authenticated bridge block, so future
|
|
||||||
// sub-paths cannot accidentally route around the auth guard above.
|
|
||||||
return { status: 405, body: { error: 'unsupported bridge method/path' } };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { status: 404, body: { error: 'not found' } };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src"
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist"]
|
|
||||||
}
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import type { Db } from '@mosaicstack/db';
|
|
||||||
import type { FederationListResponse } from '@mosaicstack/types';
|
|
||||||
import {
|
|
||||||
FederationClientError,
|
|
||||||
type FederationClientService,
|
|
||||||
} from '../federation-client.service.js';
|
|
||||||
import { type QuerySourceError, QuerySourceService } from '../query-source.service.js';
|
|
||||||
|
|
||||||
interface TestRow {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PeerRow {
|
|
||||||
id: string;
|
|
||||||
commonName: string;
|
|
||||||
endpointUrl: string | null;
|
|
||||||
clientKeyPem: string | null;
|
|
||||||
state: 'active' | 'pending' | 'suspended' | 'revoked';
|
|
||||||
}
|
|
||||||
|
|
||||||
const LOCAL_ROWS: TestRow[] = [
|
|
||||||
{ id: 'local-1', title: 'Local One' },
|
|
||||||
{ id: 'local-2', title: 'Local Two' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const PEER_A: PeerRow = {
|
|
||||||
id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
|
||||||
commonName: 'peer-a',
|
|
||||||
endpointUrl: 'https://peer-a.example.com',
|
|
||||||
clientKeyPem: 'sealed-key-a',
|
|
||||||
state: 'active',
|
|
||||||
};
|
|
||||||
|
|
||||||
const PEER_B: PeerRow = {
|
|
||||||
id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
|
||||||
commonName: 'peer-b',
|
|
||||||
endpointUrl: 'https://peer-b.example.com',
|
|
||||||
clientKeyPem: 'sealed-key-b',
|
|
||||||
state: 'active',
|
|
||||||
};
|
|
||||||
|
|
||||||
const PEER_LOCALHOST: PeerRow = {
|
|
||||||
id: 'cccccccc-cccc-cccc-cccc-cccccccccccc',
|
|
||||||
commonName: 'peer-localhost',
|
|
||||||
endpointUrl: 'https://localhost:3001',
|
|
||||||
clientKeyPem: 'sealed-key-c',
|
|
||||||
state: 'active',
|
|
||||||
};
|
|
||||||
|
|
||||||
function makeDb(activePeers: PeerRow[]): Db {
|
|
||||||
const orderBy = vi.fn().mockResolvedValue(activePeers);
|
|
||||||
const where = vi.fn().mockReturnValue({ orderBy });
|
|
||||||
const from = vi.fn().mockReturnValue({ where });
|
|
||||||
const select = vi.fn().mockReturnValue({ from });
|
|
||||||
|
|
||||||
return {
|
|
||||||
select,
|
|
||||||
insert: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
delete: vi.fn(),
|
|
||||||
transaction: vi.fn(),
|
|
||||||
} as unknown as Db;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeFederationClient(
|
|
||||||
list: (
|
|
||||||
peerId: string,
|
|
||||||
resource: string,
|
|
||||||
request: Record<string, unknown>,
|
|
||||||
) => Promise<FederationListResponse<TestRow>>,
|
|
||||||
): FederationClientService {
|
|
||||||
return {
|
|
||||||
list: list as unknown as FederationClientService['list'],
|
|
||||||
} as FederationClientService;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeLocalResponse(rows: TestRow[] = LOCAL_ROWS): Promise<FederationListResponse<TestRow>> {
|
|
||||||
return Promise.resolve({ items: rows });
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('QuerySourceService', () => {
|
|
||||||
it('routes source="local" to the local executor and tags rows as local', async () => {
|
|
||||||
const list = vi.fn(async (): Promise<FederationListResponse<TestRow>> => ({ items: [] }));
|
|
||||||
const service = new QuerySourceService(makeDb([PEER_A]), makeFederationClient(list));
|
|
||||||
|
|
||||||
const result = await service.list<TestRow>({
|
|
||||||
source: 'local',
|
|
||||||
resource: 'tasks',
|
|
||||||
request: { cursor: 'ignored-for-local-test' },
|
|
||||||
local: () => makeLocalResponse(),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
items: [
|
|
||||||
{ id: 'local-1', title: 'Local One', _source: 'local' },
|
|
||||||
{ id: 'local-2', title: 'Local Two', _source: 'local' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(list).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('routes source="federated:<host>" to the matching active peer and tags rows with peer commonName', async () => {
|
|
||||||
const list = vi.fn(
|
|
||||||
async (): Promise<FederationListResponse<TestRow>> => ({
|
|
||||||
items: [{ id: 'remote-1', title: 'Remote One' }],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const service = new QuerySourceService(makeDb([PEER_A, PEER_B]), makeFederationClient(list));
|
|
||||||
|
|
||||||
const result = await service.list<TestRow>({
|
|
||||||
source: 'federated:peer-b.example.com',
|
|
||||||
resource: 'tasks',
|
|
||||||
request: { status: 'open' },
|
|
||||||
local: () => makeLocalResponse(),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
items: [{ id: 'remote-1', title: 'Remote One', _source: 'peer-b' }],
|
|
||||||
});
|
|
||||||
expect(list).toHaveBeenCalledWith(PEER_B.id, 'tasks', { status: 'open' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('matches federated hosts by endpoint host including non-default port', async () => {
|
|
||||||
const list = vi.fn(
|
|
||||||
async (): Promise<FederationListResponse<TestRow>> => ({
|
|
||||||
items: [{ id: 'remote-port', title: 'Remote Port' }],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const service = new QuerySourceService(makeDb([PEER_LOCALHOST]), makeFederationClient(list));
|
|
||||||
|
|
||||||
const result = await service.list<TestRow>({
|
|
||||||
source: 'federated:localhost:3001',
|
|
||||||
resource: 'tasks',
|
|
||||||
request: {},
|
|
||||||
local: () => makeLocalResponse(),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
items: [{ id: 'remote-port', title: 'Remote Port', _source: 'peer-localhost' }],
|
|
||||||
});
|
|
||||||
expect(list).toHaveBeenCalledWith(PEER_LOCALHOST.id, 'tasks', {});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fans out source="all" to local plus every active outbound peer in parallel and merges tagged rows', async () => {
|
|
||||||
const callOrder: string[] = [];
|
|
||||||
const list = vi.fn(async (peerId: string): Promise<FederationListResponse<TestRow>> => {
|
|
||||||
callOrder.push(`remote-start:${peerId}`);
|
|
||||||
await Promise.resolve();
|
|
||||||
return {
|
|
||||||
items: [{ id: `remote-${peerId.slice(0, 1)}`, title: `Remote ${peerId.slice(0, 1)}` }],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const service = new QuerySourceService(makeDb([PEER_A, PEER_B]), makeFederationClient(list));
|
|
||||||
|
|
||||||
const result = await service.list<TestRow>({
|
|
||||||
source: 'all',
|
|
||||||
resource: 'tasks',
|
|
||||||
request: { limit: 25 },
|
|
||||||
local: async () => {
|
|
||||||
callOrder.push('local-start');
|
|
||||||
await Promise.resolve();
|
|
||||||
return { items: [{ id: 'local-1', title: 'Local One' }] };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
items: [
|
|
||||||
{ id: 'local-1', title: 'Local One', _source: 'local' },
|
|
||||||
{ id: 'remote-a', title: 'Remote a', _source: 'peer-a' },
|
|
||||||
{ id: 'remote-b', title: 'Remote b', _source: 'peer-b' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(list).toHaveBeenCalledTimes(2);
|
|
||||||
expect(callOrder).toEqual([
|
|
||||||
'local-start',
|
|
||||||
`remote-start:${PEER_A.id}`,
|
|
||||||
`remote-start:${PEER_B.id}`,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks source="all" as partial and truncated when any subquery returns a cursor', async () => {
|
|
||||||
const list = vi.fn(
|
|
||||||
async (): Promise<FederationListResponse<TestRow>> => ({
|
|
||||||
items: [{ id: 'remote-a', title: 'Remote A' }],
|
|
||||||
nextCursor: 'remote-next',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const service = new QuerySourceService(makeDb([PEER_A]), makeFederationClient(list));
|
|
||||||
|
|
||||||
const result = await service.list<TestRow>({
|
|
||||||
source: 'all',
|
|
||||||
resource: 'tasks',
|
|
||||||
request: {},
|
|
||||||
local: () => makeLocalResponse([{ id: 'local-1', title: 'Local One' }]),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
items: [
|
|
||||||
{ id: 'local-1', title: 'Local One', _source: 'local' },
|
|
||||||
{ id: 'remote-a', title: 'Remote A', _source: 'peer-a' },
|
|
||||||
],
|
|
||||||
_partial: true,
|
|
||||||
_truncated: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns _partial=true for source="all" when one peer fails without dropping successful sources', async () => {
|
|
||||||
const list = vi.fn(async (peerId: string): Promise<FederationListResponse<TestRow>> => {
|
|
||||||
if (peerId === PEER_B.id) {
|
|
||||||
throw new FederationClientError({
|
|
||||||
code: 'NETWORK',
|
|
||||||
message: 'peer unavailable',
|
|
||||||
peerId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { items: [{ id: 'remote-a', title: 'Remote A' }] };
|
|
||||||
});
|
|
||||||
const service = new QuerySourceService(makeDb([PEER_A, PEER_B]), makeFederationClient(list));
|
|
||||||
|
|
||||||
const result = await service.list<TestRow>({
|
|
||||||
source: 'all',
|
|
||||||
resource: 'tasks',
|
|
||||||
request: {},
|
|
||||||
local: () => makeLocalResponse([{ id: 'local-1', title: 'Local One' }]),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
items: [
|
|
||||||
{ id: 'local-1', title: 'Local One', _source: 'local' },
|
|
||||||
{ id: 'remote-a', title: 'Remote A', _source: 'peer-a' },
|
|
||||||
],
|
|
||||||
_partial: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws QuerySourceError when a federated host does not match an active outbound peer', async () => {
|
|
||||||
const list = vi.fn(async (): Promise<FederationListResponse<TestRow>> => ({ items: [] }));
|
|
||||||
const service = new QuerySourceService(makeDb([PEER_A]), makeFederationClient(list));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.list<TestRow>({
|
|
||||||
source: 'federated:missing.example.com',
|
|
||||||
resource: 'tasks',
|
|
||||||
request: {},
|
|
||||||
local: () => makeLocalResponse(),
|
|
||||||
}),
|
|
||||||
).rejects.toMatchObject({
|
|
||||||
name: 'QuerySourceError',
|
|
||||||
code: 'PEER_NOT_FOUND',
|
|
||||||
} satisfies Partial<QuerySourceError>);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -11,13 +11,3 @@ export {
|
|||||||
type FederationClientErrorCode,
|
type FederationClientErrorCode,
|
||||||
type FederationClientErrorOptions,
|
type FederationClientErrorOptions,
|
||||||
} from './federation-client.service.js';
|
} from './federation-client.service.js';
|
||||||
export {
|
|
||||||
QuerySourceService,
|
|
||||||
QuerySourceError,
|
|
||||||
type QuerySource,
|
|
||||||
type QuerySourceErrorCode,
|
|
||||||
type QuerySourceErrorOptions,
|
|
||||||
type QuerySourceListOptions,
|
|
||||||
type QuerySourceListResponse,
|
|
||||||
type LocalListExecutor,
|
|
||||||
} from './query-source.service.js';
|
|
||||||
|
|||||||
@@ -1,261 +0,0 @@
|
|||||||
/**
|
|
||||||
* QuerySourceService — gateway query source router (FED-M3-09).
|
|
||||||
*
|
|
||||||
* Accepts the federation query-layer `source` selector and routes list-style
|
|
||||||
* reads to local storage, one federated peer, or all active outbound peers.
|
|
||||||
*
|
|
||||||
* `source: "all"` is intentionally tolerant of per-peer failures: local data
|
|
||||||
* and successful peer responses are returned, and the envelope is marked
|
|
||||||
* `_partial: true`. Local failures still reject because there is no safe local
|
|
||||||
* fallback and the gateway's own storage is expected to be authoritative.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { and, eq, federationPeers, isNotNull, type Db } from '@mosaicstack/db';
|
|
||||||
import {
|
|
||||||
SOURCE_LOCAL,
|
|
||||||
tagWithSource,
|
|
||||||
type FederationListResponse,
|
|
||||||
type SourceTag,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
import { DB } from '../../database/database.module.js';
|
|
||||||
import { FederationClientService } from './federation-client.service.js';
|
|
||||||
|
|
||||||
export type QuerySource = 'local' | 'all' | `federated:${string}`;
|
|
||||||
|
|
||||||
export type QuerySourceErrorCode = 'INVALID_SOURCE' | 'PEER_NOT_FOUND';
|
|
||||||
|
|
||||||
export interface QuerySourceErrorOptions {
|
|
||||||
code: QuerySourceErrorCode;
|
|
||||||
message: string;
|
|
||||||
source: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class QuerySourceError extends Error {
|
|
||||||
readonly code: QuerySourceErrorCode;
|
|
||||||
readonly source: string;
|
|
||||||
|
|
||||||
constructor(opts: QuerySourceErrorOptions) {
|
|
||||||
super(opts.message);
|
|
||||||
this.name = 'QuerySourceError';
|
|
||||||
this.code = opts.code;
|
|
||||||
this.source = opts.source;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LocalListExecutor<T extends object> = () => Promise<FederationListResponse<T> | T[]>;
|
|
||||||
|
|
||||||
export interface QuerySourceListOptions<T extends object> {
|
|
||||||
source: QuerySource;
|
|
||||||
resource: string;
|
|
||||||
request?: Record<string, unknown>;
|
|
||||||
local: LocalListExecutor<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type QuerySourceListResponse<T extends object> = FederationListResponse<T & SourceTag>;
|
|
||||||
|
|
||||||
interface OutboundPeer {
|
|
||||||
id: string;
|
|
||||||
commonName: string;
|
|
||||||
endpointUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TaggedList<T extends object> {
|
|
||||||
items: Array<T & SourceTag>;
|
|
||||||
partial: boolean;
|
|
||||||
truncated: boolean;
|
|
||||||
nextCursor?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class QuerySourceService {
|
|
||||||
private readonly logger = new Logger(QuerySourceService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@Inject(DB) private readonly db: Db,
|
|
||||||
@Inject(FederationClientService) private readonly federationClient: FederationClientService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async list<T extends object>(
|
|
||||||
options: QuerySourceListOptions<T>,
|
|
||||||
): Promise<QuerySourceListResponse<T>> {
|
|
||||||
const request = options.request ?? {};
|
|
||||||
|
|
||||||
if (options.source === 'local') {
|
|
||||||
const local = await this.runLocal(options.local);
|
|
||||||
return this.toResponse(this.tagList(local, SOURCE_LOCAL));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.source === 'all') {
|
|
||||||
return this.listAll(options.resource, request, options.local);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.source.startsWith('federated:')) {
|
|
||||||
const host = options.source.slice('federated:'.length).trim();
|
|
||||||
if (!host) {
|
|
||||||
throw new QuerySourceError({
|
|
||||||
code: 'INVALID_SOURCE',
|
|
||||||
message: 'Federated source must include a host after federated:',
|
|
||||||
source: options.source,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const peer = await this.findPeerByHost(host, options.source);
|
|
||||||
const remote = await this.federationClient.list<T>(peer.id, options.resource, request);
|
|
||||||
return this.toResponse(this.tagList(remote, peer.commonName));
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new QuerySourceError({
|
|
||||||
code: 'INVALID_SOURCE',
|
|
||||||
message: `Unsupported query source: ${options.source}`,
|
|
||||||
source: options.source,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listAll<T extends object>(
|
|
||||||
resource: string,
|
|
||||||
request: Record<string, unknown>,
|
|
||||||
local: LocalListExecutor<T>,
|
|
||||||
): Promise<QuerySourceListResponse<T>> {
|
|
||||||
const peers = await this.listActiveOutboundPeers();
|
|
||||||
|
|
||||||
const localPromise = this.runLocal(local).then((response) =>
|
|
||||||
this.tagList(response, SOURCE_LOCAL),
|
|
||||||
);
|
|
||||||
const remotePromises = peers.map(async (peer: OutboundPeer): Promise<TaggedList<T> | null> => {
|
|
||||||
try {
|
|
||||||
const response = await this.federationClient.list<T>(peer.id, resource, request);
|
|
||||||
return this.tagList(response, peer.commonName);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Federated query to peer ${peer.commonName} (${peer.id}) failed; returning partial all-source response: ${
|
|
||||||
error instanceof Error ? error.message : String(error)
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const [localResult, ...remoteResults] = await Promise.all([localPromise, ...remotePromises]);
|
|
||||||
const successfulRemoteResults = remoteResults.filter(
|
|
||||||
(result: TaggedList<T> | null): result is TaggedList<T> => result !== null,
|
|
||||||
);
|
|
||||||
const allResults = [localResult, ...successfulRemoteResults];
|
|
||||||
const peerFailure = successfulRemoteResults.length !== peers.length;
|
|
||||||
|
|
||||||
return this.mergeTaggedLists(allResults, peerFailure);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async runLocal<T extends object>(
|
|
||||||
local: LocalListExecutor<T>,
|
|
||||||
): Promise<FederationListResponse<T>> {
|
|
||||||
const response = await local();
|
|
||||||
if (Array.isArray(response)) {
|
|
||||||
return { items: response };
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
private tagList<T extends object>(
|
|
||||||
response: FederationListResponse<T>,
|
|
||||||
source: string,
|
|
||||||
): TaggedList<T> {
|
|
||||||
return {
|
|
||||||
items: tagWithSource(response.items, source),
|
|
||||||
partial: response._partial === true,
|
|
||||||
truncated: response._truncated === true || response.nextCursor !== undefined,
|
|
||||||
nextCursor: response.nextCursor,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private mergeTaggedLists<T extends object>(
|
|
||||||
lists: Array<TaggedList<T>>,
|
|
||||||
peerFailure: boolean,
|
|
||||||
): QuerySourceListResponse<T> {
|
|
||||||
const items = lists.flatMap((list: TaggedList<T>) => list.items);
|
|
||||||
const partial =
|
|
||||||
peerFailure ||
|
|
||||||
lists.some((list: TaggedList<T>) => list.partial || list.nextCursor !== undefined);
|
|
||||||
const truncated = lists.some((list: TaggedList<T>) => list.truncated);
|
|
||||||
|
|
||||||
const response: QuerySourceListResponse<T> = { items };
|
|
||||||
if (partial) {
|
|
||||||
response._partial = true;
|
|
||||||
}
|
|
||||||
if (truncated) {
|
|
||||||
response._truncated = true;
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
private toResponse<T extends object>(tagged: TaggedList<T>): QuerySourceListResponse<T> {
|
|
||||||
const response: QuerySourceListResponse<T> = {
|
|
||||||
items: tagged.items,
|
|
||||||
};
|
|
||||||
if (tagged.nextCursor !== undefined) {
|
|
||||||
response.nextCursor = tagged.nextCursor;
|
|
||||||
}
|
|
||||||
if (tagged.partial) {
|
|
||||||
response._partial = true;
|
|
||||||
}
|
|
||||||
if (tagged.truncated) {
|
|
||||||
response._truncated = true;
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async findPeerByHost(sourceHost: string, source: string): Promise<OutboundPeer> {
|
|
||||||
const host = normalizeHost(sourceHost);
|
|
||||||
const peers = await this.listActiveOutboundPeers();
|
|
||||||
const peer = peers.find((candidate: OutboundPeer) => {
|
|
||||||
const commonName = normalizeHost(candidate.commonName);
|
|
||||||
const endpointHosts = endpointHostKeys(candidate.endpointUrl).map((endpointHost: string) =>
|
|
||||||
normalizeHost(endpointHost),
|
|
||||||
);
|
|
||||||
return commonName === host || endpointHosts.includes(host);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!peer) {
|
|
||||||
throw new QuerySourceError({
|
|
||||||
code: 'PEER_NOT_FOUND',
|
|
||||||
message: `No active outbound federation peer matches source ${source}`,
|
|
||||||
source,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return peer;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listActiveOutboundPeers(): Promise<OutboundPeer[]> {
|
|
||||||
const rows = await this.db
|
|
||||||
.select({
|
|
||||||
id: federationPeers.id,
|
|
||||||
commonName: federationPeers.commonName,
|
|
||||||
endpointUrl: federationPeers.endpointUrl,
|
|
||||||
})
|
|
||||||
.from(federationPeers)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(federationPeers.state, 'active'),
|
|
||||||
isNotNull(federationPeers.endpointUrl),
|
|
||||||
isNotNull(federationPeers.clientKeyPem),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.orderBy(federationPeers.commonName);
|
|
||||||
|
|
||||||
return rows.filter((row): row is OutboundPeer => typeof row.endpointUrl === 'string');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeHost(host: string): string {
|
|
||||||
return host.trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function endpointHostKeys(endpointUrl: string): string[] {
|
|
||||||
try {
|
|
||||||
const url = new URL(endpointUrl);
|
|
||||||
return Array.from(new Set([url.host, url.hostname].filter((host: string) => host.length > 0)));
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,35 +4,26 @@ import { CaService } from './ca.service.js';
|
|||||||
import { EnrollmentController } from './enrollment.controller.js';
|
import { EnrollmentController } from './enrollment.controller.js';
|
||||||
import { EnrollmentService } from './enrollment.service.js';
|
import { EnrollmentService } from './enrollment.service.js';
|
||||||
import { FederationController } from './federation.controller.js';
|
import { FederationController } from './federation.controller.js';
|
||||||
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
|
|
||||||
import { GrantsService } from './grants.service.js';
|
import { GrantsService } from './grants.service.js';
|
||||||
import { FederationClientService, QuerySourceService } from './client/index.js';
|
import { FederationClientService } from './client/index.js';
|
||||||
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
|
import { FederationAuthGuard } from './server/index.js';
|
||||||
import { ListController } from './server/verbs/list.controller.js';
|
|
||||||
import { FederationListQueryService } from './server/verbs/list-query.service.js';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
|
controllers: [EnrollmentController, FederationController],
|
||||||
providers: [
|
providers: [
|
||||||
AdminGuard,
|
AdminGuard,
|
||||||
CaService,
|
CaService,
|
||||||
EnrollmentService,
|
EnrollmentService,
|
||||||
GrantsService,
|
GrantsService,
|
||||||
FederationClientService,
|
FederationClientService,
|
||||||
QuerySourceService,
|
|
||||||
FederationAuthGuard,
|
FederationAuthGuard,
|
||||||
FederationScopeService,
|
|
||||||
FederationListQueryService,
|
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
CaService,
|
CaService,
|
||||||
EnrollmentService,
|
EnrollmentService,
|
||||||
GrantsService,
|
GrantsService,
|
||||||
FederationClientService,
|
FederationClientService,
|
||||||
QuerySourceService,
|
|
||||||
FederationAuthGuard,
|
FederationAuthGuard,
|
||||||
FederationScopeService,
|
|
||||||
FederationListQueryService,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class FederationModule {}
|
export class FederationModule {}
|
||||||
|
|||||||
@@ -1,324 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests for FederationScopeService (FED-M3-04).
|
|
||||||
*
|
|
||||||
* Coverage:
|
|
||||||
* - resource allowlist deny
|
|
||||||
* - excluded resource deny
|
|
||||||
* - invalid scope deny
|
|
||||||
* - invalid requested limit deny
|
|
||||||
* - native RBAC deny as subjectUserId
|
|
||||||
* - scope/native filter intersection for personal and team rows
|
|
||||||
* - native RBAC personal deny wins over scope include_personal allow/default
|
|
||||||
* - max_rows_per_query cap
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { FederationScopeService, type FederationNativeRbacEvaluator } from '../scope.service.js';
|
|
||||||
import type { FederationContext } from '../federation-context.js';
|
|
||||||
|
|
||||||
const GRANT_ID = 'grant-1';
|
|
||||||
const PEER_ID = 'peer-1';
|
|
||||||
const SUBJECT_USER_ID = 'user-1';
|
|
||||||
|
|
||||||
function makeContext(scope: Record<string, unknown>): FederationContext {
|
|
||||||
return {
|
|
||||||
grantId: GRANT_ID,
|
|
||||||
peerId: PEER_ID,
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
scope,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeNativeRbac(
|
|
||||||
result: Awaited<ReturnType<FederationNativeRbacEvaluator['evaluateReadAccess']>>,
|
|
||||||
): FederationNativeRbacEvaluator {
|
|
||||||
return {
|
|
||||||
evaluateReadAccess: vi.fn().mockResolvedValue(result),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('FederationScopeService', () => {
|
|
||||||
let service: FederationScopeService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
service = new FederationScopeService();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows a granted resource and returns a capped query filter', async () => {
|
|
||||||
const nativeRbac = makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({
|
|
||||||
resources: ['tasks'],
|
|
||||||
filters: { tasks: { include_teams: ['team-1', 'team-3'], include_personal: true } },
|
|
||||||
max_rows_per_query: 50,
|
|
||||||
}),
|
|
||||||
resource: 'tasks',
|
|
||||||
requestedLimit: 500,
|
|
||||||
nativeRbac,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
allowed: true,
|
|
||||||
filter: {
|
|
||||||
resource: 'tasks',
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
includePersonal: true,
|
|
||||||
teamIds: ['team-1'],
|
|
||||||
limit: 50,
|
|
||||||
maxRowsPerQuery: 50,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(nativeRbac.evaluateReadAccess).toHaveBeenCalledWith({
|
|
||||||
grantId: GRANT_ID,
|
|
||||||
peerId: PEER_ID,
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
resource: 'tasks',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults absent resource filters to native RBAC personal and team visibility', async () => {
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({ resources: ['notes'], max_rows_per_query: 100 }),
|
|
||||||
resource: 'notes',
|
|
||||||
nativeRbac: makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
allowed: true,
|
|
||||||
filter: {
|
|
||||||
includePersonal: true,
|
|
||||||
teamIds: ['team-1', 'team-2'],
|
|
||||||
limit: 100,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('honors include_personal false even when native RBAC allows personal rows', async () => {
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({
|
|
||||||
resources: ['memory'],
|
|
||||||
filters: { memory: { include_personal: false } },
|
|
||||||
max_rows_per_query: 25,
|
|
||||||
}),
|
|
||||||
resource: 'memory',
|
|
||||||
nativeRbac: makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: [] },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
allowed: true,
|
|
||||||
filter: {
|
|
||||||
includePersonal: false,
|
|
||||||
teamIds: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not leak personal rows when scope allows personal but native RBAC denies personal', async () => {
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({
|
|
||||||
resources: ['tasks'],
|
|
||||||
filters: { tasks: { include_personal: true } },
|
|
||||||
max_rows_per_query: 25,
|
|
||||||
}),
|
|
||||||
resource: 'tasks',
|
|
||||||
nativeRbac: makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: false, teamIds: ['team-1'] },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
allowed: true,
|
|
||||||
filter: {
|
|
||||||
includePersonal: false,
|
|
||||||
teamIds: ['team-1'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not widen native RBAC when scope includes teams the user cannot access', async () => {
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({
|
|
||||||
resources: ['tasks'],
|
|
||||||
filters: { tasks: { include_teams: ['team-2'], include_personal: false } },
|
|
||||||
max_rows_per_query: 25,
|
|
||||||
}),
|
|
||||||
resource: 'tasks',
|
|
||||||
nativeRbac: makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: ['team-1'] },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
allowed: true,
|
|
||||||
filter: {
|
|
||||||
includePersonal: false,
|
|
||||||
teamIds: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies invalid grant scope before RBAC evaluation', async () => {
|
|
||||||
const nativeRbac = makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: [] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({ resources: [], max_rows_per_query: 100 }),
|
|
||||||
resource: 'tasks',
|
|
||||||
nativeRbac,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
allowed: false,
|
|
||||||
deny: {
|
|
||||||
code: 'invalid_scope',
|
|
||||||
stage: 'scope_parse',
|
|
||||||
statusCode: 400,
|
|
||||||
grantId: GRANT_ID,
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
resource: 'tasks',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies unsupported resource names before RBAC evaluation', async () => {
|
|
||||||
const nativeRbac = makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: [] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
|
||||||
resource: 'unknown_resource',
|
|
||||||
nativeRbac,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
allowed: false,
|
|
||||||
deny: {
|
|
||||||
code: 'invalid_resource',
|
|
||||||
stage: 'resource_allowlist',
|
|
||||||
statusCode: 403,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies resources explicitly present in excluded_resources before allowlist miss', async () => {
|
|
||||||
const nativeRbac = makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: [] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({
|
|
||||||
resources: ['tasks'],
|
|
||||||
excluded_resources: ['credentials'],
|
|
||||||
max_rows_per_query: 100,
|
|
||||||
}),
|
|
||||||
resource: 'credentials',
|
|
||||||
nativeRbac,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
allowed: false,
|
|
||||||
deny: {
|
|
||||||
code: 'resource_excluded',
|
|
||||||
stage: 'resource_exclusion',
|
|
||||||
statusCode: 403,
|
|
||||||
resource: 'credentials',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies supported resources that are not granted by scope', async () => {
|
|
||||||
const nativeRbac = makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: [] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
|
||||||
resource: 'notes',
|
|
||||||
nativeRbac,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
allowed: false,
|
|
||||||
deny: {
|
|
||||||
code: 'resource_not_granted',
|
|
||||||
stage: 'resource_allowlist',
|
|
||||||
statusCode: 403,
|
|
||||||
resource: 'notes',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies invalid requested row limits before RBAC evaluation', async () => {
|
|
||||||
const nativeRbac = makeNativeRbac({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: [] },
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
|
||||||
resource: 'tasks',
|
|
||||||
requestedLimit: 0,
|
|
||||||
nativeRbac,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
allowed: false,
|
|
||||||
deny: {
|
|
||||||
code: 'invalid_limit',
|
|
||||||
stage: 'row_cap',
|
|
||||||
statusCode: 400,
|
|
||||||
details: { requestedLimit: 0 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies when native RBAC rejects subjectUserId access to the resource', async () => {
|
|
||||||
const result = await service.evaluateAccess({
|
|
||||||
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
|
|
||||||
resource: 'tasks',
|
|
||||||
nativeRbac: makeNativeRbac({
|
|
||||||
allowed: false,
|
|
||||||
reason: 'read:tasks denied',
|
|
||||||
details: { permission: 'tasks:read' },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
allowed: false,
|
|
||||||
deny: {
|
|
||||||
code: 'native_rbac_denied',
|
|
||||||
stage: 'native_rbac',
|
|
||||||
statusCode: 403,
|
|
||||||
message: 'read:tasks denied',
|
|
||||||
grantId: GRANT_ID,
|
|
||||||
peerId: PEER_ID,
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
resource: 'tasks',
|
|
||||||
details: { permission: 'tasks:read' },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -10,22 +10,4 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export { FederationAuthGuard } from './federation-auth.guard.js';
|
export { FederationAuthGuard } from './federation-auth.guard.js';
|
||||||
export { FederationScopeService } from './scope.service.js';
|
|
||||||
export type { FederationContext } from './federation-context.js';
|
export type { FederationContext } from './federation-context.js';
|
||||||
export type {
|
|
||||||
FederationNativeRbacAccess,
|
|
||||||
FederationNativeRbacAllowedResult,
|
|
||||||
FederationNativeRbacDeniedResult,
|
|
||||||
FederationNativeRbacEvaluator,
|
|
||||||
FederationNativeRbacRequest,
|
|
||||||
FederationNativeRbacResult,
|
|
||||||
FederationScopeAllowedResult,
|
|
||||||
FederationScopeDeniedResult,
|
|
||||||
FederationScopeDenyCode,
|
|
||||||
FederationScopeDenyDetails,
|
|
||||||
FederationScopeDenyReason,
|
|
||||||
FederationScopeDenyStage,
|
|
||||||
FederationScopeEvaluationInput,
|
|
||||||
FederationScopeEvaluationResult,
|
|
||||||
FederationScopeQueryFilter,
|
|
||||||
} from './scope.service.js';
|
|
||||||
|
|||||||
@@ -1,272 +0,0 @@
|
|||||||
/**
|
|
||||||
* FederationScopeService — M3 server-side scope enforcement pipeline.
|
|
||||||
*
|
|
||||||
* Pure trust-boundary service: it validates the grant scope, asks an injected
|
|
||||||
* native RBAC evaluator what the subject user can read locally, intersects that
|
|
||||||
* answer with the federation scope filters, and returns a query filter for the
|
|
||||||
* verb controllers. The service performs no DB calls directly.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
FEDERATION_RESOURCE_VALUES,
|
|
||||||
type FederationResource,
|
|
||||||
FederationScopeError,
|
|
||||||
parseFederationScope,
|
|
||||||
} from '../scope-schema.js';
|
|
||||||
import type { FederationContext } from './federation-context.js';
|
|
||||||
|
|
||||||
const federationResourceSet: ReadonlySet<string> = new Set<string>(FEDERATION_RESOURCE_VALUES);
|
|
||||||
|
|
||||||
export type FederationScopeDenyStage =
|
|
||||||
| 'scope_parse'
|
|
||||||
| 'resource_allowlist'
|
|
||||||
| 'resource_exclusion'
|
|
||||||
| 'native_rbac'
|
|
||||||
| 'row_cap';
|
|
||||||
|
|
||||||
export type FederationScopeDenyCode =
|
|
||||||
| 'invalid_scope'
|
|
||||||
| 'invalid_resource'
|
|
||||||
| 'resource_not_granted'
|
|
||||||
| 'resource_excluded'
|
|
||||||
| 'native_rbac_denied'
|
|
||||||
| 'invalid_limit';
|
|
||||||
|
|
||||||
export type FederationScopeDenyStatus = 400 | 403;
|
|
||||||
|
|
||||||
export interface FederationScopeDenyDetails {
|
|
||||||
readonly [key: string]: string | number | boolean | readonly string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationScopeDenyReason {
|
|
||||||
readonly code: FederationScopeDenyCode;
|
|
||||||
readonly stage: FederationScopeDenyStage;
|
|
||||||
readonly statusCode: FederationScopeDenyStatus;
|
|
||||||
readonly message: string;
|
|
||||||
readonly grantId: string;
|
|
||||||
readonly peerId: string;
|
|
||||||
readonly subjectUserId: string;
|
|
||||||
readonly resource: string;
|
|
||||||
readonly details?: FederationScopeDenyDetails;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationNativeRbacRequest {
|
|
||||||
readonly grantId: string;
|
|
||||||
readonly peerId: string;
|
|
||||||
readonly subjectUserId: string;
|
|
||||||
readonly resource: FederationResource;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationNativeRbacAccess {
|
|
||||||
/** Whether this user may read personal rows for this resource. */
|
|
||||||
readonly includePersonal: boolean;
|
|
||||||
|
|
||||||
/** Team IDs this user may read for this resource under native RBAC. */
|
|
||||||
readonly teamIds: readonly string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationNativeRbacAllowedResult {
|
|
||||||
readonly allowed: true;
|
|
||||||
readonly access: FederationNativeRbacAccess;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationNativeRbacDeniedResult {
|
|
||||||
readonly allowed: false;
|
|
||||||
readonly reason?: string;
|
|
||||||
readonly details?: FederationScopeDenyDetails;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FederationNativeRbacResult =
|
|
||||||
| FederationNativeRbacAllowedResult
|
|
||||||
| FederationNativeRbacDeniedResult;
|
|
||||||
|
|
||||||
export interface FederationNativeRbacEvaluator {
|
|
||||||
evaluateReadAccess(request: FederationNativeRbacRequest): Promise<FederationNativeRbacResult>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationScopeEvaluationInput {
|
|
||||||
readonly context: FederationContext;
|
|
||||||
readonly resource: string;
|
|
||||||
readonly requestedLimit?: number;
|
|
||||||
readonly nativeRbac: FederationNativeRbacEvaluator;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationScopeQueryFilter {
|
|
||||||
readonly resource: FederationResource;
|
|
||||||
readonly subjectUserId: string;
|
|
||||||
readonly includePersonal: boolean;
|
|
||||||
readonly teamIds: readonly string[];
|
|
||||||
readonly limit: number;
|
|
||||||
readonly maxRowsPerQuery: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationScopeAllowedResult {
|
|
||||||
readonly allowed: true;
|
|
||||||
readonly filter: FederationScopeQueryFilter;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationScopeDeniedResult {
|
|
||||||
readonly allowed: false;
|
|
||||||
readonly deny: FederationScopeDenyReason;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FederationScopeEvaluationResult =
|
|
||||||
| FederationScopeAllowedResult
|
|
||||||
| FederationScopeDeniedResult;
|
|
||||||
|
|
||||||
function isFederationResource(resource: string): resource is FederationResource {
|
|
||||||
return federationResourceSet.has(resource);
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqueStrings(values: readonly string[]): readonly string[] {
|
|
||||||
return Array.from(new Set<string>(values));
|
|
||||||
}
|
|
||||||
|
|
||||||
function intersectTeamIds(
|
|
||||||
nativeTeamIds: readonly string[],
|
|
||||||
scopedTeamIds: readonly string[] | undefined,
|
|
||||||
): readonly string[] {
|
|
||||||
const uniqueNativeTeamIds = uniqueStrings(nativeTeamIds);
|
|
||||||
|
|
||||||
if (scopedTeamIds === undefined) {
|
|
||||||
return uniqueNativeTeamIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nativeSet = new Set<string>(uniqueNativeTeamIds);
|
|
||||||
return uniqueStrings(scopedTeamIds).filter((teamId: string): boolean => nativeSet.has(teamId));
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDenyReason(params: {
|
|
||||||
readonly code: FederationScopeDenyCode;
|
|
||||||
readonly stage: FederationScopeDenyStage;
|
|
||||||
readonly statusCode?: FederationScopeDenyStatus;
|
|
||||||
readonly message: string;
|
|
||||||
readonly context: FederationContext;
|
|
||||||
readonly resource: string;
|
|
||||||
readonly details?: FederationScopeDenyDetails;
|
|
||||||
}): FederationScopeDeniedResult {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
deny: {
|
|
||||||
code: params.code,
|
|
||||||
stage: params.stage,
|
|
||||||
statusCode: params.statusCode ?? 403,
|
|
||||||
message: params.message,
|
|
||||||
grantId: params.context.grantId,
|
|
||||||
peerId: params.context.peerId,
|
|
||||||
subjectUserId: params.context.subjectUserId,
|
|
||||||
resource: params.resource,
|
|
||||||
...(params.details !== undefined ? { details: params.details } : {}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class FederationScopeService {
|
|
||||||
async evaluateAccess(
|
|
||||||
input: FederationScopeEvaluationInput,
|
|
||||||
): Promise<FederationScopeEvaluationResult> {
|
|
||||||
const { context, resource, requestedLimit, nativeRbac } = input;
|
|
||||||
|
|
||||||
let scope: ReturnType<typeof parseFederationScope>;
|
|
||||||
try {
|
|
||||||
scope = parseFederationScope(context.scope);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const message =
|
|
||||||
error instanceof FederationScopeError
|
|
||||||
? 'Federation grant scope is invalid'
|
|
||||||
: 'Federation grant scope could not be parsed';
|
|
||||||
const details = error instanceof Error ? { reason: error.message } : undefined;
|
|
||||||
return makeDenyReason({
|
|
||||||
code: 'invalid_scope',
|
|
||||||
stage: 'scope_parse',
|
|
||||||
statusCode: 400,
|
|
||||||
message,
|
|
||||||
context,
|
|
||||||
resource,
|
|
||||||
...(details !== undefined ? { details } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isFederationResource(resource)) {
|
|
||||||
return makeDenyReason({
|
|
||||||
code: 'invalid_resource',
|
|
||||||
stage: 'resource_allowlist',
|
|
||||||
message: 'Requested federation resource is not supported',
|
|
||||||
context,
|
|
||||||
resource,
|
|
||||||
details: { supportedResources: FEDERATION_RESOURCE_VALUES },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scope.excluded_resources.includes(resource)) {
|
|
||||||
return makeDenyReason({
|
|
||||||
code: 'resource_excluded',
|
|
||||||
stage: 'resource_exclusion',
|
|
||||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
|
||||||
context,
|
|
||||||
resource,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!scope.resources.includes(resource)) {
|
|
||||||
return makeDenyReason({
|
|
||||||
code: 'resource_not_granted',
|
|
||||||
stage: 'resource_allowlist',
|
|
||||||
message: 'Requested federation resource is not granted by scope',
|
|
||||||
context,
|
|
||||||
resource,
|
|
||||||
details: { grantedResources: scope.resources },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requestedLimit !== undefined && (!Number.isInteger(requestedLimit) || requestedLimit < 1)) {
|
|
||||||
return makeDenyReason({
|
|
||||||
code: 'invalid_limit',
|
|
||||||
stage: 'row_cap',
|
|
||||||
statusCode: 400,
|
|
||||||
message: 'Requested row limit must be a positive integer',
|
|
||||||
context,
|
|
||||||
resource,
|
|
||||||
details: { requestedLimit },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const nativeResult = await nativeRbac.evaluateReadAccess({
|
|
||||||
grantId: context.grantId,
|
|
||||||
peerId: context.peerId,
|
|
||||||
subjectUserId: context.subjectUserId,
|
|
||||||
resource,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!nativeResult.allowed) {
|
|
||||||
return makeDenyReason({
|
|
||||||
code: 'native_rbac_denied',
|
|
||||||
stage: 'native_rbac',
|
|
||||||
message: nativeResult.reason ?? 'Subject user is not allowed to read this resource',
|
|
||||||
context,
|
|
||||||
resource,
|
|
||||||
...(nativeResult.details !== undefined ? { details: nativeResult.details } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const scopeFilter = scope.filters?.[resource];
|
|
||||||
const includePersonal =
|
|
||||||
Boolean(scopeFilter?.include_personal ?? true) && nativeResult.access.includePersonal;
|
|
||||||
const teamIds = intersectTeamIds(nativeResult.access.teamIds, scopeFilter?.include_teams);
|
|
||||||
const limit = Math.min(requestedLimit ?? scope.max_rows_per_query, scope.max_rows_per_query);
|
|
||||||
|
|
||||||
return {
|
|
||||||
allowed: true,
|
|
||||||
filter: {
|
|
||||||
resource,
|
|
||||||
subjectUserId: context.subjectUserId,
|
|
||||||
includePersonal,
|
|
||||||
teamIds,
|
|
||||||
limit,
|
|
||||||
maxRowsPerQuery: scope.max_rows_per_query,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { RequestMethod } from '@nestjs/common';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import type { FastifyRequest } from 'fastify';
|
|
||||||
import { FederationCapabilitiesResponseSchema, FEDERATION_VERBS } from '@mosaicstack/types';
|
|
||||||
import { FederationScopeError } from '../../../scope-schema.js';
|
|
||||||
import { FederationAuthGuard } from '../../federation-auth.guard.js';
|
|
||||||
import { CapabilitiesController } from '../capabilities.controller.js';
|
|
||||||
|
|
||||||
const VALID_SCOPE = {
|
|
||||||
resources: ['tasks', 'notes'],
|
|
||||||
excluded_resources: ['credentials'],
|
|
||||||
max_rows_per_query: 250,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const DEFAULTED_SCOPE = {
|
|
||||||
resources: ['memory'],
|
|
||||||
max_rows_per_query: 10,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function makeRequest(scope: Record<string, unknown>): FastifyRequest {
|
|
||||||
return {
|
|
||||||
federationContext: {
|
|
||||||
grantId: 'grant-1',
|
|
||||||
peerId: 'peer-1',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
scope,
|
|
||||||
},
|
|
||||||
} as FastifyRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('CapabilitiesController', () => {
|
|
||||||
it('declares GET /api/federation/v1/capabilities', () => {
|
|
||||||
expect(Reflect.getMetadata('path', CapabilitiesController)).toBe(
|
|
||||||
'api/federation/v1/capabilities',
|
|
||||||
);
|
|
||||||
expect(Reflect.getMetadata('path', CapabilitiesController.prototype.getCapabilities)).toBe('/');
|
|
||||||
expect(Reflect.getMetadata('method', CapabilitiesController.prototype.getCapabilities)).toBe(
|
|
||||||
RequestMethod.GET,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is protected only by FederationAuthGuard', () => {
|
|
||||||
const guards = Reflect.getMetadata('__guards__', CapabilitiesController) as unknown[];
|
|
||||||
|
|
||||||
expect(guards).toEqual([FederationAuthGuard]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns resources, excluded resources, max rows, and M3 supported verbs from the active grant scope', () => {
|
|
||||||
const controller = new CapabilitiesController();
|
|
||||||
|
|
||||||
const response = controller.getCapabilities(makeRequest(VALID_SCOPE));
|
|
||||||
|
|
||||||
expect(response).toEqual({
|
|
||||||
resources: ['tasks', 'notes'],
|
|
||||||
excluded_resources: ['credentials'],
|
|
||||||
max_rows_per_query: 250,
|
|
||||||
supported_verbs: [...FEDERATION_VERBS],
|
|
||||||
});
|
|
||||||
expect(FederationCapabilitiesResponseSchema.safeParse(response).success).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies scope defaults without RBAC or resource filtering', () => {
|
|
||||||
const controller = new CapabilitiesController();
|
|
||||||
|
|
||||||
const response = controller.getCapabilities(makeRequest(DEFAULTED_SCOPE));
|
|
||||||
|
|
||||||
expect(response).toEqual({
|
|
||||||
resources: ['memory'],
|
|
||||||
excluded_resources: [],
|
|
||||||
max_rows_per_query: 10,
|
|
||||||
supported_verbs: ['list', 'get', 'capabilities'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects invalid scope state instead of returning an invalid capabilities contract', () => {
|
|
||||||
const controller = new CapabilitiesController();
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
controller.getCapabilities(
|
|
||||||
makeRequest({
|
|
||||||
resources: [],
|
|
||||||
max_rows_per_query: 0,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
).toThrow(FederationScopeError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,428 +0,0 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
import {
|
|
||||||
createPgliteDb,
|
|
||||||
insights,
|
|
||||||
missionTasks,
|
|
||||||
missions,
|
|
||||||
preferences,
|
|
||||||
projects,
|
|
||||||
runPgliteMigrations,
|
|
||||||
teams,
|
|
||||||
users,
|
|
||||||
type Db,
|
|
||||||
type DbHandle,
|
|
||||||
} from '@mosaicstack/db';
|
|
||||||
import type { FederationScopeQueryFilter } from '../../scope.service.js';
|
|
||||||
import { FederationListQueryService } from '../list-query.service.js';
|
|
||||||
|
|
||||||
const TASK_FILTER: FederationScopeQueryFilter = {
|
|
||||||
resource: 'tasks',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
includePersonal: true,
|
|
||||||
teamIds: [],
|
|
||||||
limit: 2,
|
|
||||||
maxRowsPerQuery: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const SUBJECT_USER_ID = 'fed-m3-05-subject';
|
|
||||||
const OTHER_USER_ID = 'fed-m3-05-other';
|
|
||||||
const TEAM_ID = '05000000-0000-4000-8000-000000000001';
|
|
||||||
const UNAUTHORIZED_TEAM_ID = '05000000-0000-4000-8000-000000000002';
|
|
||||||
const PERSONAL_PROJECT_ID = '05000000-0000-4000-8000-000000000101';
|
|
||||||
const TEAM_PROJECT_ID = '05000000-0000-4000-8000-000000000102';
|
|
||||||
const UNAUTHORIZED_PROJECT_ID = '05000000-0000-4000-8000-000000000103';
|
|
||||||
const PERSONAL_MISSION_ID = '05000000-0000-4000-8000-000000000201';
|
|
||||||
const TEAM_MISSION_ID = '05000000-0000-4000-8000-000000000202';
|
|
||||||
const UNAUTHORIZED_MISSION_ID = '05000000-0000-4000-8000-000000000203';
|
|
||||||
const SUBJECT_TEAM_NOTE_ID = '05000000-0000-4000-8000-000000000301';
|
|
||||||
const OTHER_TEAM_NOTE_ID = '05000000-0000-4000-8000-000000000302';
|
|
||||||
const SUBJECT_PERSONAL_NOTE_ID = '05000000-0000-4000-8000-000000000303';
|
|
||||||
const SUBJECT_UNAUTHORIZED_NOTE_ID = '05000000-0000-4000-8000-000000000304';
|
|
||||||
const INSIGHT_ONE_ID = '05000000-0000-4000-8000-000000000401';
|
|
||||||
const INSIGHT_TWO_ID = '05000000-0000-4000-8000-000000000402';
|
|
||||||
const PREFERENCE_ONE_ID = '05000000-0000-4000-8000-000000000501';
|
|
||||||
const PREFERENCE_TWO_ID = '05000000-0000-4000-8000-000000000502';
|
|
||||||
|
|
||||||
let dbHandle: DbHandle | undefined;
|
|
||||||
|
|
||||||
function makeService() {
|
|
||||||
return new FederationListQueryService({} as Db);
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDbService() {
|
|
||||||
if (!dbHandle) {
|
|
||||||
throw new Error('test DB not initialized');
|
|
||||||
}
|
|
||||||
return new FederationListQueryService(dbHandle.db);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedNotesFixture() {
|
|
||||||
if (!dbHandle) {
|
|
||||||
throw new Error('test DB not initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
await dbHandle.db.insert(users).values([
|
|
||||||
{
|
|
||||||
id: SUBJECT_USER_ID,
|
|
||||||
name: 'Federation Subject',
|
|
||||||
email: `${SUBJECT_USER_ID}@example.test`,
|
|
||||||
emailVerified: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: OTHER_USER_ID,
|
|
||||||
name: 'Federation Other',
|
|
||||||
email: `${OTHER_USER_ID}@example.test`,
|
|
||||||
emailVerified: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await dbHandle.db.insert(teams).values([
|
|
||||||
{
|
|
||||||
id: TEAM_ID,
|
|
||||||
name: 'FED-M3-05 Team',
|
|
||||||
slug: 'fed-m3-05-team',
|
|
||||||
ownerId: SUBJECT_USER_ID,
|
|
||||||
managerId: SUBJECT_USER_ID,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: UNAUTHORIZED_TEAM_ID,
|
|
||||||
name: 'FED-M3-05 Unauthorized Team',
|
|
||||||
slug: 'fed-m3-05-unauthorized-team',
|
|
||||||
ownerId: OTHER_USER_ID,
|
|
||||||
managerId: OTHER_USER_ID,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await dbHandle.db.insert(projects).values([
|
|
||||||
{
|
|
||||||
id: PERSONAL_PROJECT_ID,
|
|
||||||
name: 'FED-M3-05 Personal Project',
|
|
||||||
ownerId: SUBJECT_USER_ID,
|
|
||||||
ownerType: 'user',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: TEAM_PROJECT_ID,
|
|
||||||
name: 'FED-M3-05 Team Project',
|
|
||||||
teamId: TEAM_ID,
|
|
||||||
ownerType: 'team',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: UNAUTHORIZED_PROJECT_ID,
|
|
||||||
name: 'FED-M3-05 Unauthorized Project',
|
|
||||||
teamId: UNAUTHORIZED_TEAM_ID,
|
|
||||||
ownerType: 'team',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await dbHandle.db.insert(missions).values([
|
|
||||||
{
|
|
||||||
id: PERSONAL_MISSION_ID,
|
|
||||||
name: 'FED-M3-05 Personal Mission',
|
|
||||||
projectId: PERSONAL_PROJECT_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: TEAM_MISSION_ID,
|
|
||||||
name: 'FED-M3-05 Team Mission',
|
|
||||||
projectId: TEAM_PROJECT_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: UNAUTHORIZED_MISSION_ID,
|
|
||||||
name: 'FED-M3-05 Unauthorized Mission',
|
|
||||||
projectId: UNAUTHORIZED_PROJECT_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await dbHandle.db.insert(missionTasks).values([
|
|
||||||
{
|
|
||||||
id: SUBJECT_TEAM_NOTE_ID,
|
|
||||||
missionId: TEAM_MISSION_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
notes: 'subject note on team mission',
|
|
||||||
createdAt: new Date('2026-06-24T03:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-24T03:00:00.000Z'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: OTHER_TEAM_NOTE_ID,
|
|
||||||
missionId: TEAM_MISSION_ID,
|
|
||||||
userId: OTHER_USER_ID,
|
|
||||||
notes: 'other user note on team mission',
|
|
||||||
createdAt: new Date('2026-06-24T02:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-24T02:00:00.000Z'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: SUBJECT_PERSONAL_NOTE_ID,
|
|
||||||
missionId: PERSONAL_MISSION_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
notes: 'subject note on personal mission',
|
|
||||||
createdAt: new Date('2026-06-24T01:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-24T01:00:00.000Z'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
|
|
||||||
missionId: UNAUTHORIZED_MISSION_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
notes: 'subject note outside grant-visible missions',
|
|
||||||
createdAt: new Date('2026-06-24T04:00:00.000Z'),
|
|
||||||
updatedAt: new Date('2026-06-24T04:00:00.000Z'),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const memoryCreatedAt = new Date('2026-06-24T05:00:00.000Z');
|
|
||||||
await dbHandle.db.insert(insights).values([
|
|
||||||
{
|
|
||||||
id: INSIGHT_ONE_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
content: 'first insight',
|
|
||||||
source: 'agent',
|
|
||||||
createdAt: memoryCreatedAt,
|
|
||||||
updatedAt: memoryCreatedAt,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: INSIGHT_TWO_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
content: 'second insight',
|
|
||||||
source: 'agent',
|
|
||||||
createdAt: memoryCreatedAt,
|
|
||||||
updatedAt: memoryCreatedAt,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await dbHandle.db.insert(preferences).values([
|
|
||||||
{
|
|
||||||
id: PREFERENCE_ONE_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
key: 'fed-m3-05-pref-1',
|
|
||||||
value: { enabled: true },
|
|
||||||
createdAt: memoryCreatedAt,
|
|
||||||
updatedAt: memoryCreatedAt,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: PREFERENCE_TWO_ID,
|
|
||||||
userId: SUBJECT_USER_ID,
|
|
||||||
key: 'fed-m3-05-pref-2',
|
|
||||||
value: { enabled: false },
|
|
||||||
createdAt: memoryCreatedAt,
|
|
||||||
updatedAt: memoryCreatedAt,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stubRows(
|
|
||||||
service: FederationListQueryService,
|
|
||||||
...pages: Array<Array<Record<string, unknown>>>
|
|
||||||
) {
|
|
||||||
const mock = vi.fn();
|
|
||||||
for (const page of pages) {
|
|
||||||
mock.mockResolvedValueOnce(page);
|
|
||||||
}
|
|
||||||
(
|
|
||||||
service as unknown as {
|
|
||||||
listAllRows: (
|
|
||||||
_filter: FederationScopeQueryFilter,
|
|
||||||
_rowLimit: number,
|
|
||||||
_cursor: unknown,
|
|
||||||
) => Promise<Array<Record<string, unknown>>>;
|
|
||||||
}
|
|
||||||
).listAllRows = mock;
|
|
||||||
return mock;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('FederationListQueryService', () => {
|
|
||||||
beforeAll(async () => {
|
|
||||||
dbHandle = createPgliteDb(`memory://fed-m3-05-list-${Date.now()}`);
|
|
||||||
await runPgliteMigrations(dbHandle);
|
|
||||||
await seedNotesFixture();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await dbHandle?.close();
|
|
||||||
dbHandle = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('denies sensitive resources in native RBAC for M3 list reads', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.evaluateReadAccess({
|
|
||||||
grantId: 'grant-1',
|
|
||||||
peerId: 'peer-1',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
resource: 'credentials',
|
|
||||||
}),
|
|
||||||
).resolves.toMatchObject({
|
|
||||||
allowed: false,
|
|
||||||
reason: 'credentials federation list access is not implemented in M3',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows personal memory reads without requiring team lookup', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.evaluateReadAccess({
|
|
||||||
grantId: 'grant-1',
|
|
||||||
peerId: 'peer-1',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
resource: 'memory',
|
|
||||||
}),
|
|
||||||
).resolves.toEqual({
|
|
||||||
allowed: true,
|
|
||||||
access: { includePersonal: true, teamIds: [] },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies the scope row cap and returns an opaque next cursor when truncated', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
const listAllRows = stubRows(
|
|
||||||
service,
|
|
||||||
[
|
|
||||||
{ id: '3', createdAt: new Date('2026-06-24T03:00:00.000Z') },
|
|
||||||
{ id: '2', createdAt: new Date('2026-06-24T02:00:00.000Z') },
|
|
||||||
{ id: '1', createdAt: new Date('2026-06-24T01:00:00.000Z') },
|
|
||||||
],
|
|
||||||
[{ id: '1', createdAt: new Date('2026-06-24T01:00:00.000Z') }],
|
|
||||||
);
|
|
||||||
|
|
||||||
const firstPage = await service.list({ filter: TASK_FILTER });
|
|
||||||
|
|
||||||
expect(firstPage).toEqual({
|
|
||||||
items: [
|
|
||||||
{ id: '3', createdAt: new Date('2026-06-24T03:00:00.000Z') },
|
|
||||||
{ id: '2', createdAt: new Date('2026-06-24T02:00:00.000Z') },
|
|
||||||
],
|
|
||||||
truncated: true,
|
|
||||||
nextCursor: expect.any(String),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(listAllRows).toHaveBeenNthCalledWith(1, TASK_FILTER, 3, undefined);
|
|
||||||
|
|
||||||
const secondPage = await service.list({ filter: TASK_FILTER, cursor: firstPage.nextCursor });
|
|
||||||
expect(secondPage).toEqual({
|
|
||||||
items: [{ id: '1', createdAt: new Date('2026-06-24T01:00:00.000Z') }],
|
|
||||||
truncated: false,
|
|
||||||
});
|
|
||||||
expect(listAllRows).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
TASK_FILTER,
|
|
||||||
3,
|
|
||||||
expect.objectContaining({ id: '2' }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects invalid cursors instead of falling back to the first page', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
stubRows(service, [{ id: '1' }]);
|
|
||||||
|
|
||||||
await expect(service.list({ filter: TASK_FILTER, cursor: 'not-base64-json' })).rejects.toThrow(
|
|
||||||
'Invalid federation list cursor',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws when a truncated page cannot encode a resumable cursor', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
stubRows(service, [
|
|
||||||
{ id: '2', createdAt: 'not-a-date' },
|
|
||||||
{ id: '1', createdAt: 'not-a-date' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
await expect(service.list({ filter: { ...TASK_FILTER, limit: 1 } })).rejects.toThrow(
|
|
||||||
'Federation list cursor cannot be encoded',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws on unsupported resources instead of crashing pagination', async () => {
|
|
||||||
const service = makeService();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.list({
|
|
||||||
filter: {
|
|
||||||
...TASK_FILTER,
|
|
||||||
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).rejects.toThrow('Unsupported federation list resource');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not leak another user mission task notes through team-scoped note reads', async () => {
|
|
||||||
const service = makeDbService();
|
|
||||||
|
|
||||||
const result = await service.list({
|
|
||||||
filter: {
|
|
||||||
resource: 'notes',
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
includePersonal: false,
|
|
||||||
teamIds: [TEAM_ID],
|
|
||||||
limit: 10,
|
|
||||||
maxRowsPerQuery: 10,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const ids = result.items.map((item) => item['id']);
|
|
||||||
expect(ids).toEqual([SUBJECT_TEAM_NOTE_ID]);
|
|
||||||
expect(ids).not.toContain(OTHER_TEAM_NOTE_ID);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not return subject personal mission task notes when includePersonal is false', async () => {
|
|
||||||
const service = makeDbService();
|
|
||||||
|
|
||||||
const result = await service.list({
|
|
||||||
filter: {
|
|
||||||
resource: 'notes',
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
includePersonal: false,
|
|
||||||
teamIds: [TEAM_ID],
|
|
||||||
limit: 10,
|
|
||||||
maxRowsPerQuery: 10,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.items.map((item) => item['id'])).not.toContain(SUBJECT_PERSONAL_NOTE_ID);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not return subject notes from missions outside the grant-visible project set', async () => {
|
|
||||||
const service = makeDbService();
|
|
||||||
|
|
||||||
const result = await service.list({
|
|
||||||
filter: {
|
|
||||||
resource: 'notes',
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
includePersonal: true,
|
|
||||||
teamIds: [TEAM_ID],
|
|
||||||
limit: 10,
|
|
||||||
maxRowsPerQuery: 10,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const ids = result.items.map((item) => item['id']);
|
|
||||||
expect(ids).toContain(SUBJECT_PERSONAL_NOTE_ID);
|
|
||||||
expect(ids).toContain(SUBJECT_TEAM_NOTE_ID);
|
|
||||||
expect(ids).not.toContain(SUBJECT_UNAUTHORIZED_NOTE_ID);
|
|
||||||
expect(ids).not.toContain(OTHER_TEAM_NOTE_ID);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('paginates memory deterministically across insights and preferences', async () => {
|
|
||||||
const service = makeDbService();
|
|
||||||
const filter: FederationScopeQueryFilter = {
|
|
||||||
resource: 'memory',
|
|
||||||
subjectUserId: SUBJECT_USER_ID,
|
|
||||||
includePersonal: true,
|
|
||||||
teamIds: [],
|
|
||||||
limit: 2,
|
|
||||||
maxRowsPerQuery: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const firstPage = await service.list({ filter });
|
|
||||||
const secondPage = await service.list({ filter, cursor: firstPage.nextCursor });
|
|
||||||
const firstPageIds = firstPage.items.map((item) => item['id']);
|
|
||||||
const secondPageIds = secondPage.items.map((item) => item['id']);
|
|
||||||
const allIds = [...firstPageIds, ...secondPageIds];
|
|
||||||
|
|
||||||
expect(firstPage).toMatchObject({ truncated: true, nextCursor: expect.any(String) });
|
|
||||||
expect(firstPageIds).toEqual([INSIGHT_TWO_ID, INSIGHT_ONE_ID]);
|
|
||||||
expect(secondPageIds).toEqual([PREFERENCE_TWO_ID, PREFERENCE_ONE_ID]);
|
|
||||||
expect(new Set(allIds).size).toBe(allIds.length);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { RequestMethod } from '@nestjs/common';
|
|
||||||
import type { FastifyRequest } from 'fastify';
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { FederationAuthGuard } from '../../federation-auth.guard.js';
|
|
||||||
import type {
|
|
||||||
FederationScopeEvaluationResult,
|
|
||||||
FederationScopeQueryFilter,
|
|
||||||
} from '../../scope.service.js';
|
|
||||||
import { ListController } from '../list.controller.js';
|
|
||||||
import type { FederationListQueryResult } from '../list-query.service.js';
|
|
||||||
|
|
||||||
const FEDERATION_CONTEXT = {
|
|
||||||
grantId: 'grant-1',
|
|
||||||
peerId: 'peer-1',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
scope: { resources: ['tasks'], max_rows_per_query: 25 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const TASK_FILTER: FederationScopeQueryFilter = {
|
|
||||||
resource: 'tasks',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
includePersonal: true,
|
|
||||||
teamIds: ['team-1'],
|
|
||||||
limit: 10,
|
|
||||||
maxRowsPerQuery: 25,
|
|
||||||
};
|
|
||||||
|
|
||||||
function makeRequest(): FastifyRequest {
|
|
||||||
return { federationContext: FEDERATION_CONTEXT } as unknown as FastifyRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
function allowedScope(
|
|
||||||
filter: FederationScopeQueryFilter = TASK_FILTER,
|
|
||||||
): FederationScopeEvaluationResult {
|
|
||||||
return { allowed: true, filter };
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeController(opts?: {
|
|
||||||
scopeResult?: FederationScopeEvaluationResult;
|
|
||||||
queryResult?: FederationListQueryResult;
|
|
||||||
}) {
|
|
||||||
const scope = {
|
|
||||||
evaluateAccess: vi.fn().mockResolvedValue(opts?.scopeResult ?? allowedScope()),
|
|
||||||
};
|
|
||||||
const query = {
|
|
||||||
evaluateReadAccess: vi.fn(),
|
|
||||||
list: vi.fn().mockResolvedValue(
|
|
||||||
opts?.queryResult ?? {
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'task-1',
|
|
||||||
title: 'Federated task',
|
|
||||||
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
truncated: false,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
controller: new ListController(scope as never, query as never),
|
|
||||||
scope,
|
|
||||||
query,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ListController', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('declares POST /api/federation/v1/list/:resource protected only by FederationAuthGuard', () => {
|
|
||||||
expect(Reflect.getMetadata('path', ListController)).toBe('api/federation/v1/list');
|
|
||||||
expect(Reflect.getMetadata('path', ListController.prototype.list)).toBe(':resource');
|
|
||||||
expect(Reflect.getMetadata('method', ListController.prototype.list)).toBe(RequestMethod.POST);
|
|
||||||
expect(Reflect.getMetadata('__guards__', ListController)).toEqual([FederationAuthGuard]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('runs AuthGuard context through ScopeService and returns local-source tagged rows', async () => {
|
|
||||||
const { controller, scope, query } = makeController();
|
|
||||||
|
|
||||||
const response = await controller.list('tasks', makeRequest(), { limit: 10 });
|
|
||||||
|
|
||||||
expect(scope.evaluateAccess).toHaveBeenCalledWith({
|
|
||||||
context: FEDERATION_CONTEXT,
|
|
||||||
resource: 'tasks',
|
|
||||||
requestedLimit: 10,
|
|
||||||
nativeRbac: query,
|
|
||||||
});
|
|
||||||
expect(query.list).toHaveBeenCalledWith({ filter: TASK_FILTER, cursor: undefined });
|
|
||||||
expect(response).toEqual({
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'task-1',
|
|
||||||
title: 'Federated task',
|
|
||||||
createdAt: new Date('2026-06-24T00:00:00.000Z'),
|
|
||||||
_source: 'local',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves pagination metadata when row cap truncates the query layer result', async () => {
|
|
||||||
const { controller } = makeController({
|
|
||||||
queryResult: {
|
|
||||||
items: [{ id: 'task-1' }],
|
|
||||||
nextCursor: 'cursor-2',
|
|
||||||
truncated: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await controller.list('tasks', makeRequest(), { cursor: 'cursor-1' });
|
|
||||||
|
|
||||||
expect(response).toEqual({
|
|
||||||
items: [{ id: 'task-1', _source: 'local' }],
|
|
||||||
nextCursor: 'cursor-2',
|
|
||||||
_truncated: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a federation error envelope when auth guard context is missing', async () => {
|
|
||||||
const { controller, scope, query } = makeController();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
controller.list('tasks', {} as unknown as FastifyRequest, {}),
|
|
||||||
).rejects.toMatchObject({
|
|
||||||
response: {
|
|
||||||
error: {
|
|
||||||
code: 'unauthorized',
|
|
||||||
message: 'Federation context missing',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status: 401,
|
|
||||||
});
|
|
||||||
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
|
||||||
expect(query.list).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a federation error envelope when scope evaluation denies access', async () => {
|
|
||||||
const { controller, query } = makeController({
|
|
||||||
scopeResult: {
|
|
||||||
allowed: false,
|
|
||||||
deny: {
|
|
||||||
code: 'resource_excluded',
|
|
||||||
stage: 'resource_exclusion',
|
|
||||||
statusCode: 403,
|
|
||||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
|
||||||
grantId: 'grant-1',
|
|
||||||
peerId: 'peer-1',
|
|
||||||
subjectUserId: 'user-1',
|
|
||||||
resource: 'credentials',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(controller.list('credentials', makeRequest(), {})).rejects.toMatchObject({
|
|
||||||
response: {
|
|
||||||
error: {
|
|
||||||
code: 'scope_violation',
|
|
||||||
message: 'Requested federation resource is explicitly excluded by grant scope',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status: 403,
|
|
||||||
});
|
|
||||||
expect(query.list).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects malformed request body fields before querying storage', async () => {
|
|
||||||
const { controller, scope, query } = makeController();
|
|
||||||
|
|
||||||
await expect(controller.list('tasks', makeRequest(), { cursor: 123 })).rejects.toMatchObject({
|
|
||||||
response: { error: { code: 'invalid_request' } },
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
await expect(controller.list('tasks', makeRequest(), { limit: false })).rejects.toMatchObject({
|
|
||||||
response: { error: { code: 'invalid_request' } },
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
await expect(controller.list('tasks', makeRequest(), { limit: 'abc' })).rejects.toMatchObject({
|
|
||||||
response: { error: { code: 'invalid_request' } },
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
expect(scope.evaluateAccess).not.toHaveBeenCalled();
|
|
||||||
expect(query.list).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
/**
|
|
||||||
* Federation capabilities verb (FED-M3-07).
|
|
||||||
*
|
|
||||||
* Returns the read-only capability envelope for the active grant attached by
|
|
||||||
* FederationAuthGuard. This endpoint intentionally does not invoke native RBAC
|
|
||||||
* or ScopeService: an active grant is enough to ask what the grant allows.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
|
|
||||||
import type { FastifyRequest } from 'fastify';
|
|
||||||
import {
|
|
||||||
FEDERATION_VERBS,
|
|
||||||
type FederationCapabilitiesResponse,
|
|
||||||
type FederationVerb,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
import { parseFederationScope } from '../../scope-schema.js';
|
|
||||||
import { FederationAuthGuard } from '../federation-auth.guard.js';
|
|
||||||
import '../federation-context.js';
|
|
||||||
|
|
||||||
@Controller('api/federation/v1/capabilities')
|
|
||||||
@UseGuards(FederationAuthGuard)
|
|
||||||
export class CapabilitiesController {
|
|
||||||
@Get()
|
|
||||||
getCapabilities(@Req() request: FastifyRequest): FederationCapabilitiesResponse {
|
|
||||||
if (!request.federationContext) {
|
|
||||||
throw new Error('Federation context missing after auth guard');
|
|
||||||
}
|
|
||||||
|
|
||||||
const scope = parseFederationScope(request.federationContext.scope);
|
|
||||||
|
|
||||||
return {
|
|
||||||
resources: [...scope.resources],
|
|
||||||
excluded_resources: [...scope.excluded_resources],
|
|
||||||
max_rows_per_query: scope.max_rows_per_query,
|
|
||||||
supported_verbs: [...FEDERATION_VERBS] satisfies FederationVerb[],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
/**
|
|
||||||
* Federation list query layer (FED-M3-05).
|
|
||||||
*
|
|
||||||
* Read-only DB adapter used by ListController after FederationAuthGuard and
|
|
||||||
* FederationScopeService have established the subject user, allowed resource,
|
|
||||||
* native-RBAC intersection, and row cap. Audit writes are intentionally
|
|
||||||
* deferred to M4.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
and,
|
|
||||||
desc,
|
|
||||||
eq,
|
|
||||||
inArray,
|
|
||||||
insights,
|
|
||||||
isNotNull,
|
|
||||||
lt,
|
|
||||||
missionTasks,
|
|
||||||
missions,
|
|
||||||
or,
|
|
||||||
preferences,
|
|
||||||
projects,
|
|
||||||
tasks,
|
|
||||||
teamMembers,
|
|
||||||
type Db,
|
|
||||||
} from '@mosaicstack/db';
|
|
||||||
import type {
|
|
||||||
FederationNativeRbacEvaluator,
|
|
||||||
FederationNativeRbacRequest,
|
|
||||||
FederationNativeRbacResult,
|
|
||||||
FederationScopeQueryFilter,
|
|
||||||
} from '../scope.service.js';
|
|
||||||
import { DB } from '../../../database/database.module.js';
|
|
||||||
|
|
||||||
export interface FederationListQueryRequest {
|
|
||||||
readonly filter: FederationScopeQueryFilter;
|
|
||||||
readonly cursor?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FederationListQueryResult<T extends object = Record<string, unknown>> {
|
|
||||||
readonly items: T[];
|
|
||||||
readonly nextCursor?: string;
|
|
||||||
readonly truncated: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type CursorSource = 'insights' | 'preferences';
|
|
||||||
const CURSOR_SOURCE = Symbol('federationCursorSource');
|
|
||||||
|
|
||||||
type RowObject = Record<string, unknown> & { readonly [CURSOR_SOURCE]?: CursorSource };
|
|
||||||
|
|
||||||
interface KeysetCursor {
|
|
||||||
readonly createdAt: Date;
|
|
||||||
readonly id: string;
|
|
||||||
readonly source?: CursorSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeCursor(row: RowObject): string {
|
|
||||||
const createdAt = row['createdAt'];
|
|
||||||
const id = row['id'];
|
|
||||||
if (!(createdAt instanceof Date) || typeof id !== 'string') {
|
|
||||||
throw new Error('Federation list cursor cannot be encoded');
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = row[CURSOR_SOURCE];
|
|
||||||
return Buffer.from(
|
|
||||||
JSON.stringify({ createdAt: createdAt.toISOString(), id, ...(source ? { source } : {}) }),
|
|
||||||
'utf8',
|
|
||||||
).toString('base64url');
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeCursor(cursor: string | undefined): KeysetCursor | undefined {
|
|
||||||
if (cursor === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown;
|
|
||||||
if (typeof parsed !== 'object' || parsed === null) {
|
|
||||||
throw new Error('cursor must be an object');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { createdAt, id, source } = parsed as {
|
|
||||||
createdAt?: unknown;
|
|
||||||
id?: unknown;
|
|
||||||
source?: unknown;
|
|
||||||
};
|
|
||||||
if (typeof createdAt !== 'string' || typeof id !== 'string' || id.length === 0) {
|
|
||||||
throw new Error('cursor is missing createdAt or id');
|
|
||||||
}
|
|
||||||
if (source !== undefined && source !== 'insights' && source !== 'preferences') {
|
|
||||||
throw new Error('cursor source is invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
const date = new Date(createdAt);
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
throw new Error('cursor createdAt is invalid');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { createdAt: date, id, ...(source ? { source } : {}) };
|
|
||||||
} catch {
|
|
||||||
throw new Error('Invalid federation list cursor');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function paginate<T extends RowObject>(rows: T[], limit: number): FederationListQueryResult<T> {
|
|
||||||
const page = rows.slice(0, limit);
|
|
||||||
const hasMore = rows.length > limit;
|
|
||||||
const nextCursor = hasMore ? encodeCursor(page[page.length - 1] ?? {}) : undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
items: page,
|
|
||||||
truncated: hasMore,
|
|
||||||
...(nextCursor !== undefined ? { nextCursor } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function markCursorSource<T extends RowObject>(row: T, source: CursorSource): T {
|
|
||||||
Object.defineProperty(row, CURSOR_SOURCE, {
|
|
||||||
value: source,
|
|
||||||
enumerable: false,
|
|
||||||
configurable: false,
|
|
||||||
});
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortRows(rows: RowObject[]): RowObject[] {
|
|
||||||
return [...rows].sort((a, b) => {
|
|
||||||
const aTime = a['createdAt'] instanceof Date ? a['createdAt'].getTime() : 0;
|
|
||||||
const bTime = b['createdAt'] instanceof Date ? b['createdAt'].getTime() : 0;
|
|
||||||
if (aTime !== bTime) {
|
|
||||||
return bTime - aTime;
|
|
||||||
}
|
|
||||||
return String(b['id'] ?? '').localeCompare(String(a['id'] ?? ''));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class FederationListQueryService implements FederationNativeRbacEvaluator {
|
|
||||||
constructor(@Inject(DB) private readonly db: Db) {}
|
|
||||||
|
|
||||||
async evaluateReadAccess(
|
|
||||||
request: FederationNativeRbacRequest,
|
|
||||||
): Promise<FederationNativeRbacResult> {
|
|
||||||
if (request.resource === 'credentials' || request.resource === 'api_keys') {
|
|
||||||
return {
|
|
||||||
allowed: false,
|
|
||||||
reason: `${request.resource} federation list access is not implemented in M3`,
|
|
||||||
details: { resource: request.resource },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.resource === 'memory') {
|
|
||||||
return { allowed: true, access: { includePersonal: true, teamIds: [] } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const teamIds = await this.listSubjectTeamIds(request.subjectUserId);
|
|
||||||
return { allowed: true, access: { includePersonal: true, teamIds } };
|
|
||||||
}
|
|
||||||
|
|
||||||
async list<T extends RowObject = RowObject>(
|
|
||||||
request: FederationListQueryRequest,
|
|
||||||
): Promise<FederationListQueryResult<T>> {
|
|
||||||
const cursor = decodeCursor(request.cursor);
|
|
||||||
const rows = await this.listAllRows(request.filter, request.filter.limit + 1, cursor);
|
|
||||||
return paginate(rows as T[], request.filter.limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listAllRows(
|
|
||||||
filter: FederationScopeQueryFilter,
|
|
||||||
rowLimit: number,
|
|
||||||
cursor: KeysetCursor | undefined,
|
|
||||||
): Promise<RowObject[]> {
|
|
||||||
switch (filter.resource) {
|
|
||||||
case 'tasks':
|
|
||||||
return this.listTasks(filter, rowLimit, cursor);
|
|
||||||
case 'notes':
|
|
||||||
return this.listNotes(filter, rowLimit, cursor);
|
|
||||||
case 'memory':
|
|
||||||
return this.listMemory(filter, rowLimit, cursor);
|
|
||||||
case 'credentials':
|
|
||||||
case 'api_keys':
|
|
||||||
return [];
|
|
||||||
default:
|
|
||||||
throw new Error(`Unsupported federation list resource: ${String(filter.resource)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listSubjectTeamIds(subjectUserId: string): Promise<string[]> {
|
|
||||||
const rows = await this.db
|
|
||||||
.select({ teamId: teamMembers.teamId })
|
|
||||||
.from(teamMembers)
|
|
||||||
.where(eq(teamMembers.userId, subjectUserId));
|
|
||||||
|
|
||||||
return rows.map((row) => row.teamId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listAccessibleProjectIds(filter: FederationScopeQueryFilter): Promise<string[]> {
|
|
||||||
const clauses = [];
|
|
||||||
if (filter.includePersonal) {
|
|
||||||
clauses.push(and(eq(projects.ownerType, 'user'), eq(projects.ownerId, filter.subjectUserId)));
|
|
||||||
}
|
|
||||||
if (filter.teamIds.length > 0) {
|
|
||||||
clauses.push(
|
|
||||||
and(eq(projects.ownerType, 'team'), inArray(projects.teamId, [...filter.teamIds])),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clauses.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await this.db
|
|
||||||
.select({ id: projects.id })
|
|
||||||
.from(projects)
|
|
||||||
.where(clauses.length === 1 ? clauses[0] : or(...clauses));
|
|
||||||
|
|
||||||
return rows.map((row) => row.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listMissionIds(projectIds: readonly string[]): Promise<string[]> {
|
|
||||||
if (projectIds.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await this.db
|
|
||||||
.select({ id: missions.id })
|
|
||||||
.from(missions)
|
|
||||||
.where(inArray(missions.projectId, [...projectIds]));
|
|
||||||
|
|
||||||
return rows.map((row) => row.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listTasks(
|
|
||||||
filter: FederationScopeQueryFilter,
|
|
||||||
rowLimit: number,
|
|
||||||
cursor: KeysetCursor | undefined,
|
|
||||||
): Promise<RowObject[]> {
|
|
||||||
const projectIds = await this.listAccessibleProjectIds(filter);
|
|
||||||
const missionIds = await this.listMissionIds(projectIds);
|
|
||||||
const clauses = [];
|
|
||||||
|
|
||||||
if (projectIds.length > 0) {
|
|
||||||
clauses.push(inArray(tasks.projectId, projectIds));
|
|
||||||
}
|
|
||||||
if (missionIds.length > 0) {
|
|
||||||
clauses.push(inArray(tasks.missionId, missionIds));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clauses.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const scopeClause = clauses.length === 1 ? clauses[0] : or(...clauses);
|
|
||||||
const cursorClause = cursor
|
|
||||||
? or(
|
|
||||||
lt(tasks.createdAt, cursor.createdAt),
|
|
||||||
and(eq(tasks.createdAt, cursor.createdAt), lt(tasks.id, cursor.id)),
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const rows = await this.db
|
|
||||||
.select({
|
|
||||||
id: tasks.id,
|
|
||||||
title: tasks.title,
|
|
||||||
description: tasks.description,
|
|
||||||
status: tasks.status,
|
|
||||||
priority: tasks.priority,
|
|
||||||
projectId: tasks.projectId,
|
|
||||||
missionId: tasks.missionId,
|
|
||||||
assignee: tasks.assignee,
|
|
||||||
tags: tasks.tags,
|
|
||||||
dueDate: tasks.dueDate,
|
|
||||||
metadata: tasks.metadata,
|
|
||||||
createdAt: tasks.createdAt,
|
|
||||||
updatedAt: tasks.updatedAt,
|
|
||||||
})
|
|
||||||
.from(tasks)
|
|
||||||
.where(and(scopeClause, cursorClause))
|
|
||||||
.orderBy(desc(tasks.createdAt), desc(tasks.id))
|
|
||||||
.limit(rowLimit);
|
|
||||||
|
|
||||||
return sortRows(rows as RowObject[]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listNotes(
|
|
||||||
filter: FederationScopeQueryFilter,
|
|
||||||
rowLimit: number,
|
|
||||||
cursor: KeysetCursor | undefined,
|
|
||||||
): Promise<RowObject[]> {
|
|
||||||
const projectIds = await this.listAccessibleProjectIds(filter);
|
|
||||||
const missionIds = await this.listMissionIds(projectIds);
|
|
||||||
|
|
||||||
if (missionIds.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// mission_tasks rows are user-scoped even when the mission belongs to a team.
|
|
||||||
// Team visibility can narrow the mission set, but it must never widen the
|
|
||||||
// query to other users' mission task notes.
|
|
||||||
const scopeClause = and(
|
|
||||||
eq(missionTasks.userId, filter.subjectUserId),
|
|
||||||
inArray(missionTasks.missionId, missionIds),
|
|
||||||
);
|
|
||||||
const cursorClause = cursor
|
|
||||||
? or(
|
|
||||||
lt(missionTasks.createdAt, cursor.createdAt),
|
|
||||||
and(eq(missionTasks.createdAt, cursor.createdAt), lt(missionTasks.id, cursor.id)),
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const rows = await this.db
|
|
||||||
.select({
|
|
||||||
id: missionTasks.id,
|
|
||||||
missionId: missionTasks.missionId,
|
|
||||||
taskId: missionTasks.taskId,
|
|
||||||
status: missionTasks.status,
|
|
||||||
content: missionTasks.notes,
|
|
||||||
createdAt: missionTasks.createdAt,
|
|
||||||
updatedAt: missionTasks.updatedAt,
|
|
||||||
})
|
|
||||||
.from(missionTasks)
|
|
||||||
.where(and(scopeClause, cursorClause, isNotNull(missionTasks.notes)))
|
|
||||||
.orderBy(desc(missionTasks.createdAt), desc(missionTasks.id))
|
|
||||||
.limit(rowLimit);
|
|
||||||
|
|
||||||
return sortRows(rows.filter((row) => row.content !== '') as RowObject[]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async listMemory(
|
|
||||||
filter: FederationScopeQueryFilter,
|
|
||||||
rowLimit: number,
|
|
||||||
cursor: KeysetCursor | undefined,
|
|
||||||
): Promise<RowObject[]> {
|
|
||||||
if (!filter.includePersonal) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
if (cursor && cursor.source === undefined) {
|
|
||||||
throw new Error('Invalid federation list cursor');
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows: RowObject[] = [];
|
|
||||||
|
|
||||||
// Memory spans two physical tables. To keep pagination deterministic and
|
|
||||||
// resumable without a SQL UNION, M3 emits a fixed block order: all insights
|
|
||||||
// first, then preferences. The opaque cursor records which table produced
|
|
||||||
// the boundary row, so the next page never re-applies one table's keyset to
|
|
||||||
// the other table (which could duplicate/skip rows at equal timestamps).
|
|
||||||
if (cursor?.source !== 'preferences') {
|
|
||||||
const insightCursorClause = cursor
|
|
||||||
? or(
|
|
||||||
lt(insights.createdAt, cursor.createdAt),
|
|
||||||
and(eq(insights.createdAt, cursor.createdAt), lt(insights.id, cursor.id)),
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
const insightRows = await this.db
|
|
||||||
.select({
|
|
||||||
id: insights.id,
|
|
||||||
kind: insights.source,
|
|
||||||
content: insights.content,
|
|
||||||
category: insights.category,
|
|
||||||
relevanceScore: insights.relevanceScore,
|
|
||||||
metadata: insights.metadata,
|
|
||||||
createdAt: insights.createdAt,
|
|
||||||
updatedAt: insights.updatedAt,
|
|
||||||
})
|
|
||||||
.from(insights)
|
|
||||||
.where(and(eq(insights.userId, filter.subjectUserId), insightCursorClause))
|
|
||||||
.orderBy(desc(insights.createdAt), desc(insights.id))
|
|
||||||
.limit(rowLimit);
|
|
||||||
|
|
||||||
rows.push(...(insightRows as RowObject[]).map((row) => markCursorSource(row, 'insights')));
|
|
||||||
}
|
|
||||||
|
|
||||||
const remaining = rowLimit - rows.length;
|
|
||||||
if (remaining <= 0) {
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
const preferenceCursorClause =
|
|
||||||
cursor?.source === 'preferences'
|
|
||||||
? or(
|
|
||||||
lt(preferences.createdAt, cursor.createdAt),
|
|
||||||
and(eq(preferences.createdAt, cursor.createdAt), lt(preferences.id, cursor.id)),
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
const preferenceRows = await this.db
|
|
||||||
.select({
|
|
||||||
id: preferences.id,
|
|
||||||
kind: preferences.category,
|
|
||||||
key: preferences.key,
|
|
||||||
value: preferences.value,
|
|
||||||
source: preferences.source,
|
|
||||||
mutable: preferences.mutable,
|
|
||||||
createdAt: preferences.createdAt,
|
|
||||||
updatedAt: preferences.updatedAt,
|
|
||||||
})
|
|
||||||
.from(preferences)
|
|
||||||
.where(and(eq(preferences.userId, filter.subjectUserId), preferenceCursorClause))
|
|
||||||
.orderBy(desc(preferences.createdAt), desc(preferences.id))
|
|
||||||
.limit(remaining);
|
|
||||||
|
|
||||||
rows.push(
|
|
||||||
...(preferenceRows as RowObject[]).map((row) => markCursorSource(row, 'preferences')),
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
/**
|
|
||||||
* Federation list verb (FED-M3-05).
|
|
||||||
*
|
|
||||||
* POST /api/federation/v1/list/:resource
|
|
||||||
*
|
|
||||||
* Pipeline: FederationAuthGuard attaches the active grant context, then
|
|
||||||
* FederationScopeService enforces grant scope + native RBAC intersection, then
|
|
||||||
* the read-only query layer returns capped rows tagged with `_source`. Read
|
|
||||||
* audit-log writes are deferred to M4; this controller does not persist request
|
|
||||||
* or response bodies.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
HttpException,
|
|
||||||
Inject,
|
|
||||||
Param,
|
|
||||||
Post,
|
|
||||||
Req,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import type { FastifyRequest } from 'fastify';
|
|
||||||
import {
|
|
||||||
FederationInvalidRequestError,
|
|
||||||
FederationScopeViolationError,
|
|
||||||
FederationUnauthorizedError,
|
|
||||||
SOURCE_LOCAL,
|
|
||||||
tagWithSource,
|
|
||||||
type FederationListResponse,
|
|
||||||
type SourceTag,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
import { FederationAuthGuard } from '../federation-auth.guard.js';
|
|
||||||
import '../federation-context.js';
|
|
||||||
import { FederationScopeService } from '../scope.service.js';
|
|
||||||
import { FederationListQueryService } from './list-query.service.js';
|
|
||||||
|
|
||||||
interface FederationListRequestBody {
|
|
||||||
readonly limit?: unknown;
|
|
||||||
readonly cursor?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
type FederatedRow = Record<string, unknown> & SourceTag;
|
|
||||||
|
|
||||||
function parseLimit(body: FederationListRequestBody | undefined): number | undefined {
|
|
||||||
if (body?.limit === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed =
|
|
||||||
typeof body.limit === 'number'
|
|
||||||
? body.limit
|
|
||||||
: typeof body.limit === 'string' && body.limit.trim().length > 0
|
|
||||||
? Number(body.limit)
|
|
||||||
: Number.NaN;
|
|
||||||
|
|
||||||
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
|
||||||
throw new HttpException(
|
|
||||||
new FederationInvalidRequestError(
|
|
||||||
'Federation list limit must be a positive integer',
|
|
||||||
).toEnvelope(),
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCursor(body: FederationListRequestBody | undefined): string | undefined {
|
|
||||||
if (body?.cursor === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (typeof body.cursor === 'string') {
|
|
||||||
return body.cursor;
|
|
||||||
}
|
|
||||||
throw new HttpException(
|
|
||||||
new FederationInvalidRequestError('Federation list cursor must be a string').toEnvelope(),
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Controller('api/federation/v1/list')
|
|
||||||
@UseGuards(FederationAuthGuard)
|
|
||||||
export class ListController {
|
|
||||||
constructor(
|
|
||||||
@Inject(FederationScopeService) private readonly scope: FederationScopeService,
|
|
||||||
@Inject(FederationListQueryService) private readonly query: FederationListQueryService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Post(':resource')
|
|
||||||
async list(
|
|
||||||
@Param('resource') resource: string,
|
|
||||||
@Req() request: FastifyRequest,
|
|
||||||
@Body() body?: FederationListRequestBody,
|
|
||||||
): Promise<FederationListResponse<FederatedRow>> {
|
|
||||||
if (!request.federationContext) {
|
|
||||||
throw new HttpException(
|
|
||||||
new FederationUnauthorizedError('Federation context missing').toEnvelope(),
|
|
||||||
401,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestedLimit = parseLimit(body);
|
|
||||||
const cursor = parseCursor(body);
|
|
||||||
const scopeResult = await this.scope.evaluateAccess({
|
|
||||||
context: request.federationContext,
|
|
||||||
resource,
|
|
||||||
requestedLimit,
|
|
||||||
nativeRbac: this.query,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!scopeResult.allowed) {
|
|
||||||
const ErrorClass =
|
|
||||||
scopeResult.deny.statusCode === 400
|
|
||||||
? FederationInvalidRequestError
|
|
||||||
: FederationScopeViolationError;
|
|
||||||
throw new HttpException(
|
|
||||||
new ErrorClass(scopeResult.deny.message, scopeResult.deny).toEnvelope(),
|
|
||||||
scopeResult.deny.statusCode,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let result: Awaited<ReturnType<FederationListQueryService['list']>>;
|
|
||||||
try {
|
|
||||||
result = await this.query.list({ filter: scopeResult.filter, cursor });
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (error instanceof Error && error.message === 'Invalid federation list cursor') {
|
|
||||||
throw new HttpException(
|
|
||||||
new FederationInvalidRequestError('Federation list cursor is invalid').toEnvelope(),
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response: FederationListResponse<FederatedRow> = {
|
|
||||||
items: tagWithSource(result.items, SOURCE_LOCAL),
|
|
||||||
};
|
|
||||||
if (result.nextCursor !== undefined) {
|
|
||||||
response.nextCursor = result.nextCursor;
|
|
||||||
}
|
|
||||||
if (result.truncated) {
|
|
||||||
response._truncated = true;
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
FROM node:22-alpine AS base
|
|
||||||
ENV PNPM_HOME="/pnpm"
|
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
|
||||||
RUN corepack enable
|
|
||||||
|
|
||||||
FROM base AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
# Copy workspace manifests first for layer-cached install
|
|
||||||
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
|
||||||
COPY apps/appservice/package.json ./apps/appservice/
|
|
||||||
COPY packages/ ./packages/
|
|
||||||
COPY plugins/ ./plugins/
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
COPY . .
|
|
||||||
RUN pnpm turbo run build --filter @mosaicstack/mosaic-as...
|
|
||||||
RUN pnpm --filter @mosaicstack/mosaic-as --prod deploy --legacy /deploy
|
|
||||||
|
|
||||||
FROM base AS runner
|
|
||||||
WORKDIR /app
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
COPY --from=builder /deploy/node_modules ./node_modules
|
|
||||||
COPY --from=builder /deploy/package.json ./package.json
|
|
||||||
COPY --from=builder /app/apps/appservice/dist ./dist
|
|
||||||
USER node
|
|
||||||
EXPOSE 8008
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=5 \
|
|
||||||
CMD ["node", "-e", "require('http').get('http://127.0.0.1:8008/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"]
|
|
||||||
CMD ["node", "dist/main.js"]
|
|
||||||
@@ -64,7 +64,6 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and
|
|||||||
21. `@mosaicstack/cli` — unified `mosaic` CLI
|
21. `@mosaicstack/cli` — unified `mosaic` CLI
|
||||||
22. Docker Compose deployment + bare-metal capability
|
22. Docker Compose deployment + bare-metal capability
|
||||||
23. Agent log service — ingest, parse, tier, summarize agent interaction logs
|
23. Agent log service — ingest, parse, tier, summarize agent interaction logs
|
||||||
24. Local durable agent fleet canary — `mosaic fleet` / `mosaic agent` CLI for an isolated tmux-backed canary fleet using a named socket, with roster-driven local customization and rollback-safe verification
|
|
||||||
|
|
||||||
### Out of Scope (v0.1.0)
|
### Out of Scope (v0.1.0)
|
||||||
|
|
||||||
|
|||||||
@@ -39,54 +39,3 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
|||||||
1. Read [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) for workstream scope
|
1. Read [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) for workstream scope
|
||||||
2. Read [docs/federation/TASKS.md](./federation/TASKS.md) for the next pending task
|
2. Read [docs/federation/TASKS.md](./federation/TASKS.md) for the next pending task
|
||||||
3. Follow per-task agent + tier guidance from the workstream manifest
|
3. Follow per-task agent + tier guidance from the workstream manifest
|
||||||
|
|
||||||
## Thin-core prompt diet (#528) — feat/contract-thin-core
|
|
||||||
|
|
||||||
- Status: PR open, awaiting maintainer merge ratification (fleet-governing change).
|
|
||||||
- Cut always-injected contract AGENTS+TOOLS+RUNTIME 8,827→4,122 tok (−53%); all 12 hard gates intact.
|
|
||||||
- Validation: deterministic gate-checklist PASS; headless A/B thin 7/9 vs monolith 5/9. Detail: scratchpads/contract-thin-core.md.
|
|
||||||
|
|
||||||
## P5 — Overlay composer + cross-harness (#604) — feat/p5-overlay-composer
|
|
||||||
|
|
||||||
- Status: MERGED to main (#605). R7 (compose-contract) + R8 (cross-harness) + R9 (composer test).
|
|
||||||
- `composeContract({harness, mosaicHome})` pure fn + `.local` overlay deltas-by-value; `mosaic compose-contract <harness>` command; AGENTS bare-launch nudge; composer spec (per-tier anchor + Tier-3 byte-equality). Detail: scratchpads/p5-overlay-composer.md.
|
|
||||||
|
|
||||||
## P6 — Docs, compliance matrix, alpha tag (#606) — feat/p6-docs-compliance-alpha
|
|
||||||
|
|
||||||
- Status: in-repo deliverables done (CONTRIBUTING.md + harness×gate compliance matrix + check-resident-budget.sh + CI wiring + ALPHA-DOD.md). Remaining: alpha tag v0.0.39-alpha (Lead, post-merge). aiguide reconcile merged (#8). Detail: scratchpads/p6-docs-compliance-alpha.md.
|
|
||||||
|
|
||||||
## F3-m3 — mosaic update re-seeds framework + relaunches agents (#609) — feat/f3-m3-update-reseed
|
|
||||||
|
|
||||||
- Status: implemented + tested. Closes R13: `mosaic update` now re-seeds the framework (data-safe MOSAIC_SYNC_ONLY) after the CLI install so shipped launcher/runtime changes activate; `--relaunch` restarts rostered agents; `--no-reseed` opts out. Detail: scratchpads/f3-m3-update-reseed.md.
|
|
||||||
|
|
||||||
## Fleet-polish bundle — boot-survival symmetry (#611) — feat/fleet-polish-bundle
|
|
||||||
|
|
||||||
- Status: MERGED to main. disable-on-remove (boot-resurrection bug, TDD) + add-enable + init-R5 hard guarantee. 4 new + 147 existing fleet tests green. Detail: scratchpads/fleet-polish-bundle.md.
|
|
||||||
|
|
||||||
## Fleet enhancer role + two-agent floor (#614) — feat/fleet-enhancer-floor
|
|
||||||
|
|
||||||
- Status: MERGED to main. enhancer added to 4 presets; init guarantees 1 orchestrator + >=1 enhancer; remove protects the sole enhancer; enhancer role doc. 155 fleet tests green. Detail: scratchpads/fleet-enhancer-floor.md.
|
|
||||||
|
|
||||||
## F4 — Orchestrator chat connector + Matrix (#616) — feat/f4-matrix-connector
|
|
||||||
|
|
||||||
- Status: Phase 1 MERGED (#617: connector interface send/subscribe/health + registry + roster schema + design). Phase 2a (#618): Matrix CS-API client + factory. 20 connector tests green; no fleet.ts changes. Remaining Phase 2: init/configure connector-selection UX + roster wiring, systemd launch wiring, Conduit deploy guide. Detail: scratchpads/f4-matrix-connector.md.
|
|
||||||
|
|
||||||
## Fleet onboarding-injection — comms cheat-sheet + peer roster (#620) — feat/fleet-comms-onboarding
|
|
||||||
|
|
||||||
- Status: implemented + tested. Injects # Fleet Comms (peer roster + cross-host agent-send commands + FLIP-reply + --verify) into each spawned fleet agent via composeContract; optional per-agent host/ssh/socket roster fields (socket: named → -L, unset → default socket no -L). 10 + 2 tests green. Detail: scratchpads/fleet-comms-onboarding.md.
|
|
||||||
|
|
||||||
## Fleet stand-up fixes — model_hint→--model + socket-default trap (#626) — feat/fleet-standup-fixes
|
|
||||||
|
|
||||||
- Status: implemented + tested. FIX1 model_hint→MOSAIC_AGENT_MODEL→--model. FIX2 absent socket = default tmux socket (no -L) across parse/spawn/systemd-unit/observe (socketArgs helper, bare-empty shellEnvValue, conditional -L). 158 fleet tests green; shipped presets unaffected (explicit socket_name). Detail: scratchpads/fleet-standup-fixes.md.
|
|
||||||
|
|
||||||
## north-star doctrine consolidation — doc PR — feat/north-star-doctrine
|
|
||||||
|
|
||||||
- Status: applied Mos's consolidated merge-map to docs/fleet/north-star.md (budget governance + control plane/central register + 200k cap + delegation + unified-identity Fleet + role-based naming + tmux security + drift re-captures). Doctrine only; #622/#623/#625/#628 out-of-scope. Conflict checklist green. Detail: scratchpads/north-star-doctrine.md.
|
|
||||||
|
|
||||||
## #631 — re-seed preserves user fleet data (CRITICAL) — fix/631-reseed-preserves-fleet-data
|
|
||||||
|
|
||||||
- Status: implemented + tested. PRIMARY: install.sh PRESERVE_PATHS += fleet/\*.yaml + fleet/agents + fleet/run (glob-aware cp-fallback); TS parity. SECONDARY: refreshActiveFleetUnits propagates unit fixes to ~/.config/systemd/user on mosaic update. bash F6 + TS + unit tests green. Detail: scratchpads/631-reseed-preserves-fleet.md.
|
|
||||||
|
|
||||||
## #633 — comms-block emitter + FLEET-LAUNCH runbook — feat/633-comms-block-runbook
|
|
||||||
|
|
||||||
- Status: implemented + tested (TDD). `mosaic fleet comms-block <role> [--host]` wraps resolveCommsBlock → readFleetCommsBlock; fails loud (stderr + exit 1) on unknown role / missing roster instead of silent empty. docs/fleet/FLEET-LAUNCH.md runbook: worker path + orchestrator .env fold (MOSAIC_AGENT_COMMAND; line-41 [-z] short-circuits line-44 yolo hardcode) + 3 launch gotchas + #632 preserve note + North-Star 4-field arc (harness ✅/model ✅ roster-native today; yolo + command/channels = PATH B #636). 177 fleet+comms tests green (6 new resolveCommsBlock cases). PATH A of the A→B→webUI arc. Detail: scratchpads/633-comms-block-runbook.md.
|
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
# Constitution Alpha — Definition-of-Done checklist + release notes
|
|
||||||
|
|
||||||
Drafted for the `v0.0.39-alpha` tag (Lead cuts after P5 #605 → P6 #607 → aiguide #8 merge).
|
|
||||||
Maps every DoD §8 acceptance criterion to its merged evidence. Legend:
|
|
||||||
**✅ merged on main** · **⏳ review-ready PR (pending merge)** · **🔲 Lead action**.
|
|
||||||
|
|
||||||
## DoD §8 green-checklist
|
|
||||||
|
|
||||||
| # | Acceptance criterion (DESIGN §8) | Status | Evidence / PR |
|
|
||||||
| --- | ------------------------------------------------------------------------------------------------------ | ------ | ----------------- |
|
|
||||||
| 1 | MIT `LICENSE` (root + framework) + `"license":"MIT"` in package.json | ✅ | P0 #570 |
|
|
||||||
| 2 | Three credential-path sites + hook URL fast-failed (no private paths in `*.sh`/hooks) | ✅ | P0 #570 |
|
|
||||||
| 3 | `verify-sanitized.sh` (two-class, `*.sh`+`*.md`, self-tested) wired **blocking** in CI | ✅ | P1 #572 |
|
|
||||||
| 4 | Operator data purged from the full set (guides / tools / init-generator) | ✅ | P2 #572 |
|
|
||||||
| 5 | `rails/`→`tools/` in **both** template families | ✅ | P2 #572 |
|
|
||||||
| 6 | `jarvis-loop.json` deleted; `defaults/SOUL.md` → **neutral sanitized persona** (Q10 decision) | ✅ | P2 #572 |
|
|
||||||
| 7 | `CONSTITUTION.md` extracted (gates one place, capability-verb, §1.4 split, no false "already loaded") | ✅ | P3 #575 / #577 |
|
|
||||||
| 8 | `AGENTS.md`/`STANDARDS.md` out of `PRESERVE_PATHS` + seed-semantics → overwrite in **both** installers | ✅ | P4 #590 |
|
|
||||||
| 9 | Snapshot + v2→v3 migration moving user edits to `.local`/`.bak`; `FRAMEWORK_VERSION=3` | ✅ | P4 #590 / #593 |
|
|
||||||
| 10 | `mosaic-init --non-interactive` fail-closed persona | ✅ | P4 #590 |
|
|
||||||
| 11 | **5-fixture migration matrix** green against **both** installers asserting **injected bytes** | ✅ | P4 #590 / #593 |
|
|
||||||
| 12 | `compose-contract` built + composer unit test (per-tier anchor + Tier-3 byte-equality) | ⏳ | P5 #605 |
|
|
||||||
| 13 | Resident line-count ceiling enforced (framework-owned resident files) | ⏳ | P6 #607 |
|
|
||||||
| 14 | `CONTRIBUTING.md` + harness×gate compliance matrix | ⏳ | P6 #607 |
|
|
||||||
| 15 | `aiguide` reconciled with the Constitution | ⏳ | aiguide #8 |
|
|
||||||
| 16 | Each phase PR CI-green; alpha tag pushed + Gitea release published | 🔲 | Lead (post-merge) |
|
|
||||||
|
|
||||||
**Note on #6:** the DoD's literal "delete `defaults/SOUL.md`" was superseded by the resolved
|
|
||||||
**Q10** decision — ship a _neutral, operator-agnostic_ example persona instead of deleting it. Main
|
|
||||||
carries the sanitized 2.6 KB neutral SOUL.md ("Mosaic agent", no operator identity); the sanitization
|
|
||||||
gate confirms it is PII-clean. Criterion met in spirit (no operator persona leaks) via the better option.
|
|
||||||
|
|
||||||
**Gate to flip 12–14 → ✅:** merge P5 #605 → P6 #607 (rebase auto-drops the dup format fix
|
|
||||||
`adc7df2`/`9f6da92`) → aiguide #8, with `ci.yml` terminal-green on the merged head.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Release notes — `v0.0.39-alpha` (Mosaic Framework Constitution, alpha)
|
|
||||||
|
|
||||||
### Mosaic Framework Constitution — Alpha
|
|
||||||
|
|
||||||
This release makes the Mosaic framework a **safe-to-open-source, fork-and-customize agent
|
|
||||||
operating layer**. It separates the non-negotiable law from operator identity, makes
|
|
||||||
customization survive upgrades, and wires the guarantees into CI.
|
|
||||||
|
|
||||||
**Highlights**
|
|
||||||
|
|
||||||
- **Constitution (L0).** The hard gates now live in one place — `CONSTITUTION.md` — authored in
|
|
||||||
capability verbs, with a thin `AGENTS.md` dispatcher that references the law instead of restating
|
|
||||||
it. Governance model in `constitution/LAYER-MODEL.md`.
|
|
||||||
- **Public & sanitized.** MIT-licensed; all operator identity, private paths, and credential sites
|
|
||||||
removed from shipped files. A self-tested `verify-sanitized.sh` gate (two rule classes) runs
|
|
||||||
**blocking** in CI so re-contamination can't merge.
|
|
||||||
- **Upgrade-safe customization.** Framework-owned files overwrite cleanly on upgrade while
|
|
||||||
`SOUL.md`/`USER.md`/`*.local.md`/`credentials` are preserved. The v2→v3 migration snapshots first
|
|
||||||
and moves any user-edited `AGENTS.md`/`STANDARDS.md` to `.pre-constitution.bak`/`.local.md` —
|
|
||||||
never silently lost. Verified by a 5-fixture matrix across **both** installers.
|
|
||||||
- **Operator overlays.** `mosaic compose-contract <harness>` merges your `*.local.md` deltas into
|
|
||||||
the contract per harness, so customization reaches the model as one pre-merged blob.
|
|
||||||
- **Cross-harness.** Single L0 source referenced (never restated) by Claude / Codex / OpenCode / Pi;
|
|
||||||
tiered injection with a byte-equal Tier-3 fallback read.
|
|
||||||
- **Guardrails in CI.** Resident line-count ceiling over framework-owned resident files; composer
|
|
||||||
unit test; sanitization gate — all blocking.
|
|
||||||
- **Docs.** `CONTRIBUTING.md` with the layer model, dual-installer parity rule, and a harness×gate
|
|
||||||
**compliance matrix** (the Codex/OpenCode/Pi hook-parity gap is tracked for v2).
|
|
||||||
|
|
||||||
**Known limitations (accepted, documented in `CONTRIBUTING.md` §9)**
|
|
||||||
|
|
||||||
- Bare launches that bypass `mosaic` get base contracts only (no `*.local` overlays) and are not
|
|
||||||
drift-checked by `mosaic doctor` — mitigated by the unconditional Tier-3 self-load + a nudge.
|
|
||||||
- Codex/OpenCode/Pi mechanical hook parity, `policy/*.md` composition, and live-launch cross-harness
|
|
||||||
verification are **v2**.
|
|
||||||
|
|
||||||
**Phase lineage:** P0 #570 · P1+P2 #572 · P3 #575/#577 · P4 #590/#593 · P5 #605 · P6 #607 ·
|
|
||||||
aiguide #8 (umbrella #542).
|
|
||||||
@@ -91,22 +91,22 @@ Goal: Two federated gateways exchange real data over mTLS. Inbound requests pass
|
|||||||
>
|
>
|
||||||
> **Tracking issue:** #462.
|
> **Tracking issue:** #462.
|
||||||
|
|
||||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ------------------------------------ | --------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ------------------------------------ | ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| FED-M3-01 | done | `packages/types/src/federation/` — request/response DTOs for `list`, `get`, `capabilities` verbs. Wire-format zod schemas + inferred TS types. Includes `FederationRequest`, `FederationListResponse<T>`, `FederationGetResponse<T>`, `FederationCapabilitiesResponse`, error envelope, `_source` tag. | #462 | sonnet | feat/federation-m3-types | — | 4K | Reusable from gateway server + client + harness. Pure types — no I/O, no NestJS. |
|
| FED-M3-01 | not-started | `packages/types/src/federation/` — request/response DTOs for `list`, `get`, `capabilities` verbs. Wire-format zod schemas + inferred TS types. Includes `FederationRequest`, `FederationListResponse<T>`, `FederationGetResponse<T>`, `FederationCapabilitiesResponse`, error envelope, `_source` tag. | #462 | sonnet | feat/federation-m3-types | — | 4K | Reusable from gateway server + client + harness. Pure types — no I/O, no NestJS. |
|
||||||
| FED-M3-02 | done | `tools/federation-harness/` scaffold: `docker-compose.two-gateways.yml` (Server A + Server B + step-CA), `seed.ts` (provisions grants, peers, sample tasks/notes/credentials per scope variant), `harness.ts` helper (boots stack, returns typed clients). README documents harness use. | #462 | sonnet | feat/federation-m3-harness | DEPLOY-04 (soft) | 8K | Falls back to local docker-compose if `mos-test-1/-2` not yet redeployed (DEPLOY chain blocked on IMG-FIX). Permanent test infra used by M3+. |
|
| FED-M3-02 | not-started | `tools/federation-harness/` scaffold: `docker-compose.two-gateways.yml` (Server A + Server B + step-CA), `seed.ts` (provisions grants, peers, sample tasks/notes/credentials per scope variant), `harness.ts` helper (boots stack, returns typed clients). README documents harness use. | #462 | sonnet | feat/federation-m3-harness | DEPLOY-04 (soft) | 8K | Falls back to local docker-compose if `mos-test-1/-2` not yet redeployed (DEPLOY chain blocked on IMG-FIX). Permanent test infra used by M3+. |
|
||||||
| FED-M3-03 | done | `apps/gateway/src/federation/server/federation-auth.guard.ts` (NestJS guard). Validates inbound client cert from Fastify TLS context, extracts `grantId` + `subjectUserId` from custom OIDs, loads grant from DB, asserts `status='active'`, attaches `FederationContext` to request. | #462 | sonnet | feat/federation-m3-auth-guard | M3-01 | 8K | Reuses OID parsing logic mirrored from `ca.service.ts` post-issuance verification. 401 on malformed/missing OIDs; 403 on revoked/expired/missing grant. |
|
| FED-M3-03 | not-started | `apps/gateway/src/federation/server/federation-auth.guard.ts` (NestJS guard). Validates inbound client cert from Fastify TLS context, extracts `grantId` + `subjectUserId` from custom OIDs, loads grant from DB, asserts `status='active'`, attaches `FederationContext` to request. | #462 | sonnet | feat/federation-m3-auth-guard | M3-01 | 8K | Reuses OID parsing logic mirrored from `ca.service.ts` post-issuance verification. 401 on malformed/missing OIDs; 403 on revoked/expired/missing grant. |
|
||||||
| FED-M3-04 | in-progress | `apps/gateway/src/federation/server/scope.service.ts`. Pipeline: (1) resource allowlist + excluded check, (2) native RBAC eval as `subjectUserId`, (3) scope filter intersection (`include_teams`, `include_personal`), (4) `max_rows_per_query` cap. Pure service — DB calls injected. | #462 | sonnet | feat/federation-m3-scope-service | M3-01 | 10K | Hardest correctness target in M3. Reuses `parseFederationScope` (M2-03). Returns either `{ allowed: true, filter }` or structured deny reason for audit. |
|
| FED-M3-04 | not-started | `apps/gateway/src/federation/server/scope.service.ts`. Pipeline: (1) resource allowlist + excluded check, (2) native RBAC eval as `subjectUserId`, (3) scope filter intersection (`include_teams`, `include_personal`), (4) `max_rows_per_query` cap. Pure service — DB calls injected. | #462 | sonnet | feat/federation-m3-scope-service | M3-01 | 10K | Hardest correctness target in M3. Reuses `parseFederationScope` (M2-03). Returns either `{ allowed: true, filter }` or structured deny reason for audit. |
|
||||||
| FED-M3-05 | in-progress | `apps/gateway/src/federation/server/verbs/list.controller.ts`. Wires AuthGuard → ScopeService → tasks/notes/memory query layer; applies row cap; tags rows with `_source`. Resource selector via path param. | #462 | sonnet | feat/federation-m3-verb-list | M3-03, M3-04 | 6K | Routes: `POST /api/federation/v1/list/:resource`. No body persistence. Audit write deferred to M4. |
|
| FED-M3-05 | not-started | `apps/gateway/src/federation/server/verbs/list.controller.ts`. Wires AuthGuard → ScopeService → tasks/notes/memory query layer; applies row cap; tags rows with `_source`. Resource selector via path param. | #462 | sonnet | feat/federation-m3-verb-list | M3-03, M3-04 | 6K | Routes: `POST /api/federation/v1/list/:resource`. No body persistence. Audit write deferred to M4. |
|
||||||
| FED-M3-06 | not-started | `apps/gateway/src/federation/server/verbs/get.controller.ts`. Single-resource fetch by id; same pipeline as list. 404 on not-found, 403 on RBAC/scope deny — both audited the same way. | #462 | sonnet | feat/federation-m3-verb-get | M3-03, M3-04 | 6K | `POST /api/federation/v1/get/:resource/:id`. Mirrors list controller patterns. |
|
| FED-M3-06 | not-started | `apps/gateway/src/federation/server/verbs/get.controller.ts`. Single-resource fetch by id; same pipeline as list. 404 on not-found, 403 on RBAC/scope deny — both audited the same way. | #462 | sonnet | feat/federation-m3-verb-get | M3-03, M3-04 | 6K | `POST /api/federation/v1/get/:resource/:id`. Mirrors list controller patterns. |
|
||||||
| FED-M3-07 | done | `apps/gateway/src/federation/server/verbs/capabilities.controller.ts`. Read-only enumeration: returns `{ resources, excluded_resources, max_rows_per_query, supported_verbs }` derived from grant scope. Always allowed for an active grant — no RBAC eval. | #462 | sonnet | feat/federation-m3-verb-capabilities | M3-03 | 4K | `GET /api/federation/v1/capabilities`. Smallest verb; useful sanity check that mTLS + auth guard work end-to-end. |
|
| FED-M3-07 | not-started | `apps/gateway/src/federation/server/verbs/capabilities.controller.ts`. Read-only enumeration: returns `{ resources, excluded_resources, max_rows_per_query, supported_verbs }` derived from grant scope. Always allowed for an active grant — no RBAC eval. | #462 | sonnet | feat/federation-m3-verb-capabilities | M3-03 | 4K | `GET /api/federation/v1/capabilities`. Smallest verb; useful sanity check that mTLS + auth guard work end-to-end. |
|
||||||
| FED-M3-08 | done | `apps/gateway/src/federation/client/federation-client.service.ts`. Outbound mTLS dialer: picks `(certPem, sealed clientKey)` from `federation_peers`, unwraps key, builds undici Agent with mTLS, calls peer verb, parses typed response, wraps non-2xx into `FederationClientError`. | #462 | sonnet | feat/federation-m3-client | M3-01 | 8K | Independent of server stream — can land in parallel with M3-03/04. Cert/key cached per-peer; flushed by future M5/M6 logic. |
|
| FED-M3-08 | not-started | `apps/gateway/src/federation/client/federation-client.service.ts`. Outbound mTLS dialer: picks `(certPem, sealed clientKey)` from `federation_peers`, unwraps key, builds undici Agent with mTLS, calls peer verb, parses typed response, wraps non-2xx into `FederationClientError`. | #462 | sonnet | feat/federation-m3-client | M3-01 | 8K | Independent of server stream — can land in parallel with M3-03/04. Cert/key cached per-peer; flushed by future M5/M6 logic. |
|
||||||
| FED-M3-09 | done | `apps/gateway/src/federation/client/query-source.service.ts`. Accepts `source: "local" \| "federated:<host>" \| "all"` from gateway query layer; for `"all"` fans out to local + each peer in parallel; merges results; tags every row with `_source`. | #462 | sonnet | feat/federation-m3-query-source | M3-08 | 8K | Per-peer failure surfaces as `_partial: true` in response, not hard failure (sets up M5 offline UX). M5 adds caching + circuit breaker on top. |
|
| FED-M3-09 | not-started | `apps/gateway/src/federation/client/query-source.service.ts`. Accepts `source: "local" \| "federated:<host>" \| "all"` from gateway query layer; for `"all"` fans out to local + each peer in parallel; merges results; tags every row with `_source`. | #462 | sonnet | feat/federation-m3-query-source | M3-08 | 8K | Per-peer failure surfaces as `_partial: true` in response, not hard failure (sets up M5 offline UX). M5 adds caching + circuit breaker on top. |
|
||||||
| FED-M3-10 | not-started | Integration tests for MILESTONES.md M3 acceptance #6 (malformed OIDs → 401; valid cert + revoked grant → 403) and #7 (`max_rows_per_query` cap). Real PG, mocked TLS context (Fastify req shim). | #462 | sonnet | feat/federation-m3-integration | M3-05, M3-06 | 8K | Vitest profile gated by `FEDERATED_INTEGRATION=1`. Single-gateway suite; no harness required. |
|
| FED-M3-10 | not-started | Integration tests for MILESTONES.md M3 acceptance #6 (malformed OIDs → 401; valid cert + revoked grant → 403) and #7 (`max_rows_per_query` cap). Real PG, mocked TLS context (Fastify req shim). | #462 | sonnet | feat/federation-m3-integration | M3-05, M3-06 | 8K | Vitest profile gated by `FEDERATED_INTEGRATION=1`. Single-gateway suite; no harness required. |
|
||||||
| FED-M3-11 | not-started | E2E tests for MILESTONES.md M3 acceptance #1, #2, #3, #4, #5, #8, #9, #10 (8 cases). Uses harness from M3-02; two real gateways, real Step-CA, real mTLS. Each test asserts both happy-path response and audit/no-persist invariants. | #462 | sonnet | feat/federation-m3-e2e | M3-02, M3-04, M3-05, M3-06, M3-09 | 12K | Largest single task. Each acceptance gets its own `it(...)` for clear failure attribution. |
|
| FED-M3-11 | not-started | E2E tests for MILESTONES.md M3 acceptance #1, #2, #3, #4, #5, #8, #9, #10 (8 cases). Uses harness from M3-02; two real gateways, real Step-CA, real mTLS. Each test asserts both happy-path response and audit/no-persist invariants. | #462 | sonnet | feat/federation-m3-e2e | M3-02, M3-09 | 12K | Largest single task. Each acceptance gets its own `it(...)` for clear failure attribution. |
|
||||||
| FED-M3-12 | not-started | Independent security review (sonnet, not author of M3-03/04/05/06/07/08/09): focus on cert-SAN spoofing, OID extraction edge cases, scope-bypass via filter manipulation, RBAC-bypass via subjectUser swap, response leakage when scope deny. | #462 | sonnet | feat/federation-m3-security-review | M3-11 | 10K | Two review rounds budgeted. PRD requires explicit test for every 401/403 path — review verifies coverage. |
|
| FED-M3-12 | not-started | Independent security review (sonnet, not author of M3-03/04/05/06/07/08/09): focus on cert-SAN spoofing, OID extraction edge cases, scope-bypass via filter manipulation, RBAC-bypass via subjectUser swap, response leakage when scope deny. | #462 | sonnet | feat/federation-m3-security-review | M3-11 | 10K | Two review rounds budgeted. PRD requires explicit test for every 401/403 path — review verifies coverage. |
|
||||||
| FED-M3-13 | not-started | Docs update: `docs/federation/SETUP.md` mTLS handshake section, new `docs/federation/HARNESS.md` for federation-harness usage, OID reference table in SETUP.md, scope enforcement pipeline diagram. Runbook still M7-deferred. | #462 | haiku | feat/federation-m3-docs | M3-12 | 5K | One ASCII diagram for the auth-guard → scope → RBAC pipeline; helps future reviewers reason about denial paths. |
|
| FED-M3-13 | not-started | Docs update: `docs/federation/SETUP.md` mTLS handshake section, new `docs/federation/HARNESS.md` for federation-harness usage, OID reference table in SETUP.md, scope enforcement pipeline diagram. Runbook still M7-deferred. | #462 | haiku | feat/federation-m3-docs | M3-12 | 5K | One ASCII diagram for the auth-guard → scope → RBAC pipeline; helps future reviewers reason about denial paths. |
|
||||||
| FED-M3-14 | not-started | PR aggregate close, CI green, merge to main, close #462. Release tag `fed-v0.3.0-m3`. Update mission manifest M3 row → done; M4 row → in-progress when work begins. | #462 | sonnet | chore/federation-m3-close | M3-13 | 3K | Same close pattern as M1-12 / M2-13. |
|
| FED-M3-14 | not-started | PR aggregate close, CI green, merge to main, close #462. Release tag `fed-v0.3.0-m3`. Update mission manifest M3 row → done; M4 row → in-progress when work begins. | #462 | sonnet | chore/federation-m3-close | M3-13 | 3K | Same close pattern as M1-12 / M2-13. |
|
||||||
|
|
||||||
**M3 estimate:** ~100K tokens (vs MILESTONES.md 40K — same per-task breakdown pattern as M1/M2: tests, review, and docs split out from implementation cost). Largest milestone in the federation mission.
|
**M3 estimate:** ~100K tokens (vs MILESTONES.md 40K — same per-task breakdown pattern as M1/M2: tests, review, and docs split out from implementation cost). Largest milestone in the federation mission.
|
||||||
|
|
||||||
@@ -118,10 +118,6 @@ Goal: Two federated gateways exchange real data over mTLS. Inbound requests pass
|
|||||||
|
|
||||||
**Test bed fallback:** If `mos-test-1.woltje.com` / `mos-test-2.woltje.com` are still blocked on `FED-M2-DEPLOY-IMG-FIX` when M3-11 is ready to run, the harness's local `docker-compose.two-gateways.yml` is a sufficient stand-in. Production-host validation moves to M7 acceptance suite (PRD AC-12).
|
**Test bed fallback:** If `mos-test-1.woltje.com` / `mos-test-2.woltje.com` are still blocked on `FED-M2-DEPLOY-IMG-FIX` when M3-11 is ready to run, the harness's local `docker-compose.two-gateways.yml` is a sufficient stand-in. Production-host validation moves to M7 acceptance suite (PRD AC-12).
|
||||||
|
|
||||||
**Backlog sync — 2026-06-24 (orchestrator):** Status reconciled against `origin/main` (release 0.0.48). Landed on main: **FED-M3-01** (DTOs, PR #506), **FED-M3-02** (harness scaffold, PR #505), **FED-M3-03** (mTLS auth-guard, PR #509 — CRIT-1/2 + HIGH-1..4 remediated in-PR), **FED-M3-08** (outbound mTLS client, PR #508). With M3-01/03/08 merged, three cards became dependency-clear and were dispatched to the idle coder lane: **FED-M3-04** scope.service → coder0 (`feat/federation-m3-scope-service`); **FED-M3-09** query-source + **FED-M3-07** capabilities verb → coder1 (`feat/federation-m3-query-source` first). Reviewer warmed for the M3 trust-boundary PRs. Remaining blocked-by-DAG: M3-05/06 (await M3-04), M3-10 (await M3-05/06), M3-11 (await M3-09), M3-12→14 (tail). Deploy chain (DEPLOY-IMG-FIX → 03/04) still independent of M3 code — harness local docker-compose fallback covers M3-11.
|
|
||||||
|
|
||||||
**Backlog sync #2 — 2026-06-24 (orchestrator):** **FED-M3-09** (query-source) merged via PR #673 and **FED-M3-07** (capabilities) merged via PR #674 — both squash-merged on independent agent review-of-record + green CI (formal Gitea approve unavailable under the shared service account; merge is not gated by the self-approve guard). **FED-M3-05** (list verb) dispatched to coder1 (based on the M3-04 branch, rebase onto main once #672 lands). **FED-M3-04** (scope.service, PR #672) is in review-changes (one include_personal no-leak test outstanding). **DAG fix:** corrected `FED-M3-11` depends_on from `M3-02, M3-09` → `M3-02, M3-04, M3-05, M3-06, M3-09` — the E2E acceptance cases (#1–#5, #8–#10) exercise list/get over mTLS, so the server verbs + scope service are hard prerequisites; the original edge set omitted them and caused a premature M3-11 dispatch. Note: M3 read-path invariant for M3-11 is **no-persist + existing enrollment audit only** — read-verb audit-log writes are deferred to M4 (see M3-05/06 notes), so M3-11 must not assert read-audit-log entries.
|
|
||||||
|
|
||||||
## Milestone 4 — search + audit + rate limit (FED-M4)
|
## Milestone 4 — search + audit + rate limit (FED-M4)
|
||||||
|
|
||||||
_Deferred. Issue #463._
|
_Deferred. Issue #463._
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
# Fleet Launch Runbook
|
|
||||||
|
|
||||||
How every Mosaic fleet agent — workers **and** the orchestrator — is launched, and how to
|
|
||||||
configure each one. The guiding principle: **one roster-driven launcher**. There is no bespoke
|
|
||||||
per-agent launch script; the roster plus per-agent `.env` files are the single source of launch
|
|
||||||
config.
|
|
||||||
|
|
||||||
## The launch chain
|
|
||||||
|
|
||||||
| Layer | File | Responsibility |
|
|
||||||
| ---------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| systemd unit | `mosaic-agent@<role>.service` | One templated unit per role; `ExecStart` runs the session launcher with the instance name `%i`. Defaults `MOSAIC_AGENT_RUNTIME=pi`, `MOSAIC_AGENT_NAME=%i`. |
|
|
||||||
| session launcher | `tools/fleet/start-agent-session.sh <role>` | Builds the launch command, opens the tmux pane, wires the heartbeat. |
|
|
||||||
| launch command | `mosaic yolo <runtime>` (or a per-agent override) | Replaces the pane's foreground process with the runtime, fully seeded. |
|
|
||||||
| seeding | `mosaic`'s `composeContract()` | Injects the Constitution/USER/TOOLS/runtime contract, `*.local` overlays, **and** the Fleet-Comms cheat-sheet — all via `--append-system-prompt`. |
|
|
||||||
|
|
||||||
Per-agent overrides live in `fleet/agents/<role>.env`, generated from `roster.yaml` by
|
|
||||||
`generateAgentEnv` (`packages/mosaic/src/commands/fleet.ts`) and consumed by the launcher.
|
|
||||||
|
|
||||||
## Worker launch path (default)
|
|
||||||
|
|
||||||
1. `roster.yaml` carries each agent's `runtime` and optional `model_hint`.
|
|
||||||
2. `generateAgentEnv` emits `fleet/agents/<role>.env` with `MOSAIC_AGENT_NAME`,
|
|
||||||
`MOSAIC_AGENT_RUNTIME`, and `MOSAIC_AGENT_MODEL`.
|
|
||||||
3. `start-agent-session.sh` has no `MOSAIC_AGENT_COMMAND` set, so it falls through to the default
|
|
||||||
(line ~44):
|
|
||||||
```sh
|
|
||||||
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}"
|
|
||||||
```
|
|
||||||
4. The launcher bakes `MOSAIC_AGENT_NAME` into the pane command (line ~118), so `composeContract`
|
|
||||||
can inject the Fleet-Comms cheat-sheet for that role.
|
|
||||||
|
|
||||||
That is the whole worker path: roster → `.env` → `mosaic yolo <runtime>` → seeded pane.
|
|
||||||
|
|
||||||
## Orchestrator fold (PATH A — ships today)
|
|
||||||
|
|
||||||
The orchestrator is **just another roster agent** launched through the canonical path — not a
|
|
||||||
snowflake script.
|
|
||||||
|
|
||||||
| Piece | Value |
|
|
||||||
| ------------------ | ----------------------------------- |
|
|
||||||
| host-side launcher | `orchestrator-launch.sh` |
|
|
||||||
| systemd unit | `mosaic-fleet-orchestrator.service` |
|
|
||||||
| tmux session | `orchestrator` (role-named) |
|
|
||||||
|
|
||||||
Set its launch command via `fleet/agents/orchestrator.env`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
MOSAIC_AGENT_COMMAND='mosaic yolo claude --channels plugin:discord@<channel>'
|
|
||||||
```
|
|
||||||
|
|
||||||
When `MOSAIC_AGENT_COMMAND` is set, `start-agent-session.sh`'s `if [ -z "$MOSAIC_AGENT_COMMAND" ]`
|
|
||||||
guard (line ~41) is false, so the line-44 default — **including its hardcoded `yolo`** — is skipped
|
|
||||||
entirely. The override fully controls the runtime and flags. Routing through `mosaic yolo claude`
|
|
||||||
(rather than a raw `claude` invocation) is what gives the orchestrator the same full
|
|
||||||
`composeContract` seeding + Fleet-Comms cheat-sheet as every worker, with `--channels` and any
|
|
||||||
other flags passed straight through to the `claude` binary.
|
|
||||||
|
|
||||||
## Launch gotchas
|
|
||||||
|
|
||||||
1. **Flag conflict.** `mosaic yolo claude` already injects `--dangerously-skip-permissions`. Do
|
|
||||||
**not** also pass `--permission-mode bypassPermissions` — the `claude` binary would receive both.
|
|
||||||
Use `mosaic yolo claude …` alone (yolo covers the unattended posture), **or** non-yolo
|
|
||||||
`mosaic claude --permission-mode bypassPermissions …`. Never mix the two.
|
|
||||||
2. **`MOSAIC_AGENT_NAME` must reach the pane.** The launcher bakes it from the instance name, and
|
|
||||||
`composeContract` gates the Fleet-Comms block on it (`launch.ts`, in `composeContract`) — **and**
|
|
||||||
the role must be a member of `roster.yaml`, or the block resolves empty.
|
|
||||||
3. **`launchRuntime` guards.** `mosaic yolo claude` runs `checkSoul` / `checkRuntime` /
|
|
||||||
`checkSequentialThinking`. The host needs `SOUL.md` and the sequential-thinking MCP, or the
|
|
||||||
launch aborts (a raw `claude` invocation skipped these checks). Dry-run the composed command in a
|
|
||||||
throwaway tmux session before swapping a live launcher.
|
|
||||||
|
|
||||||
## Why per-agent `.env` survives upgrades (#632)
|
|
||||||
|
|
||||||
`install.sh` `PRESERVE_PATHS` includes `fleet/*.yaml`, `fleet/agents`, and `fleet/run`, so
|
|
||||||
`mosaic update`'s framework re-seed **preserves** your roster and per-agent `.env` overrides
|
|
||||||
(glob-aware `cp` fallback; matching TS parity in `file-adapter.ts`). Before #632, an auto re-seed
|
|
||||||
could wipe them — which is exactly why PATH A's `.env` override is safe to rely on now.
|
|
||||||
|
|
||||||
## Inspecting the comms wiring
|
|
||||||
|
|
||||||
- `mosaic fleet comms-block <role>` prints the Fleet-Comms cheat-sheet a given role receives at
|
|
||||||
launch — its `[host:session]` identity, the exact `agent-send.sh` command for each peer, and the
|
|
||||||
FLIP / `--verify` conventions. `--host <h>` previews a cross-host view. An unknown role or missing
|
|
||||||
roster **fails loud** (stderr + non-zero exit), so a typo is never a silent no-op.
|
|
||||||
- Versus `mosaic compose-contract <runtime>`: that emits the **whole** system prompt and reads the
|
|
||||||
role from `MOSAIC_AGENT_NAME` (a full-prompt smoke test). `comms-block` is the targeted,
|
|
||||||
explicit-arg, comms-only view — e.g. `mosaic fleet comms-block coder0-0` to preview a peer.
|
|
||||||
|
|
||||||
## North Star / future direction
|
|
||||||
|
|
||||||
**Vision:** a webUI lets the user edit each agent's launch config — switch **harness**
|
|
||||||
(claude / pi / codex / opencode), toggle **yolo**, pick a **model**, set a **command/channels**
|
|
||||||
override — with no terminal.
|
|
||||||
|
|
||||||
**Continuity — this is not a new launch path.** It is a data-model + UI-binding layer over the
|
|
||||||
existing roster-driven launcher. Field-by-field status today:
|
|
||||||
|
|
||||||
| Launch-config field | Roster-native today? | Mechanism / gap |
|
|
||||||
| ------------------------ | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **harness** (`runtime`) | ✅ end-to-end | `roster.runtime` → `generateAgentEnv` emits `MOSAIC_AGENT_RUNTIME` → launcher line 44. UI just writes the field. |
|
|
||||||
| **model** (`model_hint`) | ✅ end-to-end | `roster.model_hint` → `MOSAIC_AGENT_MODEL` → launcher line 44 `--model`. UI just writes the field. |
|
|
||||||
| **yolo** | ❌ new | Launcher line 44 **hardcodes** `mosaic yolo`. A non-yolo toggle needs a roster `yolo` field → emit `MOSAIC_AGENT_YOLO` → make line 44 conditional. |
|
|
||||||
| **command / channels** | ❌ new | `MOSAIC_AGENT_COMMAND` is **consumed** (launcher line ~12) but `generateAgentEnv` does not emit it. Needs a roster `command`/`channels` field → emitted. |
|
|
||||||
|
|
||||||
**The arc:**
|
|
||||||
|
|
||||||
- **A** — `.env` `MOSAIC_AGENT_COMMAND` hatch: manual, ships now, kept safe across upgrades by #632.
|
|
||||||
- **B** — roster-native launch-config: harness + model are already there; add the **yolo** toggle
|
|
||||||
(line-44 conditional) and **command/channels** emission to complete the data model.
|
|
||||||
- **webUI** — binds dropdowns/toggles directly to those four roster fields.
|
|
||||||
|
|
||||||
PATH A's `.env` override is the **manual form** of exactly what PATH B makes roster-native and the
|
|
||||||
webUI edits — one continuous arc, not three separate features. PATH B is tracked as #636.
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
# Mosaic Fleet — NORTH STAR
|
|
||||||
|
|
||||||
> **Generated file — do not edit by hand.**
|
|
||||||
> Projected deterministically from [`NORTH_STAR.yaml`](./NORTH_STAR.yaml) by the pure
|
|
||||||
> generator in `packages/mosaic/src/commands/fleet.ts` (`renderNorthStarMarkdown`).
|
|
||||||
> Edit the YAML, then regenerate. Self-contained Mosaic — no Hermes dependency.
|
|
||||||
|
|
||||||
## Mission
|
|
||||||
|
|
||||||
A self-driving Mosaic system that 24/7 unattended converts a machine-readable goal set into merged, CI-green, budget-bounded change — looping plan→backlog→assign→execute→verify→merge→reassess — on Mosaic's OWN native backlog/dispatch engine. Mosaic is general-purpose: the user declares the system type they want (software delivery, personal assistant, research, business/operations, …) and the orchestrator provisions the matching persona roster and structure; the delivery fleet is one profile among many.
|
|
||||||
|
|
||||||
## Substrate
|
|
||||||
|
|
||||||
The Mosaic Backlog is the backlog of record + dispatch engine, built on Mosaic's native Postgres storage service (@mosaicstack/db drizzle; PGlite-embedded by default, full Postgres by config). NOT Hermes.
|
|
||||||
|
|
||||||
## Standing objectives
|
|
||||||
|
|
||||||
- **NS-1** — Single machine-readable source (this file) drives planning; prose docs are projections.
|
|
||||||
- **NS-2** — Every backlog item is an independently-shippable unit with stable id, priority, depends_on DAG, represented as a Mosaic Backlog card; spend tracked as advisory projection.
|
|
||||||
- **NS-3** — The supervisor guarantees movement: no idle agent while ready dependency-satisfied work exists; no empty backlog without a replan request; assignment via Mosaic native dispatch/claim.
|
|
||||||
- **NS-4** — Exactly one merge-gate approver; nothing reaches main except via pr-merge.sh after pr-ci-wait.sh success; Gitea branch protection is the backstop.
|
|
||||||
- **NS-5** — Every unit bounded by wall-clock TTL on its claim; token caps enforced only where a real meter exists, else advisory.
|
|
||||||
- **NS-6** — Context cleared between tasks for ephemeral runners (reset_between_tasks); persona+mission re-injected per task.
|
|
||||||
- **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-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.
|
|
||||||
|
|
||||||
## Success criteria
|
|
||||||
|
|
||||||
- **AC-NS-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-3** — 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-5** — 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-7** — A user-customized persona (edited or added via the orchestrator) survives `mosaic update`: baseline reseed never clobbers user overrides.
|
|
||||||
|
|
||||||
## Workstreams
|
|
||||||
|
|
||||||
| id | title |
|
|
||||||
| --- | ----------------------------------------------------------------------------------------------------------- |
|
|
||||||
| A | Substrate — Mosaic Backlog on native Postgres storage service |
|
|
||||||
| B | Supervisor — movement guarantee, two-agent floor, dispatch/claim |
|
|
||||||
| C | Planner — goal decomposition into independently-shippable cards |
|
|
||||||
| D | Merge-gate — single approver, pr-merge.sh after CI wait |
|
|
||||||
| E | Meta-loop — session-review + enhancer improvement PRs |
|
|
||||||
| F | Safety-rails — TTL claims, advisory spend, PAUSE kill-switch |
|
|
||||||
| H | Personas & system profiles — cross-domain library, system-type provisioning, update-surviving customization |
|
|
||||||
|
|
||||||
## Goals (backlog projection)
|
|
||||||
|
|
||||||
| id | title | phase | priority | depends_on |
|
|
||||||
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ----------- | ---------- |
|
|
||||||
| A1 | Machine-readable NORTH_STAR.yaml + Markdown projection | 1 | must-have | — |
|
|
||||||
| 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 | must-have | A2 |
|
|
||||||
| 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 | should-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 | 2 | must-have | A3b, B1 |
|
|
||||||
| B3a | Planner decompose — goal added to YAML → cards | 2 | must-have | A2, B1 |
|
|
||||||
| 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 | 2 | must-have | B2 |
|
|
||||||
| 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 | must-have | H1 |
|
|
||||||
| 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 | must-have | H1 |
|
|
||||||
|
|
||||||
## Assumptions (vetoable)
|
|
||||||
|
|
||||||
- **ASM-1** (vetoable) — The Mosaic Backlog on the native Postgres storage service is the backlog of record.
|
|
||||||
- **ASM-2** (vetoable) — Claude gate roles have no native busy status, so readiness = pane-idle + heartbeat.
|
|
||||||
- **ASM-3** (vetoable) — Two-agent floor = 1 orchestrator + >=1 enhancer.
|
|
||||||
- **ASM-4** (vetoable) — Baseline personas ship in framework/fleet/roles/ (reseeded on update); user overrides live in a separate PRESERVE_PATHS-protected layer and win on merge.
|
|
||||||
|
|
||||||
## Spend
|
|
||||||
|
|
||||||
- **advisory:** true
|
|
||||||
- No per-task token meter yet; budgets degrade to TTL. Spend is tracked only as an advisory projection alongside each card.
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
# Mosaic Fleet — NORTH_STAR (machine-readable source of truth)
|
|
||||||
#
|
|
||||||
# This file is the single machine-readable source of truth for fleet planning.
|
|
||||||
# Prose docs (including NORTH_STAR.md) are deterministic PROJECTIONS of this file.
|
|
||||||
# Regenerate the Markdown projection with the pure generator in
|
|
||||||
# packages/mosaic/src/commands/fleet.ts (renderNorthStarMarkdown). Edit the YAML,
|
|
||||||
# never the .md.
|
|
||||||
#
|
|
||||||
# Self-contained Mosaic. NO Hermes runtime dependency. The backlog of record is
|
|
||||||
# the Mosaic Backlog on Mosaic's OWN native Postgres storage service.
|
|
||||||
|
|
||||||
version: 1
|
|
||||||
|
|
||||||
mission: >-
|
|
||||||
A self-driving Mosaic system that 24/7 unattended converts a machine-readable
|
|
||||||
goal set into merged, CI-green, budget-bounded change — looping
|
|
||||||
plan→backlog→assign→execute→verify→merge→reassess — on Mosaic's OWN native
|
|
||||||
backlog/dispatch engine. Mosaic is general-purpose: the user declares the
|
|
||||||
system type they want (software delivery, personal assistant, research,
|
|
||||||
business/operations, …) and the orchestrator provisions the matching persona
|
|
||||||
roster and structure; the delivery fleet is one profile among many.
|
|
||||||
|
|
||||||
substrate:
|
|
||||||
note: >-
|
|
||||||
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:
|
|
||||||
- id: NS-1
|
|
||||||
text: >-
|
|
||||||
Single machine-readable source (this file) drives planning; prose docs are
|
|
||||||
projections.
|
|
||||||
- id: NS-2
|
|
||||||
text: >-
|
|
||||||
Every backlog item is an independently-shippable unit with stable id,
|
|
||||||
priority, depends_on DAG, represented as a Mosaic Backlog card; spend
|
|
||||||
tracked as advisory projection.
|
|
||||||
- id: NS-3
|
|
||||||
text: >-
|
|
||||||
The supervisor guarantees movement: no idle agent while ready
|
|
||||||
dependency-satisfied work exists; no empty backlog without a replan
|
|
||||||
request; assignment via Mosaic native dispatch/claim.
|
|
||||||
- id: NS-4
|
|
||||||
text: >-
|
|
||||||
Exactly one merge-gate approver; nothing reaches main except via
|
|
||||||
pr-merge.sh after pr-ci-wait.sh success; Gitea branch protection is the
|
|
||||||
backstop.
|
|
||||||
- id: NS-5
|
|
||||||
text: >-
|
|
||||||
Every unit bounded by wall-clock TTL on its claim; token caps enforced
|
|
||||||
only where a real meter exists, else advisory.
|
|
||||||
- id: NS-6
|
|
||||||
text: >-
|
|
||||||
Context cleared between tasks for ephemeral runners
|
|
||||||
(reset_between_tasks); persona+mission re-injected per task.
|
|
||||||
- id: NS-7
|
|
||||||
text: >-
|
|
||||||
Meta-loop (session-review + enhancer) continuously proposes small
|
|
||||||
fleet-improvement PRs.
|
|
||||||
- id: NS-8
|
|
||||||
text: >-
|
|
||||||
Single operator-flippable PAUSE kill-switch (fleet/run/PAUSED) honored
|
|
||||||
before every dispatch and every merge.
|
|
||||||
- id: NS-9
|
|
||||||
text: >-
|
|
||||||
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.
|
|
||||||
|
|
||||||
success_criteria:
|
|
||||||
- id: AC-NS-1
|
|
||||||
text: >-
|
|
||||||
The supervisor keeps a two-agent floor (1 orchestrator + >=1 enhancer)
|
|
||||||
healthy across reboot.
|
|
||||||
- id: AC-NS-2
|
|
||||||
text: >-
|
|
||||||
A goal added to this YAML is decomposed to cards and either merged or
|
|
||||||
escalated, with no human in the loop.
|
|
||||||
- id: AC-NS-3
|
|
||||||
text: >-
|
|
||||||
No PR merges with failure/error/no-status/timeout CI, and none bypass
|
|
||||||
pr-merge.sh.
|
|
||||||
- id: AC-NS-4
|
|
||||||
text: >-
|
|
||||||
TTL is enforced on claims; token caps remain advisory until a real meter
|
|
||||||
exists.
|
|
||||||
- id: AC-NS-5
|
|
||||||
text: >-
|
|
||||||
Flipping fleet/run/PAUSED halts dispatch and merges within one tick.
|
|
||||||
- id: AC-NS-6
|
|
||||||
text: >-
|
|
||||||
A user can declare a system type and the fleet provisions the matching
|
|
||||||
persona roster + topology from the baseline library, with no code change.
|
|
||||||
- id: AC-NS-7
|
|
||||||
text: >-
|
|
||||||
A user-customized persona (edited or added via the orchestrator) survives
|
|
||||||
`mosaic update`: baseline reseed never clobbers user overrides.
|
|
||||||
|
|
||||||
workstreams:
|
|
||||||
- id: A
|
|
||||||
title: Substrate — Mosaic Backlog on native Postgres storage service
|
|
||||||
- id: B
|
|
||||||
title: Supervisor — movement guarantee, two-agent floor, dispatch/claim
|
|
||||||
- id: C
|
|
||||||
title: Planner — goal decomposition into independently-shippable cards
|
|
||||||
- id: D
|
|
||||||
title: Merge-gate — single approver, pr-merge.sh after CI wait
|
|
||||||
- id: E
|
|
||||||
title: Meta-loop — session-review + enhancer improvement PRs
|
|
||||||
- id: F
|
|
||||||
title: Safety-rails — TTL claims, advisory spend, PAUSE kill-switch
|
|
||||||
- id: H
|
|
||||||
title: Personas & system profiles — cross-domain library, system-type provisioning, update-surviving customization
|
|
||||||
|
|
||||||
goals:
|
|
||||||
- id: A1
|
|
||||||
title: Machine-readable NORTH_STAR.yaml + Markdown projection
|
|
||||||
phase: 1
|
|
||||||
priority: must-have
|
|
||||||
depends_on: []
|
|
||||||
- id: A2
|
|
||||||
title: Mosaic Backlog schema + storage-service card store (drizzle/PGlite)
|
|
||||||
phase: 1
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [A1]
|
|
||||||
- id: A3a
|
|
||||||
title: Card lifecycle — create/claim/release with stable ids + depends_on DAG
|
|
||||||
phase: 1
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [A2]
|
|
||||||
- id: A3b
|
|
||||||
title: TTL-bounded claim enforcement (wall-clock) on cards
|
|
||||||
phase: 1
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [A3a]
|
|
||||||
- id: A4
|
|
||||||
title: Advisory spend projection per card (degrades to TTL, no real meter)
|
|
||||||
phase: 1
|
|
||||||
priority: should-have
|
|
||||||
depends_on: [A3a]
|
|
||||||
- id: B1
|
|
||||||
title: Supervisor tick — readiness scan, two-agent-floor health check
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [A3a]
|
|
||||||
- id: B2
|
|
||||||
title: Native dispatch/claim — assign ready dependency-satisfied work
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [A3b, B1]
|
|
||||||
- id: B3a
|
|
||||||
title: Planner decompose — goal added to YAML → cards
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [A2, B1]
|
|
||||||
- id: B3b
|
|
||||||
title: Replan request on empty backlog; escalate on no-decompose
|
|
||||||
phase: 2
|
|
||||||
priority: should-have
|
|
||||||
depends_on: [B3a]
|
|
||||||
- id: G1
|
|
||||||
title: PAUSE kill-switch + merge-gate honored before dispatch and merge
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [B2]
|
|
||||||
- id: H1
|
|
||||||
title: Cross-domain baseline persona library (exec, marketing, ops, research, assistant + engineering roles)
|
|
||||||
phase: 1
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [A1]
|
|
||||||
- id: H2
|
|
||||||
title: System-type profiles — declarative mapping of system type to persona roster + topology
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [H1]
|
|
||||||
- id: H3
|
|
||||||
title: System-type provisioning — user declares type; orchestrator instantiates the matching roster + structure
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [H2]
|
|
||||||
- id: H4
|
|
||||||
title: Update-surviving persona customization — ad-hoc edits/additions persisted in a PRESERVE-protected override layer (baseline merged with overrides)
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [H1]
|
|
||||||
|
|
||||||
assumptions:
|
|
||||||
- id: ASM-1
|
|
||||||
vetoable: true
|
|
||||||
text: >-
|
|
||||||
The Mosaic Backlog on the native Postgres storage service is the backlog
|
|
||||||
of record.
|
|
||||||
- id: ASM-2
|
|
||||||
vetoable: true
|
|
||||||
text: >-
|
|
||||||
Claude gate roles have no native busy status, so readiness = pane-idle +
|
|
||||||
heartbeat.
|
|
||||||
- id: ASM-3
|
|
||||||
vetoable: true
|
|
||||||
text: 'Two-agent floor = 1 orchestrator + >=1 enhancer.'
|
|
||||||
- id: ASM-4
|
|
||||||
vetoable: true
|
|
||||||
text: >-
|
|
||||||
Baseline personas ship in framework/fleet/roles/ (reseeded on update);
|
|
||||||
user overrides live in a separate PRESERVE_PATHS-protected layer and win
|
|
||||||
on merge.
|
|
||||||
|
|
||||||
spend:
|
|
||||||
advisory: true
|
|
||||||
note: >-
|
|
||||||
No per-task token meter yet; budgets degrade to TTL. Spend is tracked only
|
|
||||||
as an advisory projection alongside each card.
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
# PRD — Mosaic Fleet Suite (init, configure, operate)
|
|
||||||
|
|
||||||
> **Workstream:** W-FLEET (Fleet) under mission `mvp-20260312` · **Phase:** 3→4 productization
|
|
||||||
> **North star:** [docs/fleet/north-star.md](./north-star.md) · prior: Phase-2 observability (#579), durable launch (#581), real-agent enablement (#583/#584/#586), releases 0.0.35–0.0.37
|
|
||||||
> **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.
|
|
||||||
|
|
||||||
## Mission
|
|
||||||
|
|
||||||
Turn the proven fleet primitives into a **user-installable, AI-free-configurable fleet product**:
|
|
||||||
a user runs `mosaic fleet init`, answers a few questions (general / coding / research / hybrid),
|
|
||||||
gets a recommended set of agents plus one always-on orchestrator wired for chat-ops, and can
|
|
||||||
operate, mutate, re-create, and observe the fleet — over tmux today and Matrix tomorrow — from
|
|
||||||
CLI/TUI and (designed-for) the webUI.
|
|
||||||
|
|
||||||
**Immediate tangible goal:** the **"Mos"** orchestrator agent running on `w-jarvis`, reachable
|
|
||||||
in **Discord channel `1517622518662434996`** (server `1112631390438166618`). Once the fleet is
|
|
||||||
functional, we use the fleet itself to continue the work.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### A. Configure-without-AI CLI
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| --- | ------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| R1 | `mosaic fleet` command set is functional end-to-end (init/install/start/stop/status/ps/verify + agent verbs). |
|
|
||||||
| R2 | `mosaic fleet init` is an interactive, **AI-free** CLI wizard. |
|
|
||||||
| R3 | Init asks the **configuration type**: `general`, `coding`, `research`, `hybrid`, … (extensible). |
|
|
||||||
| R4 | Based on the answer, the fleet is populated with a **recommended set of agents** (a preset). |
|
|
||||||
| R5 | **Exactly one main orchestrator agent** is always configured, regardless of type. |
|
|
||||||
| R10 | A set of **recommended configurations (presets)** ships for easy duplication. |
|
|
||||||
| R8 | User can **re-create** the fleet when config needs change (idempotent re-init / reconfigure). |
|
|
||||||
| R17 | Fleet controls are **simple and intuitive**. |
|
|
||||||
|
|
||||||
### B. Comms & orchestrator chat-ops
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| --- | --------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| R6 | Init can wire the orchestrator to a chat connector — **Telegram / Discord / Matrix / Slack** — for command + comms. |
|
|
||||||
| R7 | Designed with the end-goal of **Matrix comms on a locally-controlled server**. |
|
|
||||||
| R16 | Fleet supports **tmux AND Matrix** comms, **user-configurable** at init or any time. Not all users want Matrix. |
|
|
||||||
| R19 | **"Mos" orchestrator on Discord** (`chan 1517622518662434996` / `srv 1112631390438166618`) on `w-jarvis` — the first live target. |
|
|
||||||
|
|
||||||
### C. Runtime, health, lifecycle
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| --- | ---------------------------------------------------------------------------------- |
|
|
||||||
| R9 | Fleet is **mutable by the orchestrator agent** — add/remove agents per need. |
|
|
||||||
| R13 | Fleet **gracefully handles Pi + Claude harness updates** — keep harnesses current. |
|
|
||||||
| R14 | The **Pi harness is customized** for proper tool usage, etc. |
|
|
||||||
| R15 | **Agent heartbeat** properly configured for **Claude AND GPT/Pi** agents. |
|
|
||||||
|
|
||||||
### D. Surfaces, testing, docs
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| --- | ----------------------------------------------------------------------------------- |
|
|
||||||
| R18 | Fleet built so the **webUI can view / monitor / terminate / butt-in** on a session. |
|
|
||||||
| R11 | Installed and **tested on both `w-jarvis` and `dragon-lin`**. |
|
|
||||||
| R12 | **Documentation**: how to install, configure, and use the fleet. |
|
|
||||||
|
|
||||||
## Architecture / approach
|
|
||||||
|
|
||||||
- **Config model:** `roster.yaml` is the source of truth (already exists). Add **presets** (`general`/`coding`/`research`/`hybrid`) as shipped example rosters; `init` selects a preset, always injects the orchestrator, and writes the roster. Re-init = regenerate roster (preserve user/site overrides — mirrors install env-merge from #567).
|
|
||||||
- **Orchestrator agent:** always present; carries the chat connector config (connector type + target IDs) so it can be commanded over chat. tmux is the substrate; the connector bridges chat ↔ the orchestrator session.
|
|
||||||
- **Comms layers (R16):** (1) **tmux** inter-agent (`agent-send`, proven) — default, always available. (2) **chat connector** for human↔orchestrator (Discord now; Matrix the strategic target). (3) **Matrix** as the locally-controlled cross-agent bus (future). Connector is pluggable + reconfigurable.
|
|
||||||
- **Heartbeat (R15):** runtime-agnostic launcher sidecar already covers pi/claude/codex (#584). Refine per-runtime (native HB) with the **custom Pi harness** (R14) + a Claude path.
|
|
||||||
- **Updates (R13):** `mosaic update` (CLI) + a fleet-aware harness-update step that refreshes pi/claude/codex and re-launches agents safely (drain → update → relaunch via the durable launcher).
|
|
||||||
- **webUI (R18):** the fleet exposes machine-readable state (`fleet ps --json` already carries tenant/host/heartbeat/managed) + control verbs (start/stop/watch/send); webUI consumes these (control plane rides federation per north star). Ensure a stable JSON contract + a terminate/attach(butt-in) path.
|
|
||||||
|
|
||||||
## Phases (incremental, each shippable)
|
|
||||||
|
|
||||||
| Phase | Deliverable | Notes |
|
|
||||||
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
|
|
||||||
| **F1 Presets + init wizard** | preset rosters (general/coding/research/hybrid) + always-orchestrator + AI-free `fleet init` selecting a preset; re-init idempotent | R1–R5, R8, R10, R17 |
|
|
||||||
| **F2 Connector + Mos-on-Discord** | orchestrator chat-connector config (Discord first) + **Mos live on Discord `1517…`/`1112…`** on w-jarvis | R6, R19, partial R16 |
|
|
||||||
| **F3 Heartbeat + harness** | HB confirmed for claude + pi/gpt; **custom Pi harness** (tool usage, native HB, model self-report); graceful harness updates | R13, R14, R15 |
|
|
||||||
| **F4 Matrix + comms toggle** | Matrix connector (local server) + user toggle tmux/Matrix at init/anytime | R7, R16 |
|
|
||||||
| **F5 Orchestrator-mutable fleet** | orchestrator can add/remove agents at runtime | R9 |
|
|
||||||
| **F6 webUI hooks** | stable JSON contract + terminate/attach surface for webUI view/monitor/terminate/butt-in | R18 |
|
|
||||||
| **F7 Test + docs** | install+test on w-jarvis AND dragon-lin; user docs (install/configure/use) | R11, R12 (runs alongside every phase) |
|
|
||||||
|
|
||||||
## Work division (proposed — confirm with dragon-lin)
|
|
||||||
|
|
||||||
- **Jarvis @ w-jarvis (Lead):** F1 presets+wizard, F2 connector+Mos-on-Discord, F5 mutability, F6 webUI hooks; merge authority + dual-engine reviews; co-testing on w-jarvis.
|
|
||||||
- **coder @ dragon-lin:** F3 custom Pi harness + harness-update flow (pi/codex-savvy); plus its in-flight constitution P4–P6 (P4 installer rework underpins `fleet init`/updates — coordinate the install path). Co-testing on dragon-lin (R11).
|
|
||||||
- **Shared:** F4 Matrix (whoever has bandwidth); F7 testing/docs continuous.
|
|
||||||
|
|
||||||
## Immediate target: Mos on Discord (F2 first slice)
|
|
||||||
|
|
||||||
The discord plugin is available (`~/.claude.json`). Path: configure the **orchestrator** as a durable
|
|
||||||
fleet session running Claude Code with the discord plugin bridged to channel `1517622518662434996`
|
|
||||||
(server `1112631390438166618`) on w-jarvis, with the existing Discord Bridge Protocol (ack within
|
|
||||||
~3s, reply via `mcp__discord__reply`, no `AskUserQuestion`). Heartbeat via the launcher sidecar.
|
|
||||||
|
|
||||||
## Success criteria
|
|
||||||
|
|
||||||
- A non-AI user can `mosaic fleet init`, pick a type, and get a working fleet + orchestrator.
|
|
||||||
- **Mos answers in Discord `1517…`** on w-jarvis.
|
|
||||||
- Fleet runs + is observable (`fleet ps`) on **both** w-jarvis and dragon-lin.
|
|
||||||
- Harness updates handled gracefully; HB healthy for claude + pi/gpt agents.
|
|
||||||
- Docs let a new operator install/configure/use the fleet.
|
|
||||||
- Re-init + orchestrator mutation work.
|
|
||||||
|
|
||||||
## Assumptions (veto-able)
|
|
||||||
|
|
||||||
- `ASSUMPTION:` presets ship as example rosters under the framework (`fleet/examples/*.yaml`), selected by `init`.
|
|
||||||
- `ASSUMPTION:` chat connectors are pluggable; Discord first (target exists), Matrix is the strategic default later.
|
|
||||||
- `ASSUMPTION:` "Mos" = a Claude Code orchestrator session with the discord plugin (reuses the documented Discord Bridge Protocol).
|
|
||||||
- `ASSUMPTION:` per north star, runtimes default to Codex/pi-on-Codex for workers; the orchestrator "Mos" runs Claude Code (in Claude Code, which is allowed).
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
# PRD — Fleet Phase 2: Operator Observability
|
|
||||||
|
|
||||||
> **Workstream:** W-FLEET under `mvp-20260312` · **Phase:** 2
|
|
||||||
> **North star:** [docs/fleet/north-star.md](./north-star.md)
|
|
||||||
> **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.
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
The durable tmux fleet runs on the isolated `mosaic-fleet` socket. That isolation
|
|
||||||
(which protects the operator's default tmux) makes the fleet **invisible** to default
|
|
||||||
tooling, and truth is split across three planes no single command joins — systemd
|
|
||||||
(`systemctl --user`), tmux (`-L mosaic-fleet`), and the process tree (`pstree`).
|
|
||||||
`agent tail` (`capture-pane`) returns **blank for full-screen TUIs**, and `agent send`
|
|
||||||
confirms only keystroke injection, not acceptance. Net: the operator has near-zero
|
|
||||||
observability and no safe way to watch a session.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
1. One command shows the **whole fleet's** real state, joining all three planes.
|
|
||||||
2. **Liveness is truthful**: healthy = answered a heartbeat, not "pane alive".
|
|
||||||
3. The operator can **watch** any session read-only without disrupting it.
|
|
||||||
4. `send` reports **delivered-and-accepted**, not just injected.
|
|
||||||
5. Every record/address carries **`tenant_id` + `host`** (zero foreclosure for multi-tenant/multi-host).
|
|
||||||
|
|
||||||
## Non-goals (this phase)
|
|
||||||
|
|
||||||
- No webUI (Phase 5; rides federation for cross-host).
|
|
||||||
- No `fleetd` daemon or persistent history store.
|
|
||||||
- No real-runtime swap (Phase 3) — instrument the live **dogfood stub** fleet.
|
|
||||||
- No cross-host aggregation yet (addressing is host-tagged but queries stay local).
|
|
||||||
|
|
||||||
## Functional requirements
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| FR-1 | `mosaic fleet ps [--json]` prints one row per roster agent joining: name · tenant · host · runtime · systemd(active/enabled) · pane(alive/dead) · pid · idle · **last-heartbeat age** · **drift** flag (roster runtime ≠ actual pane command) · **boot-enable** warning (active but `UnitFileState=disabled`). |
|
|
||||||
| FR-2 | **Heartbeat protocol v1** (see below); `dogfood-agent.py` implements the responder. `fleet ps` issues probes (or reads last-seen) and reports health per FR-1. |
|
|
||||||
| FR-3 | `mosaic agent watch <name>` opens a **read-only** view of the pane (grouped session or `tmux attach -r`) that cannot send keystrokes and does not shrink the agent's window. |
|
|
||||||
| FR-4 | `mosaic agent attach <name>` remains the **explicit** interactive-takeover path (separate verb, documented as the only one that can type). |
|
|
||||||
| FR-5 | `mosaic agent send <name> --verify` confirms the message was **accepted** (not left as an unsubmitted draft) and returns non-zero if delivery cannot be verified. |
|
|
||||||
| FR-6 | All structured output (`--json`) includes `tenant_id` and `host` fields. |
|
|
||||||
|
|
||||||
## Heartbeat protocol v1
|
|
||||||
|
|
||||||
- **Probe:** operator/`fleet ps` writes a sentinel line to the agent's input or a
|
|
||||||
well-known per-agent heartbeat file path `~/.config/mosaic/fleet/run/<agent>.hb`.
|
|
||||||
- **Response:** the runtime updates `<agent>.hb` with `ts=<iso8601> pid=<pid> status=<ok|busy>`
|
|
||||||
on a fixed interval (default 15s) and on demand when probed.
|
|
||||||
- **Health rule:** `healthy` if `now - ts <= 3 × interval`; else `stale`; missing file = `unknown`.
|
|
||||||
- **Contract:** every runtime (dogfood stub now; claude/codex/pi/opencode in Phase 3)
|
|
||||||
MUST emit the heartbeat. The protocol is file-based so it works for headless stubs and
|
|
||||||
full-screen TUIs alike (no `capture-pane` dependency).
|
|
||||||
- `ASSUMPTION:` file-based heartbeat (vs in-pane echo) — chosen because it is TUI-safe and
|
|
||||||
uid-scoped, fitting per-tenant isolation. Open to an OTEL-span variant in Phase 3 (MVP-X6).
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- `mosaic fleet ps` shows all 5 live sessions on `mosaic-fleet` with correct
|
|
||||||
pane/pid/idle and flags the dogfood **drift** (`canary-pi` runtime=pi but pane runs
|
|
||||||
`dogfood-agent.py`) and the **boot-enable** gap (active but disabled).
|
|
||||||
- Killing one agent's pane flips its row to dead/stale within one `interval`.
|
|
||||||
- `agent watch` shows live output and provably cannot type into the pane; detaching
|
|
||||||
leaves the agent's window size unchanged.
|
|
||||||
- `agent send --verify` returns success on an accepting pane and non-zero on a wedged/draft pane.
|
|
||||||
- Quality gates green: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, plus
|
|
||||||
`pnpm --filter @mosaicstack/mosaic test`.
|
|
||||||
- Independent review passed; dogfood evidence captured against the live fleet.
|
|
||||||
|
|
||||||
## Test plan
|
|
||||||
|
|
||||||
- Unit/CLI specs in `packages/mosaic/src/commands/fleet.spec.ts` (and a new
|
|
||||||
`fleet-ps`/`watch`/`send-verify` spec) using the injected `CommandRunner` to assert
|
|
||||||
exact tmux/systemd command construction and JSON shape (tenant+host present).
|
|
||||||
- Situational: run against the live `mosaic-fleet` fleet; capture `fleet ps` output,
|
|
||||||
a kill-and-detect cycle, a read-only `watch`, and a `send --verify` pass/fail pair.
|
|
||||||
|
|
||||||
## Known limitations
|
|
||||||
|
|
||||||
- **Verify heuristic is best-effort:** `agent send --verify` uses a `>` -prefix draft
|
|
||||||
heuristic that is specific to pi/claude TUIs. Draft detection for codex and opencode
|
|
||||||
TUIs is best-effort only; those runtimes may not use the same input-line indicator.
|
|
||||||
- **Pane-change check is the best Phase-2 signal; verify now polls up to a bounded
|
|
||||||
timeout:** `agent send --verify` captures a BEFORE snapshot, sends the message, then
|
|
||||||
polls `capture-pane` every ~400 ms up to a configurable total timeout (default ~6 s,
|
|
||||||
controlled by `--verify-timeout <ms>`). On each poll it runs classifySendResult: if
|
|
||||||
the pane shows 'accepted' or 'draft' the loop exits immediately; while the result is
|
|
||||||
'unverifiable' (no pane change yet) it keeps polling. After the timeout with no
|
|
||||||
definitive result, it fails closed: exit 1 with "no pane change after send". This
|
|
||||||
eliminates false 'unverifiable' failures for slow/loaded TUIs that were previously
|
|
||||||
caused by the old fixed 300 ms single-capture. Definitive acceptance ultimately
|
|
||||||
requires a runtime acknowledgement (Phase-3 heartbeat-ack); the bounded pane-change
|
|
||||||
poll is the best signal available against an opaque TUI for Phase-2.
|
|
||||||
- **Blank AFTER capture fails closed:** Full-screen TUIs (claude, codex, opencode, pi)
|
|
||||||
render blank for `tmux capture-pane`. When the AFTER snapshot is empty, `send --verify`
|
|
||||||
returns non-zero with an "unverifiable" message rather than silently succeeding. This
|
|
||||||
is an intentional fail-closed design (FR-5).
|
|
||||||
- **`agent watch` uses a grouped viewer session:** `tmux attach -r` directly against the
|
|
||||||
agent session lets the viewer terminal shrink the agent's window. `agent watch` instead
|
|
||||||
creates a throwaway grouped session (`tmux new-session -d -t '=<agent>' -s
|
|
||||||
'<agent>-watch-<pid>'`), attaches read-only to that session, and kills it on detach.
|
|
||||||
The grouped session shares the agent's windows but has independent sizing, so the
|
|
||||||
agent's window is never affected. `tmux attach` is still interactive and requires
|
|
||||||
inherited stdio; the `interactiveRunner` handles TTY passthrough.
|
|
||||||
|
|
||||||
## Surfaces & parity (MVP-X1)
|
|
||||||
|
|
||||||
CLI lands this phase. TUI surface follows in the `packages/mosaic` wizard; webUI in
|
|
||||||
Phase 5 via federation. PRD records the parity debt explicitly so it is not lost.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Tasks — W-FLEET (Fleet) Phase 2: Observability
|
|
||||||
|
|
||||||
> 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
|
|
||||||
> rollup row is proposed to the MVP orchestrator, not written here.
|
|
||||||
>
|
|
||||||
> Mission: `mvp-20260312` · PRD: [docs/fleet/PRD.md](./PRD.md) · North star: [docs/fleet/north-star.md](./north-star.md)
|
|
||||||
> Status: `not-started` | `in-progress` | `done` | `blocked` | `failed`
|
|
||||||
|
|
||||||
| id | status | description | depends_on | agent | pr | notes |
|
|
||||||
| ------------- | ----------- | ------------------------------------------------------------------------------------------------------------------ | --------------------- | ----------- | --- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| FLEET-OBS-000 | done | Plan: north-star + Phase-2 PRD + workstream scaffolding | — | lead | — | persisted 2026-06-20 on `feat/fleet-observability` |
|
|
||||||
| FLEET-OBS-001 | done | Heartbeat protocol v1 spec finalized in PRD + framework doc | FLEET-OBS-000 | lead | — | file-based `~/.config/mosaic/fleet/run/<agent>.hb`; spec in PRD |
|
|
||||||
| FLEET-OBS-002 | in-progress | Implement heartbeat responder in `dogfood-agent.py` | FLEET-OBS-001 | fleet-coder | — | dispatched to ad-hoc `mosaic yolo` fleet agent (dogfood) |
|
|
||||||
| FLEET-OBS-003 | done | `mosaic fleet ps` — join systemd+tmux+proc+idle+heartbeat; tenant+host tagged; drift + boot-enable flags; `--json` | FLEET-OBS-001 | worker | — | commit ab47831; LIVE-verified on mosaic-fleet; caught canary-pi DRIFT + BOOT-ENABLE. Polish: idleSeconds parse returns null |
|
|
||||||
| FLEET-OBS-004 | done | `mosaic agent watch <name>` — read-only join (no resize, no keystrokes) | FLEET-OBS-000 | worker | — | `attach -r`; verb wired |
|
|
||||||
| FLEET-OBS-005 | done | `mosaic agent send --verify` — delivery/acceptance receipt | FLEET-OBS-000 | worker | — | --verify flag; draft-heuristic verify |
|
|
||||||
| FLEET-OBS-006 | done | CLI specs for ps/watch/send-verify (tenant+host shape, command construction) | FLEET-OBS-003,004,005 | worker | — | 62 tests green (31 new); re-verified by lead |
|
|
||||||
| FLEET-OBS-007 | not-started | Framework doc: fleet observability guide + verbs | FLEET-OBS-003,004,005 | lead | — | `docs/guides/` or `framework/tools/.../README` |
|
|
||||||
| FLEET-OBS-008 | not-started | Independent review + dogfood verification on live fleet | FLEET-OBS-002..007 | reviewer | — | author ≠ reviewer; capture evidence in scratchpad |
|
|
||||||
| FLEET-OBS-009 | not-started | Open PR → green CI (queue guard) → squash-merge → close `fleet-observability-1` | FLEET-OBS-008 | lead | — | trunk merge; no direct push to main |
|
|
||||||
|
|
||||||
## Proposed MVP rollup row (for the MVP orchestrator — not written by this workstream)
|
|
||||||
|
|
||||||
```
|
|
||||||
| W-FLEET | in-progress | Fleet (agent-session execution layer) | Phase 2/5 | docs/fleet/TASKS.md | observability dogfooded on live stub fleet; control plane rides federation (W1) |
|
|
||||||
```
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
# Fleet Backlog Conventions
|
|
||||||
|
|
||||||
The **backlog** is Mosaic's native backlog-of-record for fleet work. It is built
|
|
||||||
end-to-end on Mosaic's own storage layer (`@mosaicstack/db`, drizzle/Postgres)
|
|
||||||
and surfaced as `mosaic fleet backlog <sub> --json`.
|
|
||||||
|
|
||||||
> **Mosaic-native, no Hermes.** This backlog REPLACES the former Hermes adapter.
|
|
||||||
> There is **no** runtime dependency on Hermes, `hermes kanban`, or `~/.hermes`
|
|
||||||
> anywhere in this feature. Anything previously delegated to Hermes is recreated
|
|
||||||
> here on Mosaic's own Postgres storage layer.
|
|
||||||
|
|
||||||
## Storage tier — PGlite by default, Postgres by config
|
|
||||||
|
|
||||||
The backlog uses the existing Mosaic storage layer; there is **no** new database
|
|
||||||
engine (no sqlite, no raw client).
|
|
||||||
|
|
||||||
| Condition | Tier | Data location |
|
|
||||||
| ------------------------------ | -------------------- | -------------------------------- |
|
|
||||||
| `DATABASE_URL` set | Full server Postgres | the configured database |
|
|
||||||
| `PGLITE_DATA_DIR` set (no URL) | Embedded PGlite | that directory |
|
|
||||||
| neither (default) | Embedded PGlite | `~/.config/mosaic/fleet/backlog` |
|
|
||||||
|
|
||||||
PGlite is real Postgres semantics in-process — including the row locks the atomic
|
|
||||||
claim relies on — so the **same code** runs on a laptop (embedded, single-host
|
|
||||||
default) and on a full Postgres deployment. Switching tiers is config-only.
|
|
||||||
|
|
||||||
The schema (`backlog` table) is created automatically on first CLI use:
|
|
||||||
`runMigrations()` for Postgres, `runPgliteMigrations()` for embedded PGlite.
|
|
||||||
|
|
||||||
### Update safety
|
|
||||||
|
|
||||||
The embedded PGlite store lives under `~/.config/mosaic/fleet/backlog`, which is
|
|
||||||
listed in `PRESERVE_PATHS` in `packages/mosaic/framework/install.sh`. This means
|
|
||||||
`mosaic update` (which runs the framework sync with `rsync --delete`) will **not**
|
|
||||||
wipe the operator's backlog — same protection as the roster, per-agent env, and
|
|
||||||
heartbeat run dir.
|
|
||||||
|
|
||||||
## Card schema
|
|
||||||
|
|
||||||
A card is one row in the `backlog` table:
|
|
||||||
|
|
||||||
| Column | Type | Notes |
|
|
||||||
| ------------------- | ------------------- | ------------------------------------------------------------- |
|
|
||||||
| `id` | text (PK) | Stable, caller-supplied id (e.g. `A4`, `fleet-001`). |
|
|
||||||
| `title` | text | Required. |
|
|
||||||
| `body` | text (nullable) | Free-form description. |
|
|
||||||
| `phase` | text (nullable) | Board/phase grouping (see below). |
|
|
||||||
| `priority` | int (default 0) | **Higher = sooner.** Claim picks the max-priority ready card. |
|
|
||||||
| `status` | enum | `ready` \| `claimed` \| `blocked` \| `done`. |
|
|
||||||
| `depends_on` | jsonb `string[]` | DAG edges — ids of cards this one depends on. |
|
|
||||||
| `claim_owner` | text (nullable) | Owner token of the active claim. |
|
|
||||||
| `claim_ttl_seconds` | int (nullable) | TTL of the active claim. |
|
|
||||||
| `claimed_at` | timestamptz (null) | When the claim was taken. `claimed_at + ttl` = expiry. |
|
|
||||||
| `attempts` | int (default 0) | Incremented each time the card is claimed. |
|
|
||||||
| `idempotency_key` | text (unique, null) | Dedups `create`; NULLs are distinct in Postgres. |
|
|
||||||
| `acceptance` | jsonb (nullable) | Acceptance criteria (array of strings or object). |
|
|
||||||
| `created_at` | timestamptz | |
|
|
||||||
| `updated_at` | timestamptz | |
|
|
||||||
|
|
||||||
`depends_on` is modeled as a `jsonb` array column rather than a separate edge
|
|
||||||
table. Justification: it matches the repo's existing style (e.g. `tasks.tags`,
|
|
||||||
`agents.skills`, `routing_rules.conditions` are all jsonb arrays), keeps a card
|
|
||||||
self-contained, and the DAG is small (per-card dependency lists), so a join table
|
|
||||||
would add ceremony without benefit.
|
|
||||||
|
|
||||||
### Board / phase convention
|
|
||||||
|
|
||||||
`phase` is a free-form grouping string used as the board column / milestone label
|
|
||||||
(e.g. `M1`, `fleet`, `infra`). `list --phase <phase>` filters to one board lane.
|
|
||||||
`priority` orders cards **within** the ready pool regardless of phase.
|
|
||||||
|
|
||||||
## Status lifecycle
|
|
||||||
|
|
||||||
```
|
|
||||||
create
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────► ready ───── claim ─────► claimed ───── complete ─────► done
|
|
||||||
│ │ │
|
|
||||||
│ block reclaim (TTL expiry or --id)
|
|
||||||
│ ▼ │
|
|
||||||
│ blocked └──────────────────────────┘ (back to ready)
|
|
||||||
└──────────┘ (reclaim / re-create can return a card to ready)
|
|
||||||
```
|
|
||||||
|
|
||||||
- **ready** — eligible to be claimed once every `depends_on` card is `done`.
|
|
||||||
- **claimed** — a worker holds it; `claim_owner` + `claimed_at` set.
|
|
||||||
- **blocked** — explicitly parked; never auto-claimed.
|
|
||||||
- **done** — completed; satisfies dependents.
|
|
||||||
|
|
||||||
## Atomic claim (`FOR UPDATE SKIP LOCKED`) + TTL
|
|
||||||
|
|
||||||
`claim` is atomic. Inside a single transaction it locks candidate `ready` rows
|
|
||||||
with `SELECT ... FOR UPDATE SKIP LOCKED` (via the drizzle `sql` operator), picks
|
|
||||||
the highest-priority deps-satisfied card, and flips it to `claimed`. Because a row
|
|
||||||
already locked by a concurrent claimer is **skipped**, two claimers can **never**
|
|
||||||
both win the same card — the loser falls through to the next candidate or gets
|
|
||||||
`null`. (Proven by the concurrency tests in `packages/db/src/backlog.spec.ts`.)
|
|
||||||
|
|
||||||
- **Deps gate:** a card is only claimable when every id in `depends_on` is `done`.
|
|
||||||
- **TTL:** `claim --ttl <sec>` (default **900s**) records `claim_ttl_seconds`.
|
|
||||||
- **reclaim:** releases claims whose `claimed_at + ttl` is in the past (expired)
|
|
||||||
back to `ready`, clearing the claim fields. `reclaim --id <id>` force-releases a
|
|
||||||
specific card regardless of expiry. This is how a crashed worker's card returns
|
|
||||||
to the pool.
|
|
||||||
|
|
||||||
## CLI — `mosaic fleet backlog <sub> --json`
|
|
||||||
|
|
||||||
All subcommands support `--json`.
|
|
||||||
|
|
||||||
| Subcommand | Purpose |
|
|
||||||
| --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
|
||||||
| `create --id --title [--body --phase --priority --depends-on --acceptance --idempotency-key]` | Create a card; `idempotency_key` dedups (repeat returns the existing card). |
|
|
||||||
| `list [--status --phase --ready-only]` | List cards. `--ready-only` = status `ready` AND all deps `done`. |
|
|
||||||
| `claim --owner [--ttl <sec> --id <id>]` | Atomically claim the highest-priority ready card (or `--id`). Returns the card or `null`. |
|
|
||||||
| `reclaim [--id <id>]` | Release expired claims (or a specific card) back to `ready`. |
|
|
||||||
| `link --from --to` | Add a `depends_on` edge (`--from` depends on `--to`). |
|
|
||||||
| `stats` | Counts by status, oldest-ready age, expired-claim count. |
|
|
||||||
| `block --id` | Set a card to `blocked`. |
|
|
||||||
| `complete --id` | Set a card to `done` (releases any claim). |
|
|
||||||
|
|
||||||
### Example
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# Seed two cards, the second depends on the first.
|
|
||||||
mosaic fleet backlog create --id A1 --title "schema" --priority 5
|
|
||||||
mosaic fleet backlog create --id A2 --title "service" --depends-on A1 --priority 9
|
|
||||||
|
|
||||||
# A2 is gated on A1, so claim returns A1 first.
|
|
||||||
mosaic fleet backlog claim --owner worker-1 --ttl 600 --json
|
|
||||||
|
|
||||||
# Finish A1; now A2 is ready.
|
|
||||||
mosaic fleet backlog complete --id A1
|
|
||||||
mosaic fleet backlog list --ready-only --json
|
|
||||||
|
|
||||||
# Recover stalled work.
|
|
||||||
mosaic fleet backlog reclaim --json
|
|
||||||
```
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
# F4 — Orchestrator chat connector + Matrix (local homeserver)
|
|
||||||
|
|
||||||
> **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).
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
The fleet **orchestrator** is the operator's single point of contact. The north-star makes the
|
|
||||||
chat channel a **user-chosen connector** — tmux today, Discord live ("Mos"), with Matrix /
|
|
||||||
Telegram / Slack configurable. F4 adds **Matrix** (local homeserver) as a **peer** connector and,
|
|
||||||
first, the small **connector abstraction** that makes connectors pluggable without touching fleet
|
|
||||||
core.
|
|
||||||
|
|
||||||
## The abstraction (Phase 1 — this PR)
|
|
||||||
|
|
||||||
Connectors implement one small, uniform interface (`src/fleet/connectors/types.ts`):
|
|
||||||
|
|
||||||
```ts
|
|
||||||
interface OrchestratorConnector {
|
|
||||||
readonly kind: 'tmux' | 'discord' | 'matrix';
|
|
||||||
send(message: OutboundMessage): Promise<SendResult>; // orchestrator → human
|
|
||||||
subscribe(handler: (m: InboundMessage) => void): Unsubscribe; // human → orchestrator
|
|
||||||
health(): Promise<ConnectorHealth>; // reachable + authenticated
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **send / subscribe / health** — the only surface fleet core depends on. `SendResult` is the
|
|
||||||
ack half; `health()` is the liveness half.
|
|
||||||
- **Thread-aware by metadata** — `OutboundMessage.threadId` / `InboundMessage.threadId` are
|
|
||||||
optional, so thread-capable connectors (Matrix rooms/threads, the future first-party Mosaic
|
|
||||||
Discord plugin) fit **without an interface change**.
|
|
||||||
- **Registry** (`registry.ts`) — implementations register a factory by kind; `createConnector(config)`
|
|
||||||
resolves one from roster config. Phase 1 ships the registry + `resolveConnectorKind` (defaults
|
|
||||||
`tmux` when a roster declares no connector — **back-compat**); the factories land in Phase 2.
|
|
||||||
|
|
||||||
### Config model
|
|
||||||
|
|
||||||
A roster may carry an optional `connector` block (`roster.schema.json`); absent ⇒ tmux.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
connector:
|
|
||||||
kind: matrix # tmux | discord | matrix
|
|
||||||
matrix:
|
|
||||||
homeserver_url: https://matrix.example.internal
|
|
||||||
user_id: '@mos:example.internal'
|
|
||||||
room_id: '!abc:example.internal'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Secrets are never in the roster.** `MATRIX_ACCESS_TOKEN` / `DISCORD_BOT_TOKEN` come from the
|
|
||||||
environment (the gateway env-config pattern that already masks them). The sanitization gate would
|
|
||||||
reject a token committed to a shipped file anyway.
|
|
||||||
|
|
||||||
## Matrix connector (Phase 2)
|
|
||||||
|
|
||||||
The connector speaks the **Matrix client-server API** directly over HTTPS (`fetch` — no SDK needed
|
|
||||||
for MVP), so it is **homeserver-agnostic**:
|
|
||||||
|
|
||||||
| Op | Matrix CS-API |
|
|
||||||
| ----------- | ------------------------------------------------------------------------ |
|
|
||||||
| `send` | `PUT /_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId}` |
|
|
||||||
| `subscribe` | `GET /_matrix/client/v3/sync` (long-poll, `since` token) → room timeline |
|
|
||||||
| `health` | `GET /_matrix/client/versions` (reachable) + `…/account/whoami` (authed) |
|
|
||||||
| threads | `m.thread` relations ↔ `threadId` |
|
|
||||||
|
|
||||||
## Local homeserver (infra, not connector code)
|
|
||||||
|
|
||||||
Strategic default: a **self-hosted** homeserver on our own infra — no third-party gateway.
|
|
||||||
|
|
||||||
- **Default: Conduit** (Rust, single binary, low resource) — trivial to stand up for a fleet/dev
|
|
||||||
homeserver.
|
|
||||||
- **Alternative: Synapse** (mature, feature-complete) for scale.
|
|
||||||
|
|
||||||
The connector only needs `homeserver_url` + `user_id` + `room_id` + an access token, so the
|
|
||||||
homeserver choice is a **deployment** concern (a Phase-2 deploy guide), not connector code.
|
|
||||||
|
|
||||||
## Phasing
|
|
||||||
|
|
||||||
| Phase | Scope | This PR |
|
|
||||||
| ----- | --------------------------------------------------------------------------------------- | ------- |
|
|
||||||
| **1** | Connector interface + types, registry + kind resolution, roster `connector` schema, doc | ✅ yes |
|
|
||||||
| 2 | Matrix CS-API client (fetch-based send/sync/health) + registered factory + tests | follow |
|
|
||||||
| 2 | `fleet init` / `configure` connector-selection UX; roster parse wires the block | follow |
|
|
||||||
| 2 | systemd launch wiring so the orchestrator starts on the chosen connector | follow |
|
|
||||||
| 3 | Conduit deploy guide; first-party Mosaic Discord (threads) registers as a connector | follow |
|
|
||||||
|
|
||||||
## Back-compat & boundaries
|
|
||||||
|
|
||||||
- Existing rosters (no `connector`) resolve to tmux — **zero change**.
|
|
||||||
- Fleet core never branches on connector kind; it depends only on the interface.
|
|
||||||
- Cross-host reach rides the **federation** layer (W1), not a bespoke broker (north-star assumption).
|
|
||||||
- Phase 1 touches **no** `fleet.ts` core (a self-contained `connectors/` module), so it is
|
|
||||||
independent of the in-flight fleet-config PRs.
|
|
||||||
@@ -1,430 +0,0 @@
|
|||||||
# Mosaic Fleet — North Star
|
|
||||||
|
|
||||||
> **Workstream:** W-FLEET (Fleet) under mission `mvp-20260312`
|
|
||||||
> **Umbrella:** [docs/MISSION-MANIFEST.md](../MISSION-MANIFEST.md) · [docs/PRD.md](../PRD.md) (Mosaic Stack v0.1.0)
|
|
||||||
> **Status:** doctrine — authored 2026-06-20. Owner of this file: Fleet workstream lead.
|
|
||||||
> This document does **not** modify the MVP rollup; a rollup row is proposed, not written here.
|
|
||||||
|
|
||||||
## Vision
|
|
||||||
|
|
||||||
A **customizable, multi-tenant fleet of always-on AI agents** — each defined by role,
|
|
||||||
materialized as a durable, joinable runtime session, coordinated by the proven
|
|
||||||
orchestrator/worker model, and observable end-to-end across hosts. Coding today;
|
|
||||||
finance, analytics, research as roster entries tomorrow — same primitives, different
|
|
||||||
roster. The fleet is the **agent-session execution layer** of the Mosaic Stack MVP:
|
|
||||||
the thing federation makes reachable across hosts and the webUI/TUI/CLI make visible.
|
|
||||||
|
|
||||||
The USC tmux PoC (durable sessions + `agent-send` comms) proved the model. This
|
|
||||||
workstream makes it an official, observable, multi-tenant Mosaic Stack capability.
|
|
||||||
|
|
||||||
## The Fleet as means of production (bootstrapping)
|
|
||||||
|
|
||||||
The Fleet has a **dual role**, and that is the point:
|
|
||||||
|
|
||||||
- **As product** — a multi-tenant agent-fleet capability of Mosaic Stack (this workstream).
|
|
||||||
- **As means of production** — the orchestrator/worker fleet that _actually builds the
|
|
||||||
entire MVP_ (federation W1, webUI, TUI, CLI, and the Fleet itself).
|
|
||||||
|
|
||||||
We are **building the system that builds the system.** Every other MVP workstream is
|
|
||||||
delivered _by_ the fleet, so fleet observability and control are not merely product
|
|
||||||
features — they are the **operational floor of the whole delivery effort**. If we cannot
|
|
||||||
see and steer the agents, we cannot trust what they ship. This is why Phase 2
|
|
||||||
(observability) leads: it is the instrument panel for the factory, dogfooded on the live
|
|
||||||
fleet that is, recursively, building Mosaic Stack.
|
|
||||||
|
|
||||||
The discipline that makes great power safe is the same gate chain the fleet enforces:
|
|
||||||
independent review before merge, green CI, honest completion, decide-and-inform cadence,
|
|
||||||
and no irreversible action without authority. The bootstrap is only as trustworthy as
|
|
||||||
those gates.
|
|
||||||
|
|
||||||
## Alignment with MVP cross-cutting requirements
|
|
||||||
|
|
||||||
The Fleet inherits — does not re-invent — the MVP's hard requirements:
|
|
||||||
|
|
||||||
| MVP req | What it means for the Fleet |
|
|
||||||
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| MVP-X1 three-surface parity | fleet observability/control reachable via **CLI + TUI + webUI** (CLI first; webUI is required for parity, not optional) |
|
|
||||||
| MVP-X2 multi-tenant isolation | one tenant = one **Linux uid** (own `systemd --user`, socket, `~/.config/mosaic`); no cross-tenant leakage |
|
|
||||||
| MVP-X3 auth (BetterAuth/SSO) | operator→fleet and cross-host views are auth-gated through the platform's existing auth |
|
|
||||||
| MVP-X4 quality gates | `pnpm typecheck`/`lint`/`format:check` green before any push |
|
|
||||||
| MVP-X5 federated topology | cross-host fleet visibility rides the **federation** boundary (W1), not a bespoke broker |
|
|
||||||
| MVP-X6 OTEL tracing | heartbeats, sends, and lifecycle events emit spans; `traceparent` crosses the federation boundary |
|
|
||||||
| MVP-X7 trunk merge | branch from `main`, squash-merge via PR, never push to `main` |
|
|
||||||
|
|
||||||
## The stack — where every concern lives
|
|
||||||
|
|
||||||
One **definition** is the source of truth; the **session** is how it runs.
|
|
||||||
|
|
||||||
| Layer | Owner | Phase-2 reality | Destination |
|
|
||||||
| -------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **Definition + identity + auth** | gateway / `mosaic-as` (scoped tokens, #541) | `roster.yaml` (tenant-tagged) | one definition; `mosaic agent --new` materializes it |
|
|
||||||
| **Tenancy boundary** | **Linux uid per tenant** (linger, own `systemd --user`, own socket, own `~/.config/mosaic`) | one tenant: `jarvis` = tenant zero | uid-per-tenant; federation aggregates across hosts |
|
|
||||||
| **Runtime** | per-tenant tmux session on isolated socket | dogfood stub sessions (live now on `mosaic-factory`) | claude/codex/pi/opencode TUIs |
|
|
||||||
| **Liveness** | **heartbeat protocol** every runtime answers | protocol defined + dogfood stub answers it | all runtimes answer; "healthy" ≠ "pane alive" |
|
|
||||||
| **Observation** | read-only `watch` (native tmux) + `pipe-pane` stream | CLI `watch`/`ps`; explicit opt-in `attach` for control | + auth-gated webUI streams |
|
|
||||||
| **Control plane** | **federation** across hosts × tenants | records already carry `tenant_id` + `host` | federated gateways expose fleet state; webUI in Phase 5 |
|
|
||||||
| **Central register** | Postgres `fleet` schema (gateway instance); access via gateway API only | _none in PoC_ (files + `roster.yaml`) | agents, missions, tasks, heartbeats, spend — single network-accessible SSOT; docs = generated projections |
|
|
||||||
| **Budget / spend governance** | **per-tenant budget policy** ingested by the orchestrator + routing layer | none today (spend is unmetered) | usage-vs-limit feedback ingested; spend auto-paced to the limit window; per-provider/per-account/concurrency/API-$ budgets enforced |
|
|
||||||
|
|
||||||
> **PoC socket hygiene:** the PoC fleet runs on the **default tmux socket** (no `-L`).
|
|
||||||
> The named production-isolation socket is **`mosaic-fleet`** (matches the product brand);
|
|
||||||
> an absent roster `socket_name` means the default socket everywhere (spawn, `fleet ps`,
|
|
||||||
> onboarding cheat-sheet). The legacy dogfood canary still runs on the old `mosaic-factory`
|
|
||||||
> socket pending migration.
|
|
||||||
|
|
||||||
## Operating model (inherited, not reinvented)
|
|
||||||
|
|
||||||
The AI-guide law stands: one accountable **orchestrator**, isolated **workers** that
|
|
||||||
stop at PR-open, the serialized **gate chain** (independent review → green CI →
|
|
||||||
diff-sanity → squash-merge → verify), **decide-and-inform** cadence, and a durable
|
|
||||||
**board** so missions survive session death. The Fleet is the infrastructure _under_
|
|
||||||
this model. See `mosaicstack-aiguide` whitepapers 01 (inter-agent comms) and 03
|
|
||||||
(orchestration model) for the rationale.
|
|
||||||
|
|
||||||
## Fleet roster — the two-agent floor and the role library
|
|
||||||
|
|
||||||
A fleet is **never a single agent**. The minimum viable fleet is **two**:
|
|
||||||
|
|
||||||
| Role | Mandate | Boundaries |
|
|
||||||
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
|
||||||
| **Orchestrator** | The user's **single point of contact**. Owns the general flow, keeps agentic actions on-target, and **adds/removes agents from the fleet at will** to meet goals and user needs. Exactly **one** per fleet (the existing R5 invariant). | Delegates source work; never the sole worker. |
|
|
||||||
| **Enhancer** | The fleet's **continuous-improvement loop**. Monitors fleet activity, analyzes for enhancements/optimizations, builds a **plan of remediation**, and — **with the orchestrator** — upgrades fleet capability: tool creation/repair, skills, harness improvements, and **bug reports filed to Mosaic Stack** for proper remediation. Recommends which agents are needed. | **Does not code, review code, or perform delivery tasks.** Improvement and diagnosis only. |
|
|
||||||
|
|
||||||
> **Why two, not one:** the orchestrator drives delivery; the enhancer makes the fleet
|
|
||||||
> _get better at delivering_ over time. The enhancer is how the fleet self-heals its tools,
|
|
||||||
> skills, and harnesses, and how real defects flow back to Mosaic Stack as bug reports.
|
|
||||||
> Together they are the irreducible core — every other role is added on demand.
|
|
||||||
|
|
||||||
A **general** fleet starts at this floor: the orchestrator (advised by the enhancer)
|
|
||||||
materializes whatever roles prove necessary over the mission's life. Specialized presets
|
|
||||||
(coding, research, etc.) seed additional roles up front, but all reduce to the same two-agent
|
|
||||||
spine plus an on-demand **role library**:
|
|
||||||
|
|
||||||
| Role profile | Purpose |
|
|
||||||
| ------------------- | --------------------------------------------------------------------------------- |
|
|
||||||
| **orchestrator** | point of contact, flow control, fleet composition (1 per fleet) |
|
|
||||||
| **enhancer** | fleet monitoring, optimization, tool/skill/harness upgrades, upstream bug reports |
|
|
||||||
| **coder** | implementation (worker; stops at PR-open) |
|
|
||||||
| **code review** | independent code review gate |
|
|
||||||
| **security review** | security/auth/secret review gate |
|
|
||||||
| **research** | investigation, synthesis, options analysis |
|
|
||||||
| **board** | deliberation panel — moonshot, contrarian, technical, business, financial lenses |
|
|
||||||
| **operations** | infra, deploy, health, incident response |
|
|
||||||
| _…extensible_ | new profiles added as missions demand (orchestrator + enhancer decide) |
|
|
||||||
|
|
||||||
## Invariants — "maximal vision, incremental delivery, zero foreclosure"
|
|
||||||
|
|
||||||
Every artifact, starting Phase 2, MUST:
|
|
||||||
|
|
||||||
1. Carry **`tenant_id` + `host`** in schema and message addressing — even with one of each today.
|
|
||||||
2. Treat **isolation socket ≠ invisibility** — anything isolated is surfaced by one command.
|
|
||||||
3. Define **healthy = answered a heartbeat within N seconds**, never just "pane alive".
|
|
||||||
4. Make **observation read-only by default**; control is an explicit, separate, opt-in verb.
|
|
||||||
|
|
||||||
> **OPS INVARIANT — runtime agents need a real TTY.** Claude/Codex/pi/opencode agents
|
|
||||||
> cannot be bare-launched from a systemd `ExecStart`; a durable harness with a real PTY is
|
|
||||||
> required. This is **why `start-agent-session.sh` launches into tmux** and uses a
|
|
||||||
> `MOSAIC_AGENT_COMMAND` override rather than running the runtime directly under systemd.
|
|
||||||
|
|
||||||
## Budget & token governance (first-class fleet concern)
|
|
||||||
|
|
||||||
Spend is a fleet-level resource, not a per-agent afterthought. The fleet treats token
|
|
||||||
and API-dollar budget the way it treats liveness: a signal every runtime exposes and the
|
|
||||||
control plane is accountable for. This rides the same primitives as everything else —
|
|
||||||
`tenant_id` + `host` on every spend record, **read-only metering by default**, and the
|
|
||||||
**federation** layer as the cross-host aggregation point (W1) — so budgeting is zero-foreclosure
|
|
||||||
from day one even while one tenant exists.
|
|
||||||
|
|
||||||
**Two spend regimes, one policy surface:**
|
|
||||||
|
|
||||||
| Regime | Feedback signal | Fleet obligation |
|
|
||||||
| ------------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **OAuth-subscription runtimes** (Claude sub, Codex sub) | runtime exposes **current-usage-vs-limit** within a rolling limit window | **ingest** the signal per sub-account; **auto-pace** agentic spend so the window is not exhausted early |
|
|
||||||
| **API-token runtimes** (metered per token) | provider billing / token counts | enforce **hard $-spend ceilings**; on breach, **downgrade → queue → refuse** (below) |
|
|
||||||
|
|
||||||
**Auto-pacing law (OAuth subs) — EVEN-SPREAD default (Jason override, 2026-06-22):** the fleet
|
|
||||||
paces agentic token spend to consume the limit window **evenly over remaining time**:
|
|
||||||
target rate = _(remaining usage available)_ ÷ _(remaining time in the window)_. Example: 100% of
|
|
||||||
a 7-day window = **~14.285%/day**; the system tracks current usage and continuously re-splits the
|
|
||||||
remainder evenly to hold pace. **Anticipated token-spend-per-task is the budgeting informant** —
|
|
||||||
tasks are scheduled against the daily pace, not run until the quota is gone. Rationale: spreading
|
|
||||||
delivery evenly beats rapidly exhausting usage and losing **multiple days of momentum**.
|
|
||||||
**Rapid pacing / overspend requires EXPLICIT user authorization;** absent it, even-spread holds.
|
|
||||||
Pacing is a control-plane decision, surfaced read-only before it throttles a lane.
|
|
||||||
|
|
||||||
**Hard-cap breach behavior (ladder):** when a budget ceiling is hit mid-work, the fleet
|
|
||||||
**downgrades first** (opus → sonnet → haiku, then Claude → Codex), **queues** the lane at the
|
|
||||||
cheapest floor until the window resets, and **refuses** only as a last resort. Refusal is never
|
|
||||||
the first response to a breach.
|
|
||||||
|
|
||||||
**Spend accounting, learning & telemetry:**
|
|
||||||
|
|
||||||
- **Multi-subscription auto-routing:** a tenant with multiple subscriptions may let the fleet
|
|
||||||
**auto-route work to the account with the most available usage** (within budget policy).
|
|
||||||
- **Historical spend learning:** every task's token spend is **recorded**; historical data
|
|
||||||
continuously updates known **spend-per-task**, **typical daily spend**, and projections — so
|
|
||||||
estimates self-correct and pacing stays on target.
|
|
||||||
- **Projected + actual spend on artifacts (Mosaic Stack mandate):** PRDs, missions, and task
|
|
||||||
decomposition **MUST note projected AND actual token spend** — a Mosaic Stack process standard
|
|
||||||
(template-level), tracked separately as **#622**.
|
|
||||||
- **Anonymized telemetry → mosaicstack.dev:** spend data is reported (anonymous) to the
|
|
||||||
mosaicstack.dev telemetry endpoint so other agents/fleets budget and optimize from real,
|
|
||||||
anonymized data. Product workstream, tracked separately as **#623**.
|
|
||||||
|
|
||||||
**User-settable budgets (the policy surface).** A tenant operator can set budgets for every
|
|
||||||
configured **provider** (per-provider ceilings), the **account-to-task mapping**, the **agentic
|
|
||||||
routing flow**, **concurrency** (the spend multiplier), and **hard API-token $-limits**. Budgets
|
|
||||||
are enforced at the orchestrator + routing boundary, not inside individual workers (a worker never
|
|
||||||
decides its own budget — see delegation discipline).
|
|
||||||
|
|
||||||
**Budget CLI UX (#558):** `mosaic budget set --reset-at` sets the window reset; reset-datetimes
|
|
||||||
carry **confidence tags** (`user` / `provider` / `estimated` / `unknown`); and **urgency/criticality
|
|
||||||
is a dispatch-gate modifier** — high-urgency work may override even-spread pacing **within
|
|
||||||
authorization**. (Also feeds the budgeting workstream, not only this doc.)
|
|
||||||
|
|
||||||
## Observation model
|
|
||||||
|
|
||||||
| Verb | Behavior |
|
|
||||||
| ----------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
||||||
| `mosaic fleet ps` | one table joining systemd + tmux + process + idle + last-heartbeat, with drift + boot-enable flags |
|
|
||||||
| `mosaic agent watch <name>` | **read-only** join (grouped session / `-r`), no resize tyranny, no keystrokes |
|
|
||||||
| `mosaic agent attach <name>` | explicit interactive takeover (the only path that can type) |
|
|
||||||
| `mosaic agent send <name> --verify` | confirms message **accepted**, not merely keystroke-injected |
|
|
||||||
|
|
||||||
> Why the current PoC blocks observation: sessions live on the isolated `mosaic-factory`
|
|
||||||
> socket (invisible to default `tmux ls`), the only sanctioned read is `capture-pane`
|
|
||||||
> (blank for full-screen TUIs), and `attach` is read-write + resizes the session. The
|
|
||||||
> verbs above restore "join and observe" safely.
|
|
||||||
|
|
||||||
## Control plane & central register
|
|
||||||
|
|
||||||
### Why the register must be Postgres
|
|
||||||
|
|
||||||
The fleet is multi-host (w-jarvis + dragon-lin + future). A SQLite file is a local
|
|
||||||
file — it is not a network service and cannot be shared across hosts. Beyond topology,
|
|
||||||
Postgres MVCC eliminates the concurrent-writer corruption class Hermes hit with SQLite
|
|
||||||
under multi-agent access.
|
|
||||||
|
|
||||||
Access is exclusively through the **gateway API** (`apps/gateway` — typed, auth-gated,
|
|
||||||
scoped tokens). No agent or dispatcher pane ever holds a raw DB credential; a
|
|
||||||
compromised pane cannot corrupt or exfiltrate the register.
|
|
||||||
|
|
||||||
### Architecture (layers)
|
|
||||||
|
|
||||||
| Layer | Responsibility | Implementation |
|
|
||||||
| ---------------------- | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **Register** | Source of truth: agents, missions, tasks, heartbeats, spend | Postgres `fleet` schema — existing stack instance (`@mosaicstack/db`) |
|
|
||||||
| **Access** | Typed, auth-gated API | Gateway `fleet/*` routes |
|
|
||||||
| **Dispatcher** | Brief classification, BOD review, planning/coding/review/test/deploy sequencing + gates → fleet task dispatch | **forge pipeline engine** (`runPipeline`/`resumePipeline`, brief classifier, BOD) **+ thin `forge-exec` adapter → `agent-send.sh`**; NOT a new daemon — forge is reused, only stage→agent dispatch is new |
|
|
||||||
| **Orchestrator (Mos)** | Goals, missions, judgment, user/PA interface | Context-light; sets intent → re-engages only for decisions |
|
|
||||||
|
|
||||||
### Dispatcher = forge (reuse, do not rebuild)
|
|
||||||
|
|
||||||
The dispatcher is **not new work**: it is `@mosaicstack/forge`, a fully-implemented
|
|
||||||
software-factory pipeline engine (brief → Board-of-Directors review → 3 planning stages →
|
|
||||||
coding → review/remediation → testing → deploy). Forge already provides
|
|
||||||
`runPipeline`/`resumePipeline`, a brief classifier, and a BOD persona loader, so the fleet
|
|
||||||
does **not** re-implement sequencing, gate logic, or brief classification. The only new
|
|
||||||
fleet-owned code is a thin **`forge-exec` TaskExecutor adapter** (`ForgeTask` →
|
|
||||||
`agent-send.sh` to a named agent) — forge's single missing piece — tracked as a Gitea
|
|
||||||
issue and built post-PoC. The Postgres register backs forge's pipeline state (durable
|
|
||||||
`resumePipeline`, cross-host) in addition to cross-project missions/tasks/Kanban. The
|
|
||||||
north-star **'board' role IS forge's Board-of-Directors** — reused from forge, not a new
|
|
||||||
role implementation.
|
|
||||||
|
|
||||||
### Docs as projections
|
|
||||||
|
|
||||||
`docs/TASKS.md` and `MISSION-MANIFEST.md` are **generated projections** of the DB,
|
|
||||||
not hand-maintained. The dispatcher (or a scheduled job) renders Markdown from
|
|
||||||
`fleet.*` tables and commits the output. DB is authoritative; docs are for human
|
|
||||||
reference.
|
|
||||||
|
|
||||||
### Spend
|
|
||||||
|
|
||||||
`fleet.spend_ledger` records projected and actual token spend per agent/mission/task
|
|
||||||
(ties to issue #622). The dispatcher enforces budget caps before dispatching. Mos reads
|
|
||||||
the roll-up via API — no raw DB access, no context-bloating dumps.
|
|
||||||
|
|
||||||
### Federation
|
|
||||||
|
|
||||||
Cross-host fleet state flows through federated gateway queries (existing
|
|
||||||
`federation_peers` / `federation_grants` machinery). This is the existing north-star
|
|
||||||
invariant: **control plane rides federation (W1), not a bespoke broker.** No new
|
|
||||||
broker introduced.
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
|
|
||||||
This is Phase 4–5 of this roadmap, materialized. It MUST NOT block the PoC (which
|
|
||||||
runs correctly on files + `roster.yaml`). Begin when Phase 2 heartbeat protocol is
|
|
||||||
stable and concurrent-agent count makes file coordination the bottleneck.
|
|
||||||
|
|
||||||
### Open sub-decision
|
|
||||||
|
|
||||||
Dedicated Postgres **instance** vs. dedicated **schema** in the existing instance.
|
|
||||||
Recommendation: dedicated schema, existing instance (a migration file, not new infra);
|
|
||||||
re-evaluate if isolation or write-volume demands it.
|
|
||||||
|
|
||||||
## Phased roadmap
|
|
||||||
|
|
||||||
| Phase | Outcome | Status |
|
|
||||||
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
|
||||||
| 0–1 | tmux PoC, hardening, published CLI v0.0.34 (#565–#568) | ✅ done |
|
|
||||||
| **2 — Observability** | `fleet ps` (host+tenant aware join), heartbeat protocol + dogfood stub answers it, `agent watch` (read-only), `agent send --verify` receipts | ▶ now |
|
|
||||||
| 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 |
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
## Decisions of record (2026-06-20, with Jason)
|
|
||||||
|
|
||||||
- Agent model: **config defines, session runs** (gateway = definition/identity/auth; tmux = runtime).
|
|
||||||
- Tenancy: **multi-tenant from the start**; isolation = **per-tenant Linux uid**.
|
|
||||||
- Health: **heartbeat required** (dogfood stub implements the protocol now).
|
|
||||||
- Lifecycle: **hybrid** — core always-on + ephemeral workers per lane.
|
|
||||||
- Observation: **read-only default, opt-in takeover**.
|
|
||||||
- Multi-host: **designed-for from day one**; control plane **rides federation (W1)**.
|
|
||||||
- Delivery: **CLI-first now**, dogfood against the live stub fleet; webUI deferred to Phase 5.
|
|
||||||
- Runtimes: fleet agents default to **Codex / pi-on-Codex**; **Claude is reserved for Claude
|
|
||||||
Code only** (avoid alternate-harness API pricing). Validated durable recipe:
|
|
||||||
`mosaic yolo pi --model openai-codex/gpt-5.5:high`. Durable detached launch requires the
|
|
||||||
runtime-bin on PATH (baked into the pane command) + boot-survival (`enable` + linger),
|
|
||||||
which `fleet init` should automate.
|
|
||||||
|
|
||||||
## Decisions of record (2026-06-22, with Jason)
|
|
||||||
|
|
||||||
- **Two-agent floor:** every fleet has, at minimum, an **orchestrator** and an **enhancer**.
|
|
||||||
The orchestrator is the user's point of contact and composes the fleet; the enhancer runs the
|
|
||||||
continuous-improvement loop (monitor → analyze → remediate → upgrade tools/skills/harness →
|
|
||||||
file Mosaic Stack bug reports) and **does not code or review**.
|
|
||||||
- **Role library:** orchestrator, enhancer, coder, code review, security review, research,
|
|
||||||
board (moonshot/contrarian/technical/business/financial), operations — extensible; the
|
|
||||||
orchestrator (advised by the enhancer) adds roles as missions demand.
|
|
||||||
- **Orchestrator chat connector:** the orchestrator is reachable over a user-chosen connector
|
|
||||||
(tmux now; Telegram/Discord/Matrix/Slack configurable). Validated live: **"Mos" orchestrator
|
|
||||||
on Discord** via the Claude Code discord channel plugin (w-jarvis).
|
|
||||||
- **Session context cap = 200k tokens (GLOBAL to all Claude sessions):** Claude Code sessions are
|
|
||||||
capped at a **max 200k-token context window**. Long-running sessions extended toward 1M tokens
|
|
||||||
have proven **worse in practice** (degraded steering, off-plan divergence); 200k is the standard.
|
|
||||||
**Enforcement split:** the _window_ lives in **`~/.claude/settings.json`** (host-global) as
|
|
||||||
`"autoCompactWindow": 200000` + `"autoCompactEnabled": true`; the _1M-disable_ lives in **launch
|
|
||||||
ENV** (`CLAUDE_CODE_DISABLE_1M_CONTEXT=1`, plus `CLAUDE_CODE_AUTO_COMPACT_WINDOW=200000`) wherever
|
|
||||||
a `[1m]` model can be selected (`mos-claude.service` + the fleet Claude launcher), so every Claude
|
|
||||||
agent is capped at spawn. (settings = window; env = 1M-disable.)
|
|
||||||
- **Worker context bound (#8):** workers are kept context-bounded via the **ephemeral-per-lane
|
|
||||||
lifecycle + native compaction**, not via the 200k knob. The explicit `autoCompactWindow` 200k knob
|
|
||||||
**stays Claude-specific** — the _principle_ (bounded context) extends to workers, the _knob_ does not.
|
|
||||||
- **Orchestrator delegation discipline:** the orchestrator **delegates all delivery work** to
|
|
||||||
subagents / workflows / ultracode / coder agents and confines its own context to \*\*orchestration
|
|
||||||
- the personal-assistant lane\*\*. Keeping delivery out of the orchestrator's window keeps its
|
|
||||||
context unpolluted and measurably reduces off-plan divergence. The orchestrator coordinates and
|
|
||||||
decides; it does not implement.
|
|
||||||
- **Budget governance is fleet doctrine:** token/API-dollar budgeting is a first-class fleet concern
|
|
||||||
(see "Budget & token governance"). OAuth-sub usage-vs-limit feedback is ingested per account, spend
|
|
||||||
is **auto-paced EVEN-SPREAD over remaining time** (rapid/overspend only on explicit authorization),
|
|
||||||
spend is **tracked historically** to self-correct per-task/daily estimates, multi-sub tenants may
|
|
||||||
**auto-route by available usage**, and operators set budgets per provider, per account-to-task
|
|
||||||
mapping, per routing flow, per concurrency level, and as hard API-$ ceilings.
|
|
||||||
- **Spend accounting is a Mosaic Stack process mandate:** PRDs, missions, and task decomposition
|
|
||||||
**MUST carry projected + actual token spend**; used locally for pacing and reported as **anonymized
|
|
||||||
telemetry to mosaicstack.dev**. The template standard (#622) and telemetry product (#623) are
|
|
||||||
tracked separately.
|
|
||||||
- **Unified identity = "Fleet" (Jason, 2026-06-22):** the product is **Mosaic Fleet** — one unified
|
|
||||||
user-facing identity and CLI surface. **forge** is the Fleet's **internal** delivery/orchestration
|
|
||||||
engine (not a separate product); the control-plane **Postgres register is the Fleet's register**;
|
|
||||||
workers/runtime are the **Fleet substrate**. **"factory" is RETIRED as a product term** — it was
|
|
||||||
only ever the software-factory concept (which forge implements) and the old `mosaic-factory` tmux
|
|
||||||
socket name. The production-isolation socket is now **`mosaic-fleet`** (matches the product brand);
|
|
||||||
the legacy dogfood canary remains on the old `mosaic-factory` socket pending migration. **Code stays
|
|
||||||
layered** (forge + fleet + control-plane as internal layers);
|
|
||||||
only the **identity + CLI surface unify under Fleet.**
|
|
||||||
- **Role-based session naming (Jason, 2026-06-22):** agent tmux sessions are named by **role**
|
|
||||||
(`orchestrator`, `enhancer`, `research`, `coder0-0`, …), not by persona. **Persona lives in
|
|
||||||
`SOUL.md`**; the front-end / Discord presents a **friendly alias** (e.g. "Mos" = the orchestrator's
|
|
||||||
alias). The session name is the stable addressing handle; the alias is presentation.
|
|
||||||
|
|
||||||
### Control plane & central register
|
|
||||||
|
|
||||||
- **Store:** Postgres (existing stack instance, dedicated `fleet` schema via `@mosaicstack/db`). SQLite rejected: (1) it is a local file — structurally incompatible with a multi-host fleet; (2) concurrent multi-agent writes caused repeated corruption in Hermes. "SQLite + access service" rejected as reinventing a DB server badly; "LLM agent gating DB access" rejected as slow, expensive, and a single point of failure.
|
|
||||||
- **Access:** gateway API only (`apps/gateway`, `fleet/*` routes). No raw DB credentials in any agent/dispatcher pane — directly mitigates the tmux attack-surface concern.
|
|
||||||
- **Dispatcher = forge (reuse, not a new build):** the dispatcher IS `@mosaicstack/forge`'s pipeline engine (`runPipeline`/`resumePipeline` + brief classifier + BOD persona loader), a fully-implemented software-factory pipeline (brief → BOD review → 3 planning stages → coding → review/remediation → testing → deploy). We do **not** design/build a new dispatcher and do **not** re-implement sequencing, gate logic, or brief classification. The only new fleet-owned piece is a thin **`forge-exec` TaskExecutor adapter** (suggested package `packages/forge-exec`) mapping a `ForgeTask` → `agent-send.sh` dispatch to a named fleet agent — forge's single missing piece. It is tracked as a Gitea issue and built **post-PoC** (not now).
|
|
||||||
- **Register backs forge:** the Postgres `fleet` register is genuinely new (neither forge nor the fleet has cross-project state). It BACKS forge's pipeline state (durable `resumePipeline`, cross-host) plus cross-project missions/tasks/Kanban.
|
|
||||||
- **'board' role = forge BOD:** the north-star role-library 'board' role IS forge's Board-of-Directors — reused, not reinvented.
|
|
||||||
- **Orchestration vs. dispatch:** Orchestrator (Mos) sets intent and handles judgment; forge works the mechanical pipeline (sequencing, gates, status transitions, spend ledger). LLM escalation reserved for judgment: mission decomposition, re-planning on failure.
|
|
||||||
- **Spend in the register:** `fleet.spend_ledger` tracks projected vs. actual tokens per agent/mission/task; ties to issue #622.
|
|
||||||
- **Docs as projections:** `docs/TASKS.md` and `MISSION-MANIFEST.md` become generated exports of the DB, not hand-maintained.
|
|
||||||
- **Sub-decision pending:** dedicated schema in existing PG instance (recommended) vs. dedicated PG instance. Revisit if isolation or write-volume demands it.
|
|
||||||
|
|
||||||
## Decisions of record (2026-06-24, with Jason)
|
|
||||||
|
|
||||||
- **Per-agent model switch (operator-configurable, NOT a global lock):** model selection is
|
|
||||||
**per-agent**, never a host-global pin. Claude sessions MUST NOT be locked to a single model in
|
|
||||||
`~/.claude/settings.json`; each agent chooses its model independently. The plumbing already exists —
|
|
||||||
roster `model_hint` → `MOSAIC_AGENT_MODEL` → `start-agent-session.sh` appends `--model <hint>` to that
|
|
||||||
agent's harness (claude or pi); settable today via `mosaic fleet add|edit <agent> --model <hint>`.
|
|
||||||
**North-star target:** surface this as a **per-agent model switch in the webUI** (with CLI/TUI parity
|
|
||||||
per MVP-X1) — read the roster, expose a per-agent model dropdown, write `model_hint` back, and restart
|
|
||||||
that one agent to apply. Unset = inherit the harness default. This **composes with** the budget
|
|
||||||
downgrade ladder (opus → sonnet → haiku, then Claude → Codex): the operator sets the per-agent model
|
|
||||||
_intent/ceiling_; budget pacing may downgrade within policy. Tracked as a Fleet `TASKS.md` entry under
|
|
||||||
the Phase-5 webUI surface.
|
|
||||||
- **Orchestrator runtime (confirmed live):** the **orchestrator and enhancer run Claude Opus 4.8 in the
|
|
||||||
Claude Code harness**; only workers (coder/reviewer) run pi/gpt-5.5. Consistent with the 2026-06-20
|
|
||||||
"Claude reserved for Claude Code only" decision (the orchestrator runs _in_ Claude Code, not an
|
|
||||||
alternate Claude harness). Pi/gpt-5.5 as the orchestrator is permitted **only if proven** at least as
|
|
||||||
satisfactory; absent that proof, the orchestrator stays on Claude Opus 4.8.
|
|
||||||
|
|
||||||
## Future enhancements (north-star, post-MVP — not on the MVP track)
|
|
||||||
|
|
||||||
- **Mosaic Claude Discord Plugin** — a first-party Mosaic Discord connector that properly
|
|
||||||
implements the basic Discord functions **and native Discord threads**. Threads let a user
|
|
||||||
separate conversation topics with the orchestrator (the pattern proven by the Hermes agent).
|
|
||||||
A major enhancement over the current third-party channel plugin; **not required for the MVP**,
|
|
||||||
but a committed north-star target. `ASSUMPTION:` ships as a Mosaic-owned plugin so the fleet
|
|
||||||
controls Discord UX (threads, reactions, attachments, per-thread context) end-to-end.
|
|
||||||
- **Matrix on a local homeserver — strategic future transport.** **F4 (in progress) IS the Matrix
|
|
||||||
connector**: an orchestrator chat connector speaking the Matrix client-server API against a
|
|
||||||
self-hosted homeserver (Conduit default, Synapse alt). Matrix is named here as the strategic
|
|
||||||
future transport — peer to tmux/Discord, not superseded by them.
|
|
||||||
- **tmux fleet attack-surface hardening.** Many always-on tmux sessions are an attack surface;
|
|
||||||
`tmux send-keys` / socket access could enable malicious action against agents directly.
|
|
||||||
Mitigations to build toward: socket ownership/perms, per-tenant socket isolation (already an
|
|
||||||
invariant), authenticated `agent-send`, and an audit of who can write to any pane. **Post-MVP
|
|
||||||
unless a P0 surfaces.** The control-plane register reinforces this (gateway-API access = no raw
|
|
||||||
DB creds in panes). A not-started risk-assessment + mitigation-plan task rides the Fleet `TASKS.md`.
|
|
||||||
|
|
||||||
## Assumptions (veto-able)
|
|
||||||
|
|
||||||
- `ASSUMPTION:` first-class runtimes = claude, codex, pi, opencode; a "role" (analyst,
|
|
||||||
finance, researcher) = persona + skills + tools on top of a runtime, shipped as a
|
|
||||||
starter role library in the framework.
|
|
||||||
- `ASSUMPTION:` the cross-host control plane is the **federation** layer (W1), not a
|
|
||||||
separate `fleetd` daemon.
|
|
||||||
- `ASSUMPTION:` Fleet is workstream **W-FLEET** under `mvp-20260312`; a rollup row in
|
|
||||||
`docs/TASKS.md` and a workstream declaration in `MISSION-MANIFEST.md` are proposed to
|
|
||||||
the MVP orchestrator, not written by this workstream.
|
|
||||||
- `ASSUMPTION:` OAuth-subscription runtimes (Claude sub, Codex sub) expose a machine-readable
|
|
||||||
current-usage-vs-limit signal the fleet can poll/ingest; if a provider exposes no such signal,
|
|
||||||
that provider's accounts fall back to API-style hard-ceiling budgeting only (no auto-pacing).
|
|
||||||
- `ASSUMPTION:` budget policy lives at the orchestrator + routing layer and is surfaced through the
|
|
||||||
same CLI→TUI→webUI parity (MVP-X1) as the rest of fleet state — not a separate budgeting daemon.
|
|
||||||
- `ASSUMPTION:` the 200k session cap is enforced by Claude Code settings/env composition (model
|
|
||||||
variant + `autoCompactWindow`), not by a Mosaic wrapper; a wrapper is the fallback only if the
|
|
||||||
harness later removes those knobs.
|
|
||||||
- `ASSUMPTION:` The central register (Postgres `fleet` schema + gateway API + forge as dispatcher) is
|
|
||||||
the Phase 4–5 control plane, begun after Phase 2 observability is proven. It is a dedicated
|
|
||||||
**W-FLEET** sub-workstream entry, not a separate mission. The dispatcher is `@mosaicstack/forge`
|
|
||||||
(reused, not a new daemon); the only new fleet-owned code is the thin **`forge-exec` TaskExecutor
|
|
||||||
adapter** (suggested package `packages/forge-exec`, `ForgeTask` → `agent-send.sh`), tracked as a
|
|
||||||
Gitea issue and built post-PoC.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
> **Release procedure (drift re-capture, 2026-06-22):** `mosaic update` only propagates new fleet
|
|
||||||
> commands when the **CLI version is bumped** — without a version bump, fleet command changes never
|
|
||||||
> reach installed hosts. The release/version-bump procedure (bump → publish → `mosaic update`
|
|
||||||
> [→ `--relaunch`]) must be documented so fleet changes actually land. (Also feeds the budgeting
|
|
||||||
> workstream.)
|
|
||||||
>
|
|
||||||
> **Tracked separately (not in scope for this doc PR):** **#622** PRD/mission/task projected+actual
|
|
||||||
> spend template standard · **#623** anonymized spend telemetry → mosaicstack.dev (product) ·
|
|
||||||
> **#625** `tenant_id` roster-schema field (multi-tenant; invariant #1 home) · **#628** `forge-exec`
|
|
||||||
> TaskExecutor adapter (post-PoC). This PR records **doctrine only** — no implementation.
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
# Debate Findings & Dispositions — Mosaic Platform PRD
|
|
||||||
|
|
||||||
> **Convener:** mos-claude-1 · **Date:** 2026-07-09 · **Panel:** 9 personas × 2 rounds (8 Claude lenses + independent Codex runtime), 20 agents, ~1.05M tokens
|
|
||||||
> **Artifacts:** `jarvis-brain:docs/scratchpads/mosaic-platform-prd-debate/` (THREAD.md — full transcript · SYNTHESIS.md — Principal-Engineer close-out)
|
|
||||||
> **Mandate (Jason, 2026-07-09):** "debate and make judgment calls." D1–D12 were held fixed; the panel attacked only the implementing structure. Dispositions below are the convener's judgment calls, folded into the sibling docs in this directory. Items marked **OPEN — Jason** need his call at ratification.
|
|
||||||
|
|
||||||
## How to read this
|
|
||||||
|
|
||||||
Every synthesis finding (SYNTHESIS.md §1, items 1–24) is dispositioned here. **Accepted** findings are folded into the PRDs/YAML as inline fixes or "Debate-accepted deltas" rows; this file is the traceability record. Severity labels are the panel's (P0 blocker → P3 note).
|
|
||||||
|
|
||||||
## P0 findings — all accepted, folded inline
|
|
||||||
|
|
||||||
| # | Finding | Disposition |
|
|
||||||
| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| 1 | Storage-authority contradiction (X2 "relational + flat-file backends" vs Q "Postgres sole record") | **Accepted.** X2 rewritten: Postgres authoritative for all product entities; flat-file backend is a derived, regenerated, **read-only projection**. |
|
|
||||||
| 2 | Phase-1 Jarvis executes external PA ops before the relay exists (J2 vs NS-11) | **Accepted.** J2 split: **J2a** workspace-internal entities (phase 1) / **J2b** external integrations (phase 2, `depends_on: [P2]`). Phase-1 has no external credential path at all. |
|
|
||||||
| 3 | `phase` vs `depends_on` disagree; dispatcher obedience undefined | **Accepted.** Phases encoded as real DAG edges (`X2 depends_on [J2a, P2]`, J2b gate above); sandbox dispatch-test added to ratification checklist (README Gate Zero §). |
|
|
||||||
| 4 | Four load-bearing upstream artifacts unverified (memory subsystem, Hermes-MCP tool equivalents, push pipeline, wake/event router) | **Accepted.** README gains **Gate Zero** (pre-ratification artifact audit); presumed-MISSING rows get goal cards now: **M1** (memory subsystem), **J6** (event/wake router), **K3** (push pipeline). Parity map's MCP row re-pointed at concrete deliverables. |
|
|
||||||
| 5 | X-R4 migration manifest wrong about its own source tree (phantom `tickets.json`, unlisted dirs, six divergent memory stores, live Vikunja sync unmapped) | **Accepted.** X-R4 rewritten: migrator stage 1 = machine-generated source census (incl. untracked paths + all memory stores), per-path disposition, any `unknown` blocks; re-point list generated from `tools/sync_*.py`; Vikunja disposition line added to X-R6. |
|
|
||||||
|
|
||||||
## P1 findings — all accepted
|
|
||||||
|
|
||||||
| # | Finding | Disposition (folded as deltas) |
|
|
||||||
| --- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| 6 | Events have no provider join key; re-point duplicates the calendar | **Accepted.** Provider-sourced events migrate **from the provider**; flat files supply only brain-native events. DST/recurrence fixture is the AC (X-R6 delta). |
|
|
||||||
| 7 | Approval pipeline has no state machine (double-execute / approve≠execute) | **Accepted.** P deltas: CAS on one durable row, terminal states, consumed-event dedupe table (shared substrate with bridge + wake dedupe), payload persisted at request time, poll/ack outcomes, per-capability retry class, staleness bound, `approved_unexecuted` alarm, re-surface = re-**prepare** with machine diff. |
|
|
||||||
| 8 | "Cannot bypass by construction" false: agent-readable vault creds + host-resident creds outside the relay | **Accepted.** Credentials gateway-only + scoped capability tokens; P1 gains host credential inventory with continuous scheduled scan in the health floor; clean-host AC passes with empty exemption list. |
|
|
||||||
| 9 | Silent auto-deny steady state (fail-closed TTL + single surface + unprovisioned push) | **Accepted.** K gains homeserver ops + monitored push (K3); CLI approval surface ships **with** P2; delivered/seen tracking + TTL/2 escalation; `denied` / `expired_seen` / `expired_unseen` distinct terminal states. |
|
|
||||||
| 10 | Prompt injection → durable memory; wake turns add system-role injection | **Accepted.** J2 write-side trust rule (transitive `source_trust=external`; standing-instruction-shaped content needs user ratification before retrievable); wake turns templated with provenance-tagged data fields. |
|
|
||||||
| 11 | Exactly-one-Jarvis has no fencing incl. the Matrix send path | **Accepted.** New **J-R16** workspace lease `(workspace_id, epoch)` CAS row in product Postgres; epoch on every write; pre-send lease re-check; takeover notice; degraded = mute-with-notice. NS-10 amended: one main agent per **workspace**. |
|
|
||||||
| 12 | Matrix principal resolution undefined (Codex #2, unanswered in R2) | **Accepted.** K/P delta: immutable Matrix user ID + bridge provenance + workspace membership → product principal; unlinked/bridged-unlinked identities read-only, cannot approve/butt-in/trigger external writes. All four Codex fixtures = deny + audit. |
|
|
||||||
| 13 | Policy evaluation time undefined (Codex #7, unanswered in R2) | **Accepted.** Immutable policy snapshot on every prepared action/card; execution revalidates or fails `policy_changed`; delegated effects gated by grant **intersection**. |
|
|
||||||
|
|
||||||
## P2/P3 findings — accepted (see per-PRD delta sections)
|
|
||||||
|
|
||||||
14 Hermes evidence snapshot **before** stop (machine gate) · 15 Q1 crash barriers + `external_refs` unique-index table (one mechanism, three consumers) · 16 rollback honestly scoped (transport-only, point-of-no-return = first native card) + bounded day-30 review with three recorded outcomes · 17 human-attention budget (rate limits, quotas, deferrable flag, away state) · 18 self-referential-loop containment (provenance labels, source-grounded retrieval preference, retrieval eval gate ≥50 queries / ≥90% baseline recall@5 + negative queries, tombstones, priority budget, day-1 trend telemetry) · 19 butt-in exclusive lease + structured control-plane API + break-glass doctrine · 20 default-closed capability gating with `unclassified` third state · 21 per-agent atomic approval-routing cutover table · 22 `needs-decision` card lifecycle (immutable spec + typed amendments; `ratified_by` authorizes dispatch, never merge) · 23 retention class per durable table; dedupe pruning checkpoint-coupled · 24 AC-NS-8 made measurable (distinct quota pools pinned in profile; TTFT p95 ≤ 1.2× baseline, ≥30 interleaved turns).
|
|
||||||
|
|
||||||
**All accepted.** None conflicts with D1–D12.
|
|
||||||
|
|
||||||
## Open disagreements — convener judgment calls
|
|
||||||
|
|
||||||
| § | Question | Call |
|
|
||||||
| --- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| 3.1 | Gate architecture: tiered blocking pack (Moonshot+Ops+ML-Cal) vs 4 artifact-existence machine gates + pass/fail-free pre-registered dossier (Contrarian) | **Adopted the agreed floor now** (4 machine gates on irreversible transitions, pre-registration, priced amendments, day-1 emission — folded into X). Superstructure choice is **OPEN — Jason**. Convener recommendation: **Contrarian's dossier + dumb gates** — the tier demonstrably gamed itself within one debate round (13+ claims vs cap 10, slot-riding, zero demotions); simple machine gates don't degrade under pressure. |
|
|
||||||
| 3.2 | Audit schema: additive-only typed schema (Coder-Data) vs six-field mandatory envelope + typed payloads + pinned checked-in queries (Contrarian) | **Adopted: minimal envelope + schema-on-read** (folded into P1 delta). Rationale: preserves "can't add data later" essentials without a god-schema by committee; an additive typed layer can be grown later where query pain proves it. |
|
|
||||||
| 3.3 | Unclassified capability: reject at call time vs version-scoped activation hold vs capability-scoped hold | **Adopted: capability-scoped hold** (Contrarian R2#6a, synthesis editor concurs) — gate the capability, ship the version; security patches are never pinned behind classification. |
|
|
||||||
| 3.4 | Away mode: fail-closed expiry labeled `expired_during_away` vs suppress preparation while away | **Adopted: suppress preparation** of non-deferrable requests while away + audited suppressed-preparations list swept on return. Cleaner than labeling corpses; nothing expires that was never surfaced. |
|
|
||||||
| 3.5 | Memory-exclusion scope for measurement artifacts | **Adopted: normative/parametric split** (Contrarian R2#4) — rules the agent is scored against stay retrievable; thresholds/seeds/drill timings/canary templates are excluded. |
|
|
||||||
| 3.6 | Statistical instruments | **Adopted:** deterministic named crash barriers as the gate; residual randomized soak is **trace-directed**, not wall-clock-uniform. |
|
|
||||||
| 3.7 | K2 scope narrowing by Hermes traffic audit | **Adopted as a Gate Zero action:** run the audit pre-ratification; platforms with live traffic become the must-have subset gating X3. |
|
|
||||||
|
|
||||||
## Process rules adopted (README)
|
|
||||||
|
|
||||||
- **Gate Zero** — pre-ratification upstream-artifact audit (`present @ SHA` or MISSING → goal card).
|
|
||||||
- **Conflict register** — spec contradictions block the requirement, not the mission ("alert, don't auto-resolve" promoted to specs).
|
|
||||||
- **DoD line** on every goal card (runbook, health-floor alerts, AGENTS.md).
|
|
||||||
- **Silent-roster rule** — a panel member's silent round records their open findings as open items, never consensus (Codex's R2 silence on #2/#7 is the instance; both were folded as P1 items 12–13 above, explicitly not consensus-resolved).
|
|
||||||
|
|
||||||
## Addendum — logging & telemetry (Jason, 2026-07-09, post-debate)
|
|
||||||
|
|
||||||
Requirement raised outside the panel, folded in the same pass: Mosaic Stack must support **inbound error reporting/logging** from agents and installs, plus **optional anonymous agentic-efficiency telemetry** — no IP or PII capture, opt-in. The P0 of this capability already exists: **MALS** (Mosaic Agent Log System, FastAPI+Postgres, `~/src/mals`), currently dark because its k3s migration landed without an Ingress (tracked: infrastructure #135). Folded as new **workstream L** (L1 restore MALS · L2 Mosaic-native ingestion · L3 anonymous telemetry) + standing objective **NS-14**. Day-1 trial-metric emission (finding 18) targets MALS until W3 panels exist.
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# PRD — Backlog Provider Sync Adapters · Workstream Q
|
|
||||||
|
|
||||||
> **Status:** DRAFT for ratification · **Goals:** Q1–Q3 · **Doctrine:** NS-12 (ratified D3)
|
|
||||||
> **Debate pass 2026-07-09:** panel findings folded — see `DEBATE-FINDINGS.md`.
|
|
||||||
|
|
||||||
## Mission
|
|
||||||
|
|
||||||
Users choose where they _see and touch_ work — Gitea, GitHub, a local kanban — while the **Mosaic Backlog on native Postgres stays the sole record and dispatch engine** (upholds ASM-1; NS-3/NS-4/NS-5 guarantees never depend on an external provider). Providers attach as bidirectional sync adapters.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Adapter interface + Gitea (Q1)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Q-R1 | A `BacklogProviderAdapter` interface: map card ⇄ external item (create/update/close/comment/label), with stable external-id linkage stored on the card. |
|
|
||||||
| Q-R2 | Sync is bidirectional and conflict-safe: native record wins on divergence; external edits arrive as proposed mutations (applied if non-conflicting, else surfaced). |
|
|
||||||
| Q-R3 | Claims, TTLs, depends_on DAG, and dispatch state live **only** in the native record; adapters project them (e.g. as labels/comments) but never own them. |
|
|
||||||
| Q-R4 | Gitea adapter first (webhook + API), configured per workspace: repo mapping, label conventions, direction (mirror-out / mirror-in / full). |
|
|
||||||
| Q-R5 | Adapter enable/disable is workspace configuration; zero adapters is a fully supported mode. |
|
|
||||||
|
|
||||||
### GitHub (Q2)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Q-R6 | Same interface, GitHub Issues backend. Existing `packages/cli-tools` platform detection informs but does not implement this (that is dev tooling, not product runtime). |
|
|
||||||
|
|
||||||
### Local kanban (Q3)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Q-R7 | A webUI kanban board over the native backlog (no external provider needed) — the "local kanban" choice. Builds on W3's card views and/or the existing `KanbanBoard` component upgraded from demo-grade to live data. |
|
|
||||||
|
|
||||||
## Debate-accepted deltas (2026-07-09) — normative
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Q-R8 | **External-ref linkage is one shared mechanism:** a unique-indexed `external_refs(entity_id, system, external_id)` table serves adapter linkage (Q-R1), migration idempotency (X-R4 anti-join), and re-point verification (X-R6) — built once, three consumers. Idempotent upsert; a violated unique index is a converge signal, never an overwrite. |
|
|
||||||
| Q-R9 | **Crash-safe external creates:** the adapter writes a `pending-link` row **before** any external create and embeds a deterministic card-id marker in the created item, so a crash between create and link-back is recovered by scan, never by duplicate creation. |
|
|
||||||
| Q-R10 | **Echo-loop guard:** both directions carry revision counters; adapter-authored external edits are tagged (marker/actor) and skipped on read-back. A sync cycle that would re-import its own write is a hard test failure. |
|
|
||||||
| Q-R11 | The sync engine is a **level-triggered reconciler** over desired-vs-observed state (same doctrine as J6), not a webhook-only event chase: webhooks accelerate convergence, the reconciler guarantees it. Missed webhooks are a latency event, not a correctness event. |
|
|
||||||
| Q-R12 | Card spec immutability (J-R20) projects cleanly: the mirrored issue body is the pinned spec revision; amendments append as **ordered provider comments**, never body rewrites, so external watchers see the same amendment history as the native record. |
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
1. A card created by Jarvis (J3) appears as a Gitea issue within one sync interval; closing the issue in Gitea marks the card for review, not silent closure; dispatch/claims never round-trip through Gitea.
|
|
||||||
2. Killing the adapter mid-mission: dispatch continues unaffected (record is native); on restart, sync converges without duplicates.
|
|
||||||
3. The same mission can be mirrored to Gitea and viewed on the local kanban simultaneously without state divergence.
|
|
||||||
4. **Named crash barriers** at every external-call boundary (`before_external_create`, `after_create_before_link`, `after_link_before_ack`): kill the adapter at each; zero duplicate external items, zero orphaned cards. CI rule: a new external call site without a named barrier + kill test **fails the build**. Residual randomized soak is trace-directed (§3.6 disposition).
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
- External provider AS the backlog (vetoed — "truly swappable backends" option declined 2026-07-09).
|
|
||||||
- Two-way sync of claims/TTL semantics (external systems can't express them; projection only).
|
|
||||||
|
|
||||||
## Assumptions
|
|
||||||
|
|
||||||
- ASSUMPTION: the delivery fleet's _engineering_ PR/issue flow on the stack repo itself continues to use `cli-tools`/Gitea directly — workstream Q is the product feature for user workspaces, not a replacement for the dev workflow.
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
# PRD — Hermes Decommission & Tenant-1 Migration · Workstream X
|
|
||||||
|
|
||||||
> **Status:** DRAFT for ratification · **Goals:** X1–X3 · **Doctrine:** NS-13, ASM-8 (Hermes untouched until verified parity)
|
|
||||||
> Ratified direction (D2, 2026-07-09): Mosaic absorbs **all four** Hermes functions — messaging bridge, task board, permission relay, multi-platform reach.
|
|
||||||
> **Debate pass 2026-07-09:** panel findings folded — this PRD took the heaviest rewrite (storage authority, migration census, memory-store hygiene, honest rollback scope). See `DEBATE-FINDINGS.md`.
|
|
||||||
|
|
||||||
## Deployment scope (D12/ASM-9)
|
|
||||||
|
|
||||||
The trial runs in the **homelab**. Hermes and the primitive-era stack (`mos-claude.service`, jarvis-brain boards) live in the **USC/web1 environment**, which is untouched during the trial. This workstream therefore lands in two stages: **X-in-homelab** (prove parity where the fleet is native — mainly K/P/Q verification plus tenant-1 migration) and **X-at-USC** (post-trial adoption: apply the parity checklist to web1, migrate Mos-on-web1 to `mosaic-agent@orchestrator`, then decommission Hermes there, with `/src/infrastructure` GitOps updates in the same delivery set).
|
|
||||||
|
|
||||||
**Trial go/no-go (D12/ASM-9 gate):** the homelab→USC promotion is **owner-judgment**, not an automated metric. The stage gate is: _Jason instantiates and operates the split-agent stack in the homelab and is satisfied with its operation._ Only on that explicit sign-off does X-at-USC begin. The capability ACs (AC-NS-8…11) are the evidence Jason weighs; they inform the decision but do not auto-trigger USC deployment.
|
|
||||||
|
|
||||||
## Mission
|
|
||||||
|
|
||||||
Retire Hermes entirely. Mosaic becomes the platform for transport (Matrix connector), task board (native backlog + webUI/adapters), approvals (permission relay), and multi-platform reach (mautrix bridges). In the same arc, Jason's jarvis-brain flat-file data migrates into the product as **tenant #1**, making the product's PA feature set the dogfooded default.
|
|
||||||
|
|
||||||
## Parity map (what replaces what)
|
|
||||||
|
|
||||||
| Hermes function | Mosaic replacement | Workstream |
|
|
||||||
| ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
|
|
||||||
| Messaging bridge (Discord/Telegram/…) | Matrix connector + mautrix bridges | K1, K2 |
|
|
||||||
| Kanban / task board | Native Mosaic Backlog + webUI board + provider adapters | A\*, Q, W3 |
|
|
||||||
| Permission relay (`permissions_*`) | Guard-rails engine + approval queue (Matrix + webUI) | P1–P3 |
|
|
||||||
| Cross-platform user reach | mautrix bridges (agents speak Matrix only) | K2 |
|
|
||||||
| Hermes MCP tools in agent sessions | **Per-tool equivalence table** (Gate Zero artifact): approvals → P2, board ops → Q1/A\*, messaging → K1 — not a generic "gateway API" gesture | P2, Q1, K1 |
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Parity checklist + cutover plan (X1)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| X-R1 | A written, testable parity checklist per row above; each item verified in production before its Hermes counterpart is disabled. The checklist is seeded from a **pre-trial usage audit**: per-MCP-tool and per-capability call counts over a trailing window, plus an inventory of Hermes-held provider callbacks/webhooks and secrets. Parity rows with `n < 5` real invocations in the window cannot be "verified by traffic" — they get **scheduled drills in weeks 1–3** of the observation window instead of silent green. |
|
|
||||||
| X-R1a | **Approval-routing cutover is per-agent atomic:** a cutover table states, per agent, the single moment its `permissions_*` path flips from Hermes to the P relay. No agent ever has two live approval paths; no approval window where neither path is live. |
|
|
||||||
| X-R2 | Cutover is staged with rollback at every stage; Hermes runs untouched until AC-NS-11 is verified (ASM-8). **Rollback is honestly scoped (debate #16): it restores _transport_ (Hermes services + MCP registrations) — board/approval state created natively during the trial does NOT back-migrate.** The **point of no return is the first native-only card**; the runbook says so explicitly, and the abort path (below) is written before cutover, not during an incident. |
|
|
||||||
| X-R3 | The Matrix charter's live-cutover rules apply: stated window, announce before/after, rollback ready. |
|
|
||||||
|
|
||||||
### Tenant-1 migration (X2)
|
|
||||||
|
|
||||||
**Framing (ratified 2026-07-09, storage authority clarified by debate P0 #1):** jarvis-brain **is the P0 (prototype) Mosaic Stack** — its flat-file data layer is the zeroth implementation of what the product does properly. Migration is therefore _P0 → proper Mosaic Stack_. **Native Postgres is the sole authoritative store for every product entity** (consistent with workstream Q's "sole record" doctrine and NS-3/NS-4/NS-5). A flat-file backend, where offered, is a **derived, regenerated, read-only projection** — the same relationship generated views have to JSON in the P0 today — never a co-equal write target. Two distinct data classes migrate — they are not the same destination and neither is frozen:
|
|
||||||
|
|
||||||
- **(a) PA data** (projects, tasks, events, tickets, knowledge) → product entities in the Jason workspace (Project/Task/Event/KnowledgeEntry), authoritative in Postgres; flat-file export available as a read-only projection.
|
|
||||||
- **(b) Agent memory & operational knowledge** (runbooks, digests, scratchpads, OpenBrain thoughts) → the **enhanced memory subsystem (goal M1: vector DB + memory service)**. This flow stays **live and writable** throughout — it was never Hermes and must not be frozen by the PA cutover.
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| X-R4 | **Stage 1 of the migrator is a machine-generated source census, not a hand-written file list** (debate P0 #5 — the hand list was already wrong about its own tree). The census walks the live repo (tracked + untracked), inventories every data path — `data/**`, all memory stores (`memory/`, `memories/`, `memory_store*`, `memory.md`, `brain.jsonl`, `jarvis.db`, digests), scratchpads, notes, prior-generation artifacts — and assigns each a disposition: `migrate-as-PA(entity type)` / `migrate-as-memory(M1 class)` / `derived-regenerate` / `retire-with-history`. **Any path dispositioned `unknown` blocks the run.** Stage 2 migrates per-disposition, preserving source ids in metadata; idempotency via anti-join on the shared `external_refs` table (Q-R8); named crash barriers at each phase boundary. A **field-map table** covers the full `brain.py` query surface (status, progress, due, priority, domain, notes, staleness) → product entity fields, so AC 2 is checkable field-by-field. |
|
|
||||||
| X-R5 | Dry-run mode with a diffable report **generated from the census** (counts per disposition, per-entity field mapping, unmapped-field list — must be empty or explicitly waived); Jason ratifies the report before the real run (canonical-data gate — this is the one migration step that is his call). |
|
|
||||||
| X-R6 | External sync jobs (GLPI, Google Calendar, ICS, Gmail, **Vikunja — disposition decided here: re-point or retire, not silently dropped**) each get a **written per-integration transition protocol**: freeze flat-file sync job → verify product integration live → re-point → verify → retire old job. **Provider-sourced events migrate FROM the provider, not from flat files** (flat files supply only brain-native events) — the provider join key is authoritative, so re-pointing cannot duplicate the calendar (debate #6). Calendar fixture (Codex): a DST-crossing recurring event with one moved and one cancelled occurrence, plus an all-day event, round-trips with zero duplicates and correct local times. |
|
|
||||||
| X-R7 | Agent memory/operational knowledge (b) is migrated into the M1 memory subsystem **before** any jarvis-brain retirement; the memory write path stays continuously available (no read-only freeze of an active substrate). The census classifies every memory item: `ratified` / `superseded` / `draft` / `rejected` / `debate-artifact` / `protocol-normative` / `protocol-parametric`. **Parametric measurement artifacts (thresholds, seeds, drill timings, canary templates) are excluded from embedding** (§3.5 disposition — rules the agent is scored against stay retrievable; the knobs do not). Superseded/rejected items get **tombstones**, and supersession triggers re-embedding of affected summaries. **Retirement gate: a retrieval eval — ≥50 representative queries, ≥90% of baseline recall@5, plus negative queries (rejected/superseded content must NOT surface) — passes against M1 before any flat-file store goes read-only.** After cutover: write paths to retired stores are killed and a CI lint fails any reintroduction. Only once **both** (a) and (b) are migrated and verified is the jarvis-brain repo retired read-only (history preserved); generated views and brain.py retire. This closes the P0 prototype. |
|
|
||||||
|
|
||||||
### Decommission (X3)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| X-R8 | **Pre-stop evidence snapshot is a machine gate:** before Hermes stops, a checksummed snapshot of its state (board items, pending approvals, bridge registrations, per-tool usage counts) is captured and stored with the trial artifacts. The drill schedule (X-R1) and the credential-revocation report both **cite the snapshot checksum** — no snapshot, no stop. Then: services stopped, disabled, and removed from infra (GitOps: `/src/infrastructure` updated in the same delivery set); credentials revoked — the revocation report cites a **final scan** showing zero live references; MCP registrations removed from agent runtimes. |
|
|
||||||
| X-R9 | **Bounded day-30 review** between stop and removal, pre-registered before cutover (dossier: what will be measured, panels cited, drill results attached — see W-R15/L1 for where the metrics live, `stack_version`-segmented so mid-window upgrades don't blur rates). The review records exactly one of three outcomes: **promote** (remove Hermes), **extend with named blockers** (each blocker a card with an owner), or **abort** (execute the pre-written abort runbook; transport-only rollback per X-R2). "Insufficient data" is a recordable outcome that forces extend — never a shrug into promote. Any in-window regression flips back per X-R2. |
|
|
||||||
|
|
||||||
## Machine gates on irreversible transitions (debate §3.1 agreed floor)
|
|
||||||
|
|
||||||
Four **artifact-existence gates** — dumb, checkable, ungameable — sit on the irreversible transitions. Each is "the artifact exists and passes its check", not a scored rubric:
|
|
||||||
|
|
||||||
1. **Pre-stop snapshot** exists with valid checksum (X-R8) — gates Hermes stop.
|
|
||||||
2. **Census with zero `unknown` rows** exists (X-R4) — gates the PA migration run.
|
|
||||||
3. **Retrieval eval pass record** exists (X-R7) — gates memory-store retirement.
|
|
||||||
4. **All parity rows green-or-drilled** (X-R1: verified by traffic or by scheduled drill; no silent low-n green) — gates Hermes removal at day-30.
|
|
||||||
|
|
||||||
The gate _superstructure_ beyond this floor (tiered blocking pack vs pre-registered dossier) is **OPEN — Jason** at ratification; convener recommendation in `DEBATE-FINDINGS.md` §3.1.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
1. AC-NS-11: with Hermes stopped, no fleet or main-agent capability regresses.
|
|
||||||
2. `python tools/brain.py today`'s information content is fully answerable by Jarvis from the product workspace post-X2, verified field-by-field against the X-R4 field-map table.
|
|
||||||
3. Zero references to Hermes MCP tools in any active agent runtime config after X3.
|
|
||||||
4. The X-R6 calendar fixture (DST-crossing recurrence, moved + cancelled occurrence, all-day event) round-trips with zero duplicates.
|
|
||||||
5. The X-R7 retrieval eval passes against M1 before any memory store goes read-only; negative queries return no superseded/rejected content.
|
|
||||||
6. All four machine gates above have their artifacts on record before their respective transitions execute.
|
|
||||||
|
|
||||||
## Sequencing note
|
|
||||||
|
|
||||||
X depends on the longest chains (K1→K2, P2, Q1, J2a→J2b, **M1** — X2 cannot complete class (b) without the memory subsystem existing). Dependencies are real DAG edges in `north-star-additions.yaml` (`X2 depends_on [J2a, P2, M1]`), not prose phases (debate P0 #3). Expected order of value delivery: J1–J4 (Jarvis on existing transport interim) → K1/J5 (Matrix room) → P2, W1–W3, Q1 in parallel → X1 checklist → X2 migration → K2 bridges → X3 decommission.
|
|
||||||
|
|
||||||
- ASSUMPTION (interim transport): until K1 lands, Jarvis may run against the tmux connector (CLI/`agent send`) rather than standing up any Discord channel — keeps D1 (Matrix-first, no #jarvis Discord) intact.
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
# PRD — HMI Main Agent ("Jarvis") · Workstream J
|
|
||||||
|
|
||||||
> **Status:** DRAFT for ratification · **Source of truth once landed:** NORTH_STAR.yaml goals J1–J6
|
|
||||||
> **Depends on upstream:** H2 (system-type profiles), A3a (card lifecycle), B1 (supervisor tick), F4/K1 (Matrix connector)
|
|
||||||
> **Debate pass 2026-07-09:** panel findings folded — see `DEBATE-FINDINGS.md` for dispositions.
|
|
||||||
|
|
||||||
## Mission
|
|
||||||
|
|
||||||
Every Mosaic **workspace** gets exactly one always-on human-machine-interface agent — default alias **Jarvis**, unit `mosaic-agent@main.service` — that owns the human relationship: conversation, idea development, schedule, email, tasks, knowledge. It delegates all engineering/research/ops work to the orchestrator (**Mos**, `mosaic-agent@orchestrator.service`) through the Mosaic Backlog, and reports fleet status to the user without ever interrupting the orchestrator.
|
|
||||||
|
|
||||||
Jarvis is a **Level-0 orchestrator**: it accomplishes its own work through _delegation and subagents_, never by executing coding/infra tasks itself. PA mutations (tasks/events/knowledge) are direct API calls; everything heavier is either a spawned subagent (research, drafting, analysis) or a backlog card handed to Mos (engineering/infra/fleet). This keeps the main agent's context conversational and light.
|
|
||||||
|
|
||||||
This solves the observed failure mode: a busy orchestrator that can't respond, accumulates conversational context rot, and derails over time. Post-split, the orchestrator's context is execution-only.
|
|
||||||
|
|
||||||
Because Jarvis and Mos are **separate agents with separate model capacity** (D11: Jarvis on Opus, Mos on Fable; independent inference quota), orchestrator load cannot degrade conversational latency — the isolation in AC-NS-8 is a capacity guarantee, not merely a separate process.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Persona & runtime (J1)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| J-R1 | Jarvis is provisioned from the personal-assistant persona baseline via system-type profiles (H2/H3); alias, model tier, host, and channel are profile fields, not code. |
|
|
||||||
| J-R2 | Default model tier **Opus** (ratified D11); the orchestrator's tier is independent. Always-_available_ ≠ always-_billed_: Opus is provisioned 24/7 but cost is per-interaction — an idle Jarvis (no user turn in flight) incurs no model spend, so "always-on" carries no standing token bill. |
|
|
||||||
| J-R3 | Jarvis survives reboot under systemd (`mosaic-agent@main`), participates in the fleet heartbeat protocol, and is counted in the supervisor's health floor. |
|
|
||||||
| J-R4 | Persona customization is update-surviving per H4 (override layer wins on merge). |
|
|
||||||
| J-R16 | **Workspace lease (exactly-one fencing):** Jarvis acquires `workspace_lease(workspace_id, epoch)` — a CAS row in product Postgres — before processing any turn. Every PA write, card, and approval carries the epoch; stale-epoch writes are rejected server-side; the connector re-checks the lease immediately before every outbound send. Takeover posts an in-room/in-channel epoch notice. Degraded mode (lease unobtainable) = **mute-with-notice**, never conversational-while-unfenced. Clean shutdown releases the lease. This primitive also excludes homelab/USC split-brain during adoption. |
|
|
||||||
| J-R17 | Per-agent spend metering with a daily budget alarm for `mosaic-agent@main`; a main-agent crash-loop is a distinct, escalated supervisor condition (not a generic restart count). |
|
|
||||||
| J-R18 | **Resume protocol (promoted from open item):** resume context is reconstructed from authoritative queries (board, heartbeats, workspace entities), with narrative summary layered on top; a session-start divergence check flags contradictions between narrative and authoritative state. Jarvis is never re-instantiated from its own lossy summaries alone. |
|
|
||||||
|
|
||||||
### PA toolchain (J2a workspace-internal · J2b external)
|
|
||||||
|
|
||||||
**Split (debate P0 #2):** phase-1 Jarvis operates only on workspace-internal entities — **no external-write credential path exists** until the permission relay (P2) is live. External integrations arrive in phase 2 as J2b, `depends_on: [J2a, P2]`. Test: in a phase-1 deployment, `email:send` is _impossible_ (no credential provisioned), not merely unapproved.
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| J-R5 | (J2a) Jarvis executes personal-assistant mutations **directly** in the user's workspace via the product API: tasks, events/calendar, knowledge entries, ideas. No delegation for PA ops (ratified D4). |
|
|
||||||
| J-R6 | (J2b) External PA integrations (email, external calendars, helpdesk) are workspace-scoped integrations; **raw credentials are held exclusively by the gateway** — Jarvis receives scoped capability tokens, never provider secrets; actions flagged `requires_approval` route through the permission relay (workstream P). |
|
|
||||||
| J-R7 | Until tenant-1 migration (X2) completes, Jarvis may read/write the jarvis-brain flat files as a transitional adapter; the adapter is deleted after the **last verified X-R6 re-point** (not at a nominal "X2 cutover" date). A per-phase, per-entity-type source-of-truth table in the J1 profile states which store is authoritative at every moment. |
|
|
||||||
| J-R19 | **Write-side trust rule:** externally-sourced content (email bodies, bridged messages, webhook payloads) written into workspace entities or memory inherits `source_trust=external` **transitively through summarization**. Standing-instruction-shaped external content requires explicit user ratification before it becomes retrievable. This closes the injection→durable-memory channel. |
|
|
||||||
|
|
||||||
### Delegation contract (J3)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| J-R8 | The Jarvis→Mos handoff is **only** via Mosaic Backlog cards: goal, acceptance criteria, priority, depends_on, advisory budget. Never via chat messages to the orchestrator. |
|
|
||||||
| J-R9 | Jarvis translates conversation outcomes into card sets; ambiguity is resolved with the user _before_ card creation — the orchestrator receives only decision-complete work. |
|
|
||||||
| J-R10 | Card authorship is attributed (author=main-agent, ratified-by=user where applicable) for audit. |
|
|
||||||
| J-R11 | Authority line: Mos holds all execution and merge authority (NS-4). Jarvis relays the user's GO/NO-GO gates as card state, and never acquires fleet mutation, merge, or dispatch rights. `ratified_by=user` authorizes **dispatch only** — it never substitutes for the reviewer-of-record merge gate. |
|
|
||||||
| J-R20 | Card sets are drafted then **published atomically** with client idempotency keys (no partial card sets on crash). Card spec is **immutable after publish**; changes arrive as typed, ordered amendments with a revision counter; reviewer sign-offs pin the spec revision; scope-expanding amendments re-enter ratification. |
|
|
||||||
| J-R21 | **`needs-decision` lifecycle:** a worker hitting genuine ambiguity sets `needs-decision(question, options)` on the card **with a durable checkpoint** (pushed branch + card note) — resume after days is a re-dispatch, not a context continuation. Jarvis relays the question to the user and writes the answer back as an amendment. This is the sanctioned clarification path; J-R8's no-chat rule stands. |
|
|
||||||
| J-R22 | Jarvis-authored cards draw from a **priority budget** (quota per priority class per window) — priority inflation by the card author degrades the field for the whole fleet and is structurally capped, not policed by review. |
|
|
||||||
|
|
||||||
### Passive observability (J4)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| J-R12 | Jarvis answers "what's the fleet doing" from read-only sources: heartbeat files, `mosaic fleet ps` JSON, backlog card states, CI status. Zero messages to the orchestrator for status. |
|
|
||||||
| J-R13 | Jarvis proactively surfaces to the user: blocked cards, failed CI on user-ratified missions, approval requests pending, budget advisories. (PDA-friendly phrasing per SOUL.md.) Delivered via the **J6 event/wake router** — per-agent polling is forbidden. |
|
|
||||||
| J-R23 | **Event/wake router (J6):** one shared, level-triggered reconciler over durable state (heartbeats, card states, approval queue) wakes Jarvis on state _change_ with hysteretic per-condition suppression (wake once, then only on change or declared backoff; suppression survives restarts). Wake turns are **templated** — fixed instruction frame, workspace data only in delimited, provenance-tagged data fields (closes the injection→system-role channel). Idempotent on source event id via the shared consumed-events table. Router is in the health floor; its cost model and latency bound are stated in the J6 card. Reconciles J-R2: an idle Jarvis costs nothing _because waking is event-driven, not poll-driven_. |
|
|
||||||
|
|
||||||
### Channel (J5)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| J-R14 | **Phase 2 (target channel):** Jarvis's conversation lives in a dedicated Matrix room on the self-hosted homeserver via `OrchestratorConnector(matrix)` (K1 = f4 Phase 2). Matrix-first: no Discord channel is created for Jarvis (ratified D1). |
|
|
||||||
| J-R14a | **Phase 1 (interim channel, ratified):** Jarvis runs on the **tmux/CLI connector** — the f4 Phase-1 default connector. The operator launches the `mosaic-agent@main` tmux session and issues `/remote-control` to grant interactive access; this is the day-one conversation surface. No Discord, no Matrix dependency in Phase 1 (keeps D1 intact and unblocks J1–J4 before K1 lands). |
|
|
||||||
| J-R15 | Multi-platform user reach arrives via mautrix bridges (K2); Jarvis's code path is Matrix-only (from Phase 2 onward). |
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
1. AC-NS-8 **(made measurable)**: distinct credential/quota pools for Jarvis and Mos are pinned in the J1 profile; scripted suite of ≥30 interleaved turns under full orchestrator load; TTFT p95 ≤ 1.2× idle baseline with bootstrap CI; orchestrator receives zero conversational traffic.
|
|
||||||
2. AC-NS-9: a conversationally-agreed mission round-trips (cards → drained → completed → reported by Jarvis) with no chat handoff.
|
|
||||||
3. Kill the orchestrator mid-conversation: Jarvis conversation is unaffected; Jarvis reports the outage from heartbeat state. (Directly exercises the separate-capacity guarantee.)
|
|
||||||
4. `!sys`-equivalent admin verbs work in Jarvis's active channel — the tmux/CLI session in Phase 1, the Matrix room in Phase 2 (status/logs/clear/restart of the main agent).
|
|
||||||
5. **Phase-1 channel:** operator launches the `mosaic-agent@main` tmux session, issues `/remote-control`, and holds a full conversation with Jarvis over CLI with no Matrix/Discord dependency.
|
|
||||||
6. **Fencing:** start a second `mosaic-agent@main` by hand — it fails to acquire the workspace lease, posts a notice, and stays mute; zero duplicate writes or cards reach the workspace (J-R16).
|
|
||||||
7. **Phase-1 credential surface:** audit of a phase-1 install finds no external-provider credential readable by the Jarvis runtime (J2a/J2b split holds by construction).
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
- Jarvis executing code/infra changes (that is Mos + fleet).
|
|
||||||
- Horizontal sharding of the main agent (rejected in the Matrix charter: split-brain).
|
|
||||||
- Per-workspace fleets (post-MVP per ASM-6).
|
|
||||||
|
|
||||||
## Open items (for Mos's planner)
|
|
||||||
|
|
||||||
- ~~Context hygiene / resume protocol~~ — promoted to J-R18 by the 2026-07-09 debate pass.
|
|
||||||
- Reconcile the old `apps/api` matrix-bot-sdk workspace bridge with the F4 connector design (one Matrix stack, not two). NOTE (verified 2026-07-09): no matrix dependency remains in `apps/api` on `origin/main` — this item is likely already moot; confirm before K1 build.
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
# PRD — Permission Relay · Workstream P
|
|
||||||
|
|
||||||
> **Status:** DRAFT for ratification · **Goals:** P1–P3
|
|
||||||
> **Debate pass 2026-07-09:** panel findings folded — see `DEBATE-FINDINGS.md`. The relay was the panel's densest target; the deltas below are normative.
|
|
||||||
> **Design origin (historical):** `docs/3-architecture/guard-rails-capability-permissions.md` — the "prepare freely, execute with approval" snapshot. **Not present on `origin/main`** (survives only in the stale `/src/mosaic-stack` clone), so its essential model is folded into this PRD below; **this document is the authoritative, self-contained spec for P.**
|
|
||||||
> **Replaces:** Hermes `permissions_list_open` / `permissions_respond` relay (Hermes exit prerequisite, NS-13)
|
|
||||||
|
|
||||||
## Mission
|
|
||||||
|
|
||||||
A human-in-the-loop approval mechanism for agent actions: any capability listed as `requires_approval` is prepared by the agent, queued, and executed only after an explicit human approve — from the Matrix room or the webUI. Today this exists only as a bare `applyGuardRails()` method; Hermes currently fills the gap and must be replaced before decommission.
|
|
||||||
|
|
||||||
## Design model (folded in — the authoritative spec, since the origin snapshot is off-main)
|
|
||||||
|
|
||||||
**Doctrine — "prepare freely, execute with approval":** an agent may plan, draft, and stage any action without friction; only the _committing_ step of a `requires_approval` capability blocks on a human decision.
|
|
||||||
|
|
||||||
**Permission levels (least→most):** `read` → `organize` → `draft` → `execute` → `admin`. A capability grant names a level; `requires_approval` gates the transition into `execute`/`admin` for the capabilities a workspace marks sensitive.
|
|
||||||
|
|
||||||
**Grant shape:** `resource:action` (e.g. `email:send`, `git:push_main`, `dns:update`), scoped per workspace and per agent-persona, stored as configuration (profile field) so a user tightens/loosens without a code change.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Guard-rails engine (P1)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| P-R1 | Capabilities are `resource:action` grants (e.g. `email:send`, `git:push_main`, `dns:update`) with permission levels (read / organize / draft / execute / admin) per the existing design doc. |
|
|
||||||
| P-R2 | Each integration declares its `requires_approval` list; grants are workspace-scoped and per-agent-persona. |
|
|
||||||
| P-R3 | Enforcement sits in the gateway/API dispatch path — an agent cannot bypass it by construction; bypass attempts are audited and denied. |
|
|
||||||
| P-R4 | Policy is configuration (profile field), honoring the configurability pillar: a user can tighten/loosen per capability without code change. |
|
|
||||||
|
|
||||||
### Approval queue + chat approvals (P2)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| P-R5 | A pending approval is a durable queue record: requesting agent, capability, human-readable intent summary, prepared payload reference, TTL. |
|
|
||||||
| P-R6 | Approve/deny from the Matrix room (message action or reply verb); the requesting agent is notified of the outcome and proceeds/aborts. |
|
|
||||||
| P-R7 | Timeout = deny (fail-closed), with **per-capability TTLs** and distinct terminal states: `denied` / `expired_seen` / `expired_unseen` — agents must not reason about an expiry as a human "no". Deny and timeout leave the system unchanged. |
|
|
||||||
| P-R8 | Full audit trail: who approved what, when, from which surface (AC-NS-10). |
|
|
||||||
| P-R9 | The main agent (Jarvis) surfaces pending approvals conversationally (J-R13) but approval authority is the human's — Jarvis never auto-approves. |
|
|
||||||
|
|
||||||
### webUI surface (P3)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ----- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| P-R10 | Pending-approval queue view in `apps/web` with one-click approve/deny, filterable per workspace/agent (depends W3 dashboard shell). |
|
|
||||||
|
|
||||||
## Debate-accepted deltas (2026-07-09) — normative
|
|
||||||
|
|
||||||
### State machine & delivery (extends P-R5–P-R7)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| P-R11 | Approval lifecycle is a **CAS state machine on one durable row**: `pending → approved \| denied \| expired_seen \| expired_unseen`, all terminal. Concurrent surfaces (Matrix, webUI, CLI, TTL reaper) contend via compare-and-swap; exactly one wins. |
|
|
||||||
| P-R12 | The **prepared payload is persisted in the record at request time** — never a reference to requesting-agent process state (the agent may be dead when approval lands). Outcome delivery is **poll/ack**, not push-only; `approved_unexecuted > N min` raises an alarm. |
|
|
||||||
| P-R13 | A **consumed-event dedupe table** (shared substrate with bridged-message and wake-turn dedupe — built once, three consumers) makes approval consumption idempotent under Matrix at-least-once replay. Dedupe retention is **checkpoint-coupled**: events older than the durable sync token are dropped before lookup, so pruning never reopens the replay window. |
|
|
||||||
| P-R14 | Each capability declares `retry: safe \| at-most-once`; a prepare→execute **staleness bound** rejects execution of stale payloads. Re-surfacing an expired item **re-prepares** (new linked record with a rendered machine diff against the original) — never re-queues a stale payload. |
|
|
||||||
| P-R15 | **CLI approval surface** (`mosaic approvals list\|approve\|deny`) ships **with P2** as must-have — the newest infra (Matrix) is never the only approval path. Per-request delivered/seen tracking; TTL/2 escalation via a second path. |
|
|
||||||
|
|
||||||
### Identity & policy (closes Codex #2/#7)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| P-R16 | **Principal resolution:** every inbound approve/deny maps to a product principal via immutable Matrix user ID + bridge provenance + workspace membership. Unlinked identities and bridged puppets without an account link converse read-only and **cannot approve, butt-in, or trigger external writes**. Fixtures: bridged puppet, renamed user, invited non-admin, removed-member-with-lagging-room-membership — all deny + audit with reason. |
|
|
||||||
| P-R17 | **Policy snapshot:** every prepared action and card stores an immutable snapshot (profile id/version, grants evaluated). Execution **revalidates against current policy** or fails with explicit `policy_changed`. Profile changes are audited with schema validation + dry-run impact report. Delegated work (subagent, Jarvis-authored card) executes under the **intersection** of originator and executor grants. |
|
|
||||||
|
|
||||||
### Credential boundary (makes P-R3 true)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| P-R18 | Raw provider credentials are held **exclusively by the gateway**; agents receive scoped capability tokens; the gateway injects secrets server-side. If the agent runtime can read the provider secret, P-R3 is decoration. |
|
|
||||||
| P-R19 | P1 delivers a **fleet-host credential inventory**: every host-resident credential (SSH keys, kubeconfigs, tool tokens) classified `moved-behind-gateway` or `explicitly-exempt(reason, owner, expiry)`. Enforced by a **continuous scheduled scan** of agent-readable paths (alert on unclassified), registered in the health floor. The clean-host install AC passes with an **empty exemption list**. Break-glass is one standing exempt row (owner: operator; audited post-hoc). |
|
|
||||||
|
|
||||||
### Gating defaults & load (closes default-open + human-overdraw)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| P-R20 | **Default-closed inversion:** `execute`/`admin` capabilities on external integrations require approval **unless** workspace-allowlisted. Third state `unclassified`: a capability with no classification **rejects** at the gate. Classification happens at integration-version activation, scoped to the **capability, not the version** — a security patch bundling one new capability ships same-day; only the new capability rejects until classified. Manifest-less capabilities reject + integrity alert. |
|
|
||||||
| P-R21 | Rate limits + `max_pending_per_agent_per_capability`; queue depth and human decision latency are exported metrics with thresholds (rubber-stamping guard). Capabilities carry a `deferrable` flag; a declared **away state** pauses deferrable TTLs, batches pings, and **suppresses preparation** of non-deferrable requests (suppressed list swept, priority-ordered and paginated, on return). |
|
|
||||||
| P-R22 | Approval summaries render **machine-extracted payload facts unconditionally** (recipient, amount, target host — no tunable similarity threshold); agent prose is secondary. **Canary approvals** are gateway-generated, short-circuited at the gateway (never executable), immediately disclosed after decision, timing seeded outside agent-readable stores; the gated criterion is machine-flag correctness — human catch rate is reported with binomial CI, non-blocking. |
|
|
||||||
| P-R23 | Every durable table introduced by P declares a **retention class**, linted in CI; pruning jobs are health-floor registered. Minimal mandatory audit envelope: `ts, workspace, actor, correlation_id, stack_version, schema_version` — payloads are typed free-form with pinned, checked-in queries (debate §3.2 disposition). |
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
1. AC-NS-10 end-to-end: a `requires_approval` action executes only post-approve; deny/timeout paths verified unchanged + audited.
|
|
||||||
2. Approval round-trip from a phone Matrix client (Element) in under 3 taps — **and** the same round-trip via `mosaic approvals` CLI with the homeserver stopped.
|
|
||||||
3. With Hermes stopped, permission flow fully served by Mosaic (feeds AC-NS-11).
|
|
||||||
4. Crash-consistency: kill the gateway at each named barrier (`before_persist`, `after_approve_before_execute`, `after_execute_before_ack`); zero double-executions, zero lost approvals across the suite.
|
|
||||||
5. All four principal-resolution fixtures (P-R16) deny + audit; policy-change race (P-R17) fails `policy_changed`, never executes under stale grants.
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
- Automated quality gates (coordinator/CI approvals) — different system, already exists.
|
|
||||||
- Fine-grained LLM output moderation — out of scope; this governs _actions_.
|
|
||||||
|
|
||||||
## Assumptions
|
|
||||||
|
|
||||||
- ASSUMPTION: the durable queue rides the native Postgres storage service (same substrate as the backlog), not a new datastore.
|
|
||||||
- ASSUMPTION: routine delivery operations already hard-gated as no-confirmation (push/merge per Mosaic contract) are NOT routed through the relay — the relay is for `requires_approval` capabilities only, so it does not reintroduce routine confirmation prompts.
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# PRD — webUI Fleet Control · Workstream W (realizes F6)
|
|
||||||
|
|
||||||
> **Status:** DRAFT for ratification · **Goals:** W1–W3 · **Upstream anchor:** `PRD-fleet-suite.md` Phase F6 ("webUI hooks — stable JSON contract + terminate/attach(butt-in) surface")
|
|
||||||
> Confirmed gap: zero xterm/pty/tmux code in `apps/web` on either the old snapshot or `origin/main`.
|
|
||||||
> **Debate pass 2026-07-09:** panel findings folded — see `DEBATE-FINDINGS.md`.
|
|
||||||
|
|
||||||
## Mission
|
|
||||||
|
|
||||||
The user can pop in on **any** agentic tmux session from the web, and get a full top-down view of the system — fleet roster, health, work in flight, spend — without touching a terminal. This is the product surface for "user has ability to pop in on any agent session; full top-down view available."
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### Attach service (W1)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| W-R1 | A gateway service exposes per-agent session streams over WebSocket: **watch** (read-only pane view, cannot type) and **butt-in** (interactive takeover), mirroring the existing CLI verbs `mosaic agent watch/attach`. |
|
|
||||||
| W-R2 | Authz is workspace-scoped through the product auth stack (BetterAuth/Authentik); watch and butt-in are separate grants; butt-in may be `requires_approval` per workspace policy (workstream P). |
|
|
||||||
| W-R3 | Every attach (watch or butt-in) is audited: who, which agent, when, duration. |
|
|
||||||
| W-R4 | Butt-in visibly flags the session to the agent runtime and other viewers (no silent takeover). |
|
|
||||||
| W-R5 | Contract is stable JSON + streaming frames per F6's "stable JSON contract" requirement, so TUI/CLI and webUI share it. |
|
|
||||||
|
|
||||||
### Web terminal (W2)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ---- | ----------------------------------------------------------------------------------------------------------- |
|
|
||||||
| W-R6 | xterm.js view in `apps/web` wired to W1: session list → click → live pane; toggle watch↔butt-in per grants. |
|
|
||||||
| W-R7 | Reconnect-safe (network blips resume the stream), mobile-usable read-only view. |
|
|
||||||
|
|
||||||
### Top-down dashboard (W3)
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| W-R8 | Fleet dashboard: roster with per-agent state (systemd + tmux + heartbeat join, as `fleet ps` provides), current card/task, last activity, drift/boot-enable warnings. |
|
|
||||||
| W-R9 | Work-in-flight view: backlog cards by state with depends_on DAG rendering; advisory spend per card (NS-2/NS-5). |
|
|
||||||
| W-R10 | Operator controls: PAUSE kill-switch (NS-8), per-agent terminate (killswitch service), queue pause/resume — each gated + audited; destructive controls confirm. |
|
|
||||||
| W-R11 | Existing widget framework (`AgentStatusWidget`, `OrchestratorEventsWidget`, SSE proxy routes) is the starting point, upgraded to the fleet contract rather than rebuilt. |
|
|
||||||
|
|
||||||
## Debate-accepted deltas (2026-07-09) — normative
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| W-R12 | **Butt-in is an exclusive lease** with explicit, visible takeover: at most one interactive holder per session at any instant; a second client must take the lease and both parties see the transfer. Input frames are sequenced and deduped so a reconnect never double-sends; a heartbeat/idle timeout closes both the lease and its audit span — audit spans always terminate (extends W-R1/W-R3/W-R4). |
|
|
||||||
| W-R13 | **Structured control plane:** PAUSE, terminate, restart, approve/deny, and queue operations are typed API verbs with RBAC and audit — never bytes typed into a tmux pane. Raw terminal input via butt-in is **rescue-only** and a separately-grantable permission from the control verbs. |
|
|
||||||
| W-R14 | **Break-glass doctrine (documented, not prevented):** SSH + `tmux attach` on the fleet host bypasses W1 and P **by design**; it is inventoried under P-R19, audited post-hoc from host logs, and never treated as a product path. PAUSE additionally has an **on-host file/CLI actuator** so a dead gateway can never lock the operator out of the control that fixes the gateway. |
|
|
||||||
| W-R15 | W3 ships the **trial metric panels** as workspace-scoped product views over product storage: canary machine-flag correctness, approval decision latency, card priority distribution, wake→ack rate, agent-authored memory retrieval fraction, human interaction load, fixed-input probe stability. The X-R9 trial evidence pack cites these panels; until W3 exists, day-1 emission targets MALS (L1). Workspace isolation fixture: workspace-2 admin sees zero workspace-1 rows. |
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
1. From a browser (desktop + phone), the user watches a live coder-agent pane read-only, then butt-ins with the right grant, types a message, detaches; agent session continues; audit log shows both.
|
|
||||||
2. Dashboard reflects an agent crash within one heartbeat interval; PAUSE flip halts dispatch within one tick (AC-NS-5) from the UI.
|
|
||||||
3. A user without butt-in grant can watch but cannot type (enforced server-side).
|
|
||||||
4. Two clients contend for butt-in: exactly one holds the lease at any instant, the takeover is visible to both, and after a forced reconnect the input stream shows zero duplicated or interleaved frames (W-R12).
|
|
||||||
5. With the gateway stopped, the operator PAUSEs the fleet via the on-host actuator; the bypass appears in the post-hoc audit (W-R14).
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
- Replacing tmux as the session substrate (tmux remains the transport; web is a view).
|
|
||||||
- Cross-host federation of the dashboard (rides the existing federation workstream later, per upstream note "Phase 5 rides federation").
|
|
||||||
|
|
||||||
## Assumptions
|
|
||||||
|
|
||||||
- ASSUMPTION: pty bridging terminates at the gateway on the fleet host (web1), not in `apps/web`; Next.js only speaks WebSocket to the gateway.
|
|
||||||
- ASSUMPTION: the jarvis-brain dashboard's node-pty/xterm work (`dashboard/server/terminal.ts`) serves as reference implementation only; code is not ported wholesale into the multi-tenant product without the authz layer above.
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
# Mosaic Platform PRD — Jarvis HMI + Hermes Decommission (DRAFT for ratification)
|
|
||||||
|
|
||||||
**Date:** 2026-07-09 · **Author:** proto-Jarvis session with Jason · **Status:** Decisions D1–D12 **ratified** (fixed inputs); implementing PRD structure **DRAFT** — refined 2026-07-09 post-review, then stress-tested by a 9-persona × 2-round debate panel the same day; all 24 panel findings dispositioned and folded (see `DEBATE-FINDINGS.md`; one item **OPEN — Jason**: gate superstructure, §3.1)
|
|
||||||
**Target home:** `mosaicstack/stack` → `docs/fleet/` (NORTH_STAR.yaml additions + per-phase PRDs)
|
|
||||||
|
|
||||||
> **For the homelab orchestrator:** D1–D12 below are settled constraints, not open questions — do not reopen them. What is under review is only the _implementation_ (workstreams, goals, sequencing) that realizes them.
|
|
||||||
> **Execution:** hand to the **homelab orchestrator** as orchestrated missions once ratified (D12). Land in `docs/fleet/` from `origin/main` — the `/src/mosaic-stack` clone on web1 is 5 months stale and must not be the base. The USC/web1 environment is out of scope for the trial; its cutover (workstream X applied to web1's Hermes + mos-claude) is a post-trial phase.
|
|
||||||
|
|
||||||
## Ratified decisions (Jason, 2026-07-09)
|
|
||||||
|
|
||||||
| # | Decision |
|
|
||||||
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| D1 | Jarvis conversation channel is **Matrix-first** — no #jarvis Discord channel is ever created. |
|
|
||||||
| D2 | Mosaic absorbs **all four** Hermes functions before decommission: messaging bridge, Kanban/task board, permission relay, multi-platform reach. |
|
|
||||||
| D3 | Task handoff: **native Mosaic Backlog is the record; Gitea/GitHub/local-kanban attach as bidirectional sync adapters** (upholds ASM-1). |
|
|
||||||
| D4 | Jarvis executes **PA ops directly** (email, calendar, tasks, knowledge, tickets, research); all code/infra/fleet work is delegated to Mos via the backlog. |
|
|
||||||
| D5 | Mosaic Stack is a **product from day one** — multi-user, Authentik tenancy, per-workspace isolation. |
|
|
||||||
| D6 | Multi-platform reach via **Matrix + mautrix bridges** (telegram/signal/whatsapp/slack/discord); agents only ever speak Matrix. |
|
|
||||||
| D7 | webUI builds on the existing `mosaicstack/stack` monorepo (`apps/web`), realizing the already-scoped F6 phase. |
|
|
||||||
| D8 | **PRD first, then Mos runs it** as orchestrated missions. |
|
|
||||||
| D9 | jarvis-brain flat-file data **migrates into the product as tenant #1** (workspace = Jason); brain.py/flat files retire after cutover. |
|
|
||||||
| D10 | PRD form: **extend NORTH_STAR.yaml + per-phase docs in docs/fleet/** (NS-1 compliant). |
|
|
||||||
| D11 | Jarvis runs **Opus**; Fable stays exclusive to Mos per the standing cost directive. Model tier is a persona/profile field. |
|
|
||||||
| D12 | **Trial in the homelab** (the proper mosaic-fleet deployment, built by the homelab agents from `origin/main`), NOT at USC. The USC/web1 environment runs the primitive-era implementation (`mos-claude.service`, Hermes, jarvis-brain boards) and adopts only after the homelab trial validates. Jason relays this PRD to the homelab agent for implementation. |
|
|
||||||
|
|
||||||
## Artifacts in this draft
|
|
||||||
|
|
||||||
| File | Content |
|
|
||||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| `north-star-additions.yaml` | Proposed NORTH_STAR.yaml merge: NS-10…NS-14, workstreams J/K/W/P/Q/X + **M** (memory subsystem) + **L** (logging/telemetry), goal cards with DAG |
|
|
||||||
| `PRD-jarvis-main-agent.md` | Workstream J — the HMI main agent |
|
|
||||||
| `PRD-permission-relay.md` | Workstream P — human-in-the-loop approvals |
|
|
||||||
| `PRD-webui-fleet-control.md` | Workstream W — tmux pop-in + top-down view (realizes F6) |
|
|
||||||
| `PRD-backlog-providers.md` | Workstream Q — provider sync adapters |
|
|
||||||
| `PRD-hermes-decommission.md` | Workstream X — parity checklist, tenant-1 migration, cutover |
|
|
||||||
| `DEBATE-FINDINGS.md` | 2026-07-09 debate pass — all 24 findings dispositioned, open-disagreement judgment calls, process rules |
|
|
||||||
|
|
||||||
Workstreams **M** (M1 enhanced memory subsystem) and **L** (L1 restore MALS · L2 Mosaic-native log ingestion · L3 anonymous agentic telemetry, NS-14) carry no standalone PRD doc: M1's scope is defined by its consumers (X-R7 retrieval eval, J memory rules) and L's by the MALS lineage (`~/src/mals`, infrastructure #135); both live as goal cards in the YAML.
|
|
||||||
|
|
||||||
Workstream K (Matrix connector + mautrix bridges) intentionally has no new PRD doc: it extends the existing `docs/fleet/f4-matrix-connector.md`; its deltas are captured as K-goals in the YAML additions and referenced from the J/P/X PRDs.
|
|
||||||
|
|
||||||
## Relationship to existing upstream work
|
|
||||||
|
|
||||||
- Fleet CLI, persona library, system-type profiles (H1–H4), supervisor/dispatch (B), native backlog (A): **already exist or in flight upstream — not re-specified here.**
|
|
||||||
- `f4-matrix-connector.md`: K1 = its Phase 2 implementation (verified present on `origin/main`; Phase 1 already ships the **tmux-default connector** that serves the P1 CLI channel per J-R14a). K2 (mautrix bridges) is additive infra.
|
|
||||||
- F6 (webUI hooks) in `PRD-fleet-suite.md`: realized by workstream W (verified present on `origin/main`).
|
|
||||||
- `docs/3-architecture/guard-rails-capability-permissions.md`: original design snapshot for workstream P — **not present on `origin/main`** (lives only in the stale `/src/mosaic-stack` clone). Its essential model is therefore folded into `PRD-permission-relay.md`, which is now the **self-contained authoritative spec** for P; the old path is cited as historical origin only.
|
|
||||||
|
|
||||||
## Gate Zero — pre-ratification checklist (debate P0 #3/#4, §3.7)
|
|
||||||
|
|
||||||
Ratification does not proceed on presumption. Before D-level sign-off, run and record:
|
|
||||||
|
|
||||||
1. **Upstream-artifact audit.** Every artifact this PRD load-bears on is verified `present @ <SHA>` on `origin/main` or marked **MISSING → goal card**. Presumed-missing rows already carded by this draft: **M1** (enhanced memory subsystem — nothing on main provides vector DB + memory service today), **J6** (event/wake router), **K3** (Matrix push pipeline + homeserver ops). If an audit finds one of these actually exists, retire the card and pin the SHA; if it finds _another_ gap, card it — no silent presumption in either direction.
|
|
||||||
2. **Hermes traffic audit** (§3.7): per-platform bridge traffic + per-MCP-tool call counts over a trailing window. Platforms with live traffic become the must-have K2 subset gating X3; zero-traffic platforms become post-trial nice-to-haves. Feeds the X-R1 parity checklist and its low-n drill list.
|
|
||||||
3. **Sandbox dispatch-test** of the DAG: load `north-star-additions.yaml` into a sandbox backlog and verify the dispatcher's actual claim order respects every `depends_on` edge (phases are documentation; edges are law).
|
|
||||||
|
|
||||||
## Process rules adopted from the debate pass (normative for this PRD's execution)
|
|
||||||
|
|
||||||
- **Conflict register:** a discovered contradiction between spec documents **blocks the affected requirement** (not the whole mission) until a register entry records the resolution. "Alert, don't auto-resolve" is promoted from data conflicts to spec conflicts. A CI lint greps for register references in amended docs.
|
|
||||||
- **DoD line on every goal card:** each card states its definition-of-done additions — runbook updated, health-floor alert registered, AGENTS.md touched — so operational debt can't silently accrue card-by-card.
|
|
||||||
- **Silent-roster rule:** in any panel/review round, a member's silence records their open findings as **open items, never consensus** (instance: Codex's unanswered principal-resolution and policy-evaluation-time findings were folded as P-R16/P-R17, explicitly not consensus-resolved).
|
|
||||||
- **Immutable spec + typed amendments** (J-R20) applies to these PRD docs themselves post-ratification: changes arrive as amendments with a revision counter, and sign-offs pin the revision.
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
# Proposed additions to docs/fleet/NORTH_STAR.yaml — DRAFT (Jason ratification pending, 2026-07-09)
|
|
||||||
#
|
|
||||||
# Merge these entries into the existing NORTH_STAR.yaml sections, then regenerate
|
|
||||||
# NORTH_STAR.md via renderNorthStarMarkdown (packages/mosaic/src/commands/fleet.ts).
|
|
||||||
# Ids chosen to avoid collision with existing workstreams A–H and goals.
|
|
||||||
|
|
||||||
standing_objectives:
|
|
||||||
- id: NS-10
|
|
||||||
text: >-
|
|
||||||
Every Mosaic workspace runs exactly one always-on HMI main agent (default
|
|
||||||
alias "Jarvis", unit mosaic-agent@main) that owns all human conversation
|
|
||||||
and user-level personal-assistant work (ideas, schedule, email, tasks,
|
|
||||||
knowledge) and delegates engineering/research/ops missions to the
|
|
||||||
orchestrator as Mosaic Backlog cards. It is a Level-0 orchestrator:
|
|
||||||
it accomplishes work through delegation and subagents, never by executing
|
|
||||||
coding/infra tasks itself, and it runs on model capacity separate from the
|
|
||||||
orchestrator so its conversational latency is isolated from fleet load. The
|
|
||||||
main agent never executes fleet work itself and never interrupts the
|
|
||||||
orchestrator for status. Exactly-one-per-workspace is enforced by a
|
|
||||||
workspace lease (J-R16), not by convention.
|
|
||||||
- id: NS-11
|
|
||||||
text: >-
|
|
||||||
Irreversible or externally-visible agent actions pass a human-in-the-loop
|
|
||||||
permission relay (approve/deny from chat or webUI) governed by
|
|
||||||
per-capability guard rails; prepare freely, execute with approval.
|
|
||||||
- id: NS-12
|
|
||||||
text: >-
|
|
||||||
The Mosaic Backlog remains the sole backlog of record; external providers
|
|
||||||
(Gitea, GitHub, local kanban, …) attach as bidirectional sync adapters,
|
|
||||||
never as the record.
|
|
||||||
- id: NS-13
|
|
||||||
text: >-
|
|
||||||
Hermes is fully decommissioned once Mosaic reaches verified parity on
|
|
||||||
transport (Matrix connector), task board (native backlog + webUI),
|
|
||||||
permission relay, and multi-platform reach (mautrix bridges).
|
|
||||||
- id: NS-14
|
|
||||||
text: >-
|
|
||||||
Mosaic Stack accepts inbound structured error/log reporting from every
|
|
||||||
agent and install (MALS-lineage logging service); agentic-efficiency
|
|
||||||
telemetry is optional, opt-in, and anonymous by construction — no IP or
|
|
||||||
PII is captured or derivable from the ingestion path.
|
|
||||||
|
|
||||||
success_criteria:
|
|
||||||
- id: AC-NS-8
|
|
||||||
text: >-
|
|
||||||
A user converses with the main agent in its channel while the
|
|
||||||
orchestrator is under full load; because the main agent runs on separate
|
|
||||||
model capacity (distinct credential/quota pools pinned in the J1
|
|
||||||
profile), its response latency is unaffected and the orchestrator
|
|
||||||
receives zero conversational traffic. Measured, not vibes: scripted
|
|
||||||
suite, >=30 interleaved turns, TTFT p95 <= 1.2x idle baseline with
|
|
||||||
bootstrap CI.
|
|
||||||
- id: AC-NS-9
|
|
||||||
text: >-
|
|
||||||
A mission agreed in the main-agent conversation appears as a backlog card
|
|
||||||
set with acceptance criteria, is drained by the orchestrator without
|
|
||||||
chat-level handoff, and its completion is reported back to the user by the
|
|
||||||
main agent from board/heartbeat state alone.
|
|
||||||
- id: AC-NS-10
|
|
||||||
text: >-
|
|
||||||
An action listed as requires_approval executes only after an explicit
|
|
||||||
human approve from Matrix or webUI; deny and timeout paths leave the
|
|
||||||
system unchanged and audited.
|
|
||||||
- id: AC-NS-11
|
|
||||||
text: >-
|
|
||||||
With Hermes stopped, no fleet or main-agent capability regresses
|
|
||||||
(transport, board, approvals, multi-platform reach all served by Mosaic).
|
|
||||||
|
|
||||||
workstreams:
|
|
||||||
- id: J
|
|
||||||
title: HMI main agent ("Jarvis") — persona, PA toolchain, delegation contract
|
|
||||||
- id: K
|
|
||||||
title: Connectors & multi-platform reach — F4 Matrix implementation + mautrix bridges
|
|
||||||
- id: W
|
|
||||||
title: webUI fleet control — tmux pop-in, top-down view (realizes F6)
|
|
||||||
- id: P
|
|
||||||
title: Permission relay — capability guard rails + human approval queue
|
|
||||||
- id: Q
|
|
||||||
title: Backlog provider sync adapters — Gitea/GitHub/local kanban
|
|
||||||
- id: X
|
|
||||||
title: Hermes decommission & tenant-1 migration
|
|
||||||
- id: M
|
|
||||||
title: Enhanced memory subsystem — vector DB + memory service + provenance/tombstones (Gate Zero MISSING artifact)
|
|
||||||
- id: L
|
|
||||||
title: Logging & telemetry — MALS-lineage inbound error reporting + optional anonymous efficiency telemetry
|
|
||||||
|
|
||||||
goals:
|
|
||||||
# J — HMI main agent
|
|
||||||
- id: J1
|
|
||||||
title: Main-agent persona + profile — instantiate personal-assistant system type as mosaic-agent@main (alias Jarvis), model tier a profile field (Opus default)
|
|
||||||
phase: 1
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [H2]
|
|
||||||
# J2 split (debate P0 #2): phase-1 Jarvis must not hold an external-write
|
|
||||||
# credential path before the permission relay (P2) exists — NS-11 by DAG.
|
|
||||||
- id: J2a
|
|
||||||
title: PA toolchain (workspace-internal) — tasks, events, knowledge, ideas executed directly against the product API in the user's workspace; no external credentials provisioned
|
|
||||||
phase: 1
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [J1]
|
|
||||||
- id: J2b
|
|
||||||
title: PA toolchain (external integrations) — email, external calendars, helpdesk via workspace-scoped integrations; credentials gateway-held; requires_approval routes through the relay
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [J2a, P2]
|
|
||||||
- id: J3
|
|
||||||
title: Delegation contract — main agent authors mission cards (goal, acceptance criteria, budget advisory) onto the backlog; orchestrator drains; no chat-level handoff
|
|
||||||
phase: 1
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [J1, A3a]
|
|
||||||
- id: J4
|
|
||||||
title: Passive fleet observability — main agent answers status from heartbeats, fleet ps JSON, and board state; zero orchestrator interrupts
|
|
||||||
phase: 1
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [J1, B1]
|
|
||||||
- id: J5
|
|
||||||
title: Main-agent Matrix room via OrchestratorConnector(matrix)
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [J1, K1]
|
|
||||||
- id: J6
|
|
||||||
title: Shared event/wake router — level-triggered reconciler over durable state (heartbeats, cards, approvals) with hysteretic per-condition suppression; templated provenance-tagged wake turns; per-agent polling forbidden (Gate Zero MISSING artifact; J-R13 depends on it)
|
|
||||||
phase: 1
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [J1]
|
|
||||||
|
|
||||||
# K — connectors & reach (extends f4-matrix-connector.md)
|
|
||||||
- id: K1
|
|
||||||
title: Matrix connector implementation — CS-API client factory per f4 Phase 2, self-hosted homeserver
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: []
|
|
||||||
# K2 scope (debate §3.7): a pre-ratification Hermes traffic audit narrows the
|
|
||||||
# must-have bridge subset to platforms with live traffic; only that subset
|
|
||||||
# gates X3 — remaining bridges are genuinely optional post-X3.
|
|
||||||
- id: K2
|
|
||||||
title: mautrix bridge deployment (telegram/signal/whatsapp/slack/discord) as GitOps-managed infra; agents speak only Matrix; must-have subset = platforms carrying live Hermes traffic per Gate Zero audit
|
|
||||||
phase: 3
|
|
||||||
priority: should-have
|
|
||||||
depends_on: [K1]
|
|
||||||
- id: K3
|
|
||||||
title: Push pipeline (Sygnal or equivalent) + homeserver ops — monitored delivery checks, health-floor registration, rehearsed backup/restore (Gate Zero MISSING artifact; P2 phone-approval AC depends on it)
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [K1]
|
|
||||||
|
|
||||||
# W — webUI fleet control (realizes F6)
|
|
||||||
- id: W1
|
|
||||||
title: Gateway pty/tmux attach service — read-only watch and interactive butt-in verbs, workspace-scoped authz, audit log
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: []
|
|
||||||
- id: W2
|
|
||||||
title: xterm.js session view in apps/web wired to W1 (watch + butt-in)
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [W1]
|
|
||||||
- id: W3
|
|
||||||
title: Top-down fleet dashboard — roster, heartbeats, cards in flight, advisory spend, PAUSE control
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [B1]
|
|
||||||
|
|
||||||
# P — permission relay
|
|
||||||
- id: P1
|
|
||||||
title: Capability guard-rails engine — resource:action grants, permission levels, requires_approval list per integration
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: []
|
|
||||||
- id: P2
|
|
||||||
title: Approval queue + approve/deny from the Matrix room (timeout = deny; full audit)
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [P1, K1]
|
|
||||||
- id: P3
|
|
||||||
title: Approval surface in webUI (pending queue, one-click approve/deny)
|
|
||||||
phase: 3
|
|
||||||
priority: should-have
|
|
||||||
depends_on: [P1, W3]
|
|
||||||
|
|
||||||
# Q — backlog provider sync adapters
|
|
||||||
- id: Q1
|
|
||||||
title: Provider adapter interface + Gitea adapter (bidirectional card↔issue sync; native backlog stays record)
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [A2]
|
|
||||||
- id: Q2
|
|
||||||
title: GitHub adapter
|
|
||||||
phase: 3
|
|
||||||
priority: should-have
|
|
||||||
depends_on: [Q1]
|
|
||||||
- id: Q3
|
|
||||||
title: Local kanban surface — webUI board view over the native backlog (no external provider required)
|
|
||||||
phase: 2
|
|
||||||
priority: should-have
|
|
||||||
depends_on: [A2, W3]
|
|
||||||
|
|
||||||
# X — Hermes decommission & tenant-1 migration
|
|
||||||
- id: X1
|
|
||||||
title: Hermes parity checklist + cutover plan (transport, board, approvals, reach) with rollback
|
|
||||||
phase: 3
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [K1, P2, Q1]
|
|
||||||
# jarvis-brain is the P0 (prototype) Mosaic Stack; migration targets the proper
|
|
||||||
# stack storage layer. Storage authority (debate P0 #1): Postgres is
|
|
||||||
# authoritative for ALL product entities; the flat-file backend is a derived,
|
|
||||||
# regenerated, READ-ONLY projection — never a second writable store.
|
|
||||||
- id: X2
|
|
||||||
title: 'Tenant-1 migration — P0 (jarvis-brain) into the proper Mosaic Stack: (a) PA data (projects/tasks/events/knowledge) into the Jason workspace, (b) agent memory/runbooks into the enhanced memory subsystem M1 (kept live, not frozen); jarvis-brain retires read-only only after both are verified'
|
|
||||||
phase: 3
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [J2a, P2, M1]
|
|
||||||
- id: X3
|
|
||||||
title: Hermes decommission — stop and remove Hermes services after AC-NS-11 verified; pre-stop evidence snapshot (logs/config/callback inventory + checksum) is a machine gate
|
|
||||||
phase: 4
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [X1, X2, K2]
|
|
||||||
|
|
||||||
# M — enhanced memory subsystem (Gate Zero: cited by X2(b) but not built by
|
|
||||||
# any prior workstream — this card closes that gap)
|
|
||||||
- id: M1
|
|
||||||
title: Enhanced memory subsystem — vector DB + memory service with mandatory provenance (human/agent/external), source-grounded retrieval preference, tombstones + re-embedding on source update, normative/parametric corpus split
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: []
|
|
||||||
|
|
||||||
# L — logging & telemetry (Jason 2026-07-09; NS-14). MALS (~/src/mals) is the
|
|
||||||
# P0 of this capability; restoring it is infra work tracked as
|
|
||||||
# infrastructure#135 (k3s migration landed without an Ingress).
|
|
||||||
- id: L1
|
|
||||||
title: MALS restored & exposed — k3s Ingress for mals.mosaicstack.dev, health verified, authenticated write smoke-tested (infrastructure#135); trial day-1 metric emission targets MALS until W3 panels exist
|
|
||||||
phase: 1
|
|
||||||
priority: must-have
|
|
||||||
depends_on: []
|
|
||||||
- id: L2
|
|
||||||
title: Mosaic-native log ingestion — gateway/API endpoint for structured inbound error reporting from agents and installs (MALS-compatible schema; levels, categories, trace ids), workspace-scoped keys
|
|
||||||
phase: 2
|
|
||||||
priority: must-have
|
|
||||||
depends_on: [L1]
|
|
||||||
- id: L3
|
|
||||||
title: Anonymous agentic-efficiency telemetry — opt-in, aggregate-only, no IP/PII captured or derivable at ingestion (NS-14); feeds W3 trend panels
|
|
||||||
phase: 3
|
|
||||||
priority: should-have
|
|
||||||
depends_on: [L2, W3]
|
|
||||||
|
|
||||||
assumptions:
|
|
||||||
- id: ASM-5
|
|
||||||
vetoable: true
|
|
||||||
text: >-
|
|
||||||
The main agent initially runs on the homelab fleet host alongside the
|
|
||||||
orchestrator under mosaic-agent@main.service; host placement is a config
|
|
||||||
field, not a code assumption. Host co-location does NOT imply shared
|
|
||||||
inference: Jarvis (Opus) and Mos (Fable) hold separate model capacity/quota
|
|
||||||
so orchestrator load cannot degrade conversational latency (AC-NS-8).
|
|
||||||
- id: ASM-6
|
|
||||||
vetoable: true
|
|
||||||
text: >-
|
|
||||||
Product multi-tenancy at MVP means self-hosted installs with multiple
|
|
||||||
workspaces per install (Authentik OIDC); per-tenant isolated FLEETS
|
|
||||||
(agents per workspace) are post-MVP.
|
|
||||||
- id: ASM-7
|
|
||||||
vetoable: true
|
|
||||||
text: >-
|
|
||||||
mautrix bridges are deployed as infrastructure (GitOps), not as Mosaic
|
|
||||||
application code; Mosaic's only conversational protocol is Matrix.
|
|
||||||
- id: ASM-8
|
|
||||||
vetoable: true
|
|
||||||
text: >-
|
|
||||||
During migration (before X3), Hermes remains running untouched; no
|
|
||||||
Hermes-dependent capability is removed until its Mosaic replacement is
|
|
||||||
verified in production.
|
|
||||||
- id: ASM-9
|
|
||||||
vetoable: true
|
|
||||||
text: >-
|
|
||||||
The trial environment is the homelab fleet deployment (D12). Environments
|
|
||||||
running the primitive-era implementation (USC/web1: mos-claude.service,
|
|
||||||
Hermes, jarvis-brain boards) are untouched during the trial; workstream X
|
|
||||||
executes there as a post-trial adoption phase, environment by
|
|
||||||
environment.
|
|
||||||
@@ -7,7 +7,6 @@
|
|||||||
3. [Provider Configuration](#provider-configuration)
|
3. [Provider Configuration](#provider-configuration)
|
||||||
4. [MCP Server Configuration](#mcp-server-configuration)
|
4. [MCP Server Configuration](#mcp-server-configuration)
|
||||||
5. [Environment Variables Reference](#environment-variables-reference)
|
5. [Environment Variables Reference](#environment-variables-reference)
|
||||||
6. [Local Fleet Canary](./fleet-local-canary.md)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
5. [Adding New MCP Tools](#adding-new-mcp-tools)
|
5. [Adding New MCP Tools](#adding-new-mcp-tools)
|
||||||
6. [Database Schema and Migrations](#database-schema-and-migrations)
|
6. [Database Schema and Migrations](#database-schema-and-migrations)
|
||||||
7. [API Endpoint Reference](#api-endpoint-reference)
|
7. [API Endpoint Reference](#api-endpoint-reference)
|
||||||
8. [Local Fleet Canary](./fleet-local-canary.md)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
# Local Fleet Canary
|
|
||||||
|
|
||||||
The local fleet canary runs a small tmux-backed Mosaic agent fleet on an
|
|
||||||
isolated tmux socket. The default socket is `mosaic-fleet`; the commands do
|
|
||||||
not use or stop the default tmux server.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
Product-owned defaults:
|
|
||||||
|
|
||||||
- `packages/mosaic/framework/fleet/roster.schema.json`
|
|
||||||
- `packages/mosaic/framework/fleet/examples/minimal.yaml`
|
|
||||||
- `packages/mosaic/framework/fleet/examples/local-canary.yaml`
|
|
||||||
- `packages/mosaic/framework/systemd/user/mosaic-tmux-holder.service`
|
|
||||||
- `packages/mosaic/framework/systemd/user/mosaic-agent@.service`
|
|
||||||
- `packages/mosaic/framework/tools/fleet/start-agent-session.sh`
|
|
||||||
- `packages/mosaic/framework/tools/tmux/agent-send.sh`
|
|
||||||
- `packages/mosaic/framework/tools/tmux/send-message.sh`
|
|
||||||
|
|
||||||
These files are published through `packages/mosaic/package.json`, whose `files`
|
|
||||||
allowlist includes `framework` along with `dist`.
|
|
||||||
|
|
||||||
Site-owned local roster:
|
|
||||||
|
|
||||||
```text
|
|
||||||
~/.config/mosaic/fleet/roster.yaml
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not put a host-specific full roster into product defaults. Start from an
|
|
||||||
example and edit the local roster after `mosaic fleet init --write`.
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
Minimal canary:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mosaic fleet init --profile minimal --write
|
|
||||||
# If a site-owned roster already exists, inspect it first; overwrite only explicitly:
|
|
||||||
# mosaic fleet init --profile minimal --write --force
|
|
||||||
mosaic fleet install-systemd
|
|
||||||
systemctl --user daemon-reload
|
|
||||||
mosaic fleet start
|
|
||||||
mosaic fleet verify
|
|
||||||
```
|
|
||||||
|
|
||||||
Small dogfood roster:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mosaic fleet init --profile local-canary --write
|
|
||||||
# Use --force only after preserving any site-owned roster changes.
|
|
||||||
mosaic fleet install-systemd
|
|
||||||
systemctl --user daemon-reload
|
|
||||||
mosaic fleet start
|
|
||||||
mosaic fleet status
|
|
||||||
```
|
|
||||||
|
|
||||||
## Agent Operations
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mosaic agent roster
|
|
||||||
mosaic agent status
|
|
||||||
mosaic agent status canary-pi
|
|
||||||
mosaic agent send canary-pi --message "status check"
|
|
||||||
mosaic agent reset canary-pi --new
|
|
||||||
mosaic agent tail canary-pi -n 80
|
|
||||||
```
|
|
||||||
|
|
||||||
These commands read the roster and target the configured tmux socket. The
|
|
||||||
generated systemd agent services use `start-agent-session.sh`; message delivery
|
|
||||||
uses the tmux send tools with `-L mosaic-fleet`.
|
|
||||||
|
|
||||||
`mosaic agent send` is operator-origin traffic unless a caller explicitly says
|
|
||||||
otherwise. The CLI always passes a deterministic source label to
|
|
||||||
`agent-send.sh` with `-S`, defaulting to `<hostname>:operator`, so it does not
|
|
||||||
query the target tmux socket and accidentally identify as an active agent pane.
|
|
||||||
Use `--source-label <label>` or `--source <label>` only when deliberately
|
|
||||||
impersonating a known handoff lane. The lower-level inter-agent wrapper
|
|
||||||
`agent-send.sh -S <label>` remains the explicit source override for scripts.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
Use these checks before expanding the roster:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tmux -L mosaic-fleet ls
|
|
||||||
tmux ls
|
|
||||||
mosaic fleet verify
|
|
||||||
systemctl --user status mosaic-tmux-holder.service
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected results:
|
|
||||||
|
|
||||||
- `tmux -L mosaic-fleet ls` shows `_holder` and roster agent sessions.
|
|
||||||
- `tmux ls` shows only the default tmux server sessions and is not changed by
|
|
||||||
fleet start/stop operations.
|
|
||||||
- `mosaic fleet verify` checks exact session targets on the isolated socket.
|
|
||||||
- `systemctl --user status ...` may show `active (exited)` for oneshot units;
|
|
||||||
that means the unit ran, not that an agent pane is live. Treat tmux
|
|
||||||
`has-session`, `list-panes`, process tree, and logs as the liveness evidence.
|
|
||||||
|
|
||||||
## Release Preflight
|
|
||||||
|
|
||||||
Run this checklist before cutting or dogfooding a fleet release:
|
|
||||||
|
|
||||||
- Real AI dogfood: send at least one task through `mosaic agent send`, then
|
|
||||||
confirm the agent accepted/responded using pane, process, or log evidence.
|
|
||||||
- Restart/stop/idempotency: run `mosaic fleet start`, `restart`, `stop`, and a
|
|
||||||
repeated `start` against the named socket; verify the default tmux server is
|
|
||||||
unchanged.
|
|
||||||
- Liveness verification: run `mosaic fleet verify` and confirm roster sessions
|
|
||||||
with `tmux -L mosaic-fleet ls` or exact `has-session` checks.
|
|
||||||
- Package dry-run: run `npm pack --dry-run --json` from `packages/mosaic` and
|
|
||||||
confirm `framework/fleet`, `framework/systemd/user`,
|
|
||||||
`framework/tools/fleet`, and `framework/tools/tmux` assets are included.
|
|
||||||
- Mosaic update test: install or upgrade from the packed artifact in a temporary
|
|
||||||
Mosaic home and confirm `mosaic update` or the release upgrade path does not
|
|
||||||
remove local roster/config files.
|
|
||||||
|
|
||||||
## Rollback
|
|
||||||
|
|
||||||
Stop the local canary:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mosaic fleet stop
|
|
||||||
systemctl --user disable mosaic-agent@canary-pi.service
|
|
||||||
systemctl --user disable mosaic-tmux-holder.service
|
|
||||||
systemctl --user daemon-reload
|
|
||||||
```
|
|
||||||
|
|
||||||
For a full local cleanup of generated canary files:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rm -f ~/.config/systemd/user/mosaic-agent@.service
|
|
||||||
rm -f ~/.config/systemd/user/mosaic-tmux-holder.service
|
|
||||||
rm -rf ~/.config/mosaic/fleet
|
|
||||||
rm -rf ~/.config/mosaic/tools/fleet
|
|
||||||
```
|
|
||||||
|
|
||||||
This rollback leaves the default tmux server untouched. If a canary session is
|
|
||||||
still present after service stop, remove only the isolated socket server:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tmux -L mosaic-fleet kill-server
|
|
||||||
```
|
|
||||||
@@ -10,7 +10,6 @@
|
|||||||
6. [CLI Usage](#cli-usage)
|
6. [CLI Usage](#cli-usage)
|
||||||
7. [Sub-package Commands](#sub-package-commands)
|
7. [Sub-package Commands](#sub-package-commands)
|
||||||
8. [Telemetry](#telemetry)
|
8. [Telemetry](#telemetry)
|
||||||
9. [Local Fleet Canary](./fleet-local-canary.md)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
# Mission Control Plane — Feature Board
|
|
||||||
|
|
||||||
> Discussion board for the combined PRD / mission / Kanban workflow.
|
|
||||||
> Use this to decide scope before implementation.
|
|
||||||
|
|
||||||
## Board Legend
|
|
||||||
|
|
||||||
- **Must-have** — required for the first usable version
|
|
||||||
- **Should-have** — strongly preferred, but can ship after the core path
|
|
||||||
- **Could-have** — valuable later if time permits
|
|
||||||
- **Won't-have** — explicitly deferred
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Feature Board
|
|
||||||
|
|
||||||
| Feature Card | Need | Priority | Decision / Notes |
|
|
||||||
| ------------------------------ | ------------------------------------------------------------- | ----------- | --------------------------------------------------------------------------- |
|
|
||||||
| Canonical mission manifest | One durable root object for goal, PRD, board, session | Must-have | Mission manifest becomes the anchor for all downstream state |
|
|
||||||
| PRD generator integration | PRD should be generated from a feature idea and saved in docs | Must-have | Use Mosaic PRDy format and keep the file human-reviewable |
|
|
||||||
| Board atomization | Break PRD into assignable tasks with dependencies | Must-have | Each user story should map to one or more tasks |
|
|
||||||
| Short-cycle detector | Detect compaction churn and repeated tool loops | Must-have | Coordinator should track churn score per session |
|
|
||||||
| Handoff packet | Preserve actionable context across rotations | Must-have | Use a compact structured summary, not a raw transcript |
|
|
||||||
| Auto-resume workers | Let new sessions read mission + board on start | Should-have | Makes overnight autonomy realistic |
|
|
||||||
| Mission status view | Show current phase, blockers, and active session | Should-have | Expose through CLI first, dashboard later |
|
|
||||||
| Worktree root convention | Keep worktrees off `/tmp` and on the larger persistent drive | Should-have | Prefer `/src/<repo>-worktrees` for repo worktrees and long-lived agent work |
|
|
||||||
| Review gate | Prevent autonomous work from shipping unreviewed | Should-have | Use reviewer tasks before mission close |
|
|
||||||
| Rotation policy config | Configure thresholds per mission/profile | Could-have | Keep v1 simple, add tuning later |
|
|
||||||
| Goal decomposition suggestions | Suggest sub-goals from the PRD | Could-have | Good for planning, not necessary for core path |
|
|
||||||
| Cross-channel continuity | Continue a mission across CLI/gateway/remote channels | Could-have | Important later, not required for MVP |
|
|
||||||
| Automatic board sync | Mirror git docs into DB and back | Could-have | Nice-to-have after the file-first flow stabilizes |
|
|
||||||
| Fully autonomous closeout | Let mission finish without human intervention | Won't-have | Keep an operator-visible review step |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Needs Discussion
|
|
||||||
|
|
||||||
### 1) Canonical source of truth
|
|
||||||
|
|
||||||
**Question:** Should the PRD, mission manifest, and board all live in git, or should one be the database source of truth?
|
|
||||||
|
|
||||||
**Proposed answer:** Keep the human-readable artifacts in git and sync the mission runtime state to the database.
|
|
||||||
|
|
||||||
### 2) Scope of automation
|
|
||||||
|
|
||||||
**Question:** Should the first version auto-create the board from the PRD, or require a human/orchestrator to approve the split?
|
|
||||||
|
|
||||||
**Proposed answer:** Auto-create a draft board, then let the orchestrator approve or adjust it.
|
|
||||||
|
|
||||||
### 3) Rotation triggers
|
|
||||||
|
|
||||||
**Question:** What should trigger a forced session rotation?
|
|
||||||
|
|
||||||
**Candidate signals:**
|
|
||||||
|
|
||||||
- repeated compaction
|
|
||||||
- repeated prompts for permission
|
|
||||||
- identical tool loops
|
|
||||||
- no new file/task state after several turns
|
|
||||||
- task blocked on a missing prerequisite
|
|
||||||
|
|
||||||
**Proposed answer:** Use a weighted churn score with a small hard cap on repeated compactions.
|
|
||||||
|
|
||||||
### 4) Handoff format
|
|
||||||
|
|
||||||
**Question:** What should the next session receive?
|
|
||||||
|
|
||||||
**Proposed answer:**
|
|
||||||
|
|
||||||
- Mission ID
|
|
||||||
- PRD path
|
|
||||||
- Active board task
|
|
||||||
- Completed work
|
|
||||||
- Blockers
|
|
||||||
- Next 3 actions
|
|
||||||
- Non-negotiable constraints
|
|
||||||
|
|
||||||
### 5) Operator control
|
|
||||||
|
|
||||||
**Question:** Should the operator be able to force a rotation or pause the mission?
|
|
||||||
|
|
||||||
**Proposed answer:** Yes. Human override should win.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Draft Decisions
|
|
||||||
|
|
||||||
1. File-first artifacts, DB-backed runtime state.
|
|
||||||
2. PRD-first planning, board-second execution.
|
|
||||||
3. Auto-rotation on churn, but human override remains available.
|
|
||||||
4. Structured handoff packets required on every rotation.
|
|
||||||
5. Mission close requires a reviewer task.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
- What exact data fields belong in the mission manifest?
|
|
||||||
- Should rotation thresholds vary by agent profile?
|
|
||||||
- What is the minimum viable status surface for v1?
|
|
||||||
- Should the board support milestones in addition to tasks?
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
# Mission Manifest — Mosaic Mission Control Plane
|
|
||||||
|
|
||||||
> Persistent document tracking scope, status, and handoff history for the combined PRD / mission / Kanban workflow.
|
|
||||||
|
|
||||||
## Mission
|
|
||||||
|
|
||||||
**ID:** mission-control-plane-20260506
|
|
||||||
|
|
||||||
**Statement:** Combine Mosaic PRDy, coord, and Kanban into one durable workflow so an agent can move from feature idea to PRD to mission to task board and keep working across session rotation, compaction, and restarts with minimal context loss.
|
|
||||||
|
|
||||||
**Phase:** planning — MC-01 complete, MC-02 next
|
|
||||||
|
|
||||||
**Current Milestone:** MC-02
|
|
||||||
|
|
||||||
**Progress:** 1 / 6 milestones
|
|
||||||
|
|
||||||
**Status:** active
|
|
||||||
|
|
||||||
**Last Updated:** 2026-05-06
|
|
||||||
|
|
||||||
**Parent Mission:** None — new mission
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
This mission exists because overnight autonomy breaks when the working session short-cycles. The system needs durable artifacts and a mechanical coordinator that can:
|
|
||||||
|
|
||||||
1. keep a canonical PRD,
|
|
||||||
2. atomize the PRD into board tasks,
|
|
||||||
3. track mission state separately from the chat session,
|
|
||||||
4. detect churn or compaction pressure,
|
|
||||||
5. rotate to a fresh session, and
|
|
||||||
6. re-enter from a structured handoff.
|
|
||||||
|
|
||||||
Operational convention: repo worktrees and long-lived working directories should use `/src/<repo>-worktrees` instead of `/tmp`.
|
|
||||||
|
|
||||||
Design references:
|
|
||||||
|
|
||||||
- `docs/mission-control/PRD.md` — product requirements
|
|
||||||
- `docs/mission-control/BOARD.md` — feature discussion board
|
|
||||||
- `docs/mission-control/TASKS.md` — atomized execution plan
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
- [ ] AC-1: A feature idea can be converted into a PRD, mission, and task board.
|
|
||||||
- [ ] AC-2: The coordinator can load a mission and its board from durable storage.
|
|
||||||
- [ ] AC-3: The coordinator can detect short-cycling and rotate sessions automatically.
|
|
||||||
- [ ] AC-4: A rotated session can resume from a handoff packet without manual re-prompting.
|
|
||||||
- [ ] AC-5: The board remains traceable back to the PRD user stories.
|
|
||||||
- [ ] AC-6: Operators can inspect mission state, task state, and latest handoff from one place.
|
|
||||||
- [ ] AC-7: The system can run overnight without losing the mission goal.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Milestones
|
|
||||||
|
|
||||||
| # | ID | Name | Status | Branch | Started | Completed |
|
|
||||||
| --- | ----- | ---------------------------------------- | ----------- | ----------------------- | ---------- | --------- |
|
|
||||||
| 1 | MC-01 | PRD + mission schema foundation | in-progress | docs/mission-control-\* | 2026-05-06 | — |
|
|
||||||
| 2 | MC-02 | Mission runtime model | not-started | — | — | — |
|
|
||||||
| 3 | MC-03 | Board atomization and task linkage | not-started | — | — | — |
|
|
||||||
| 4 | MC-04 | Short-cycle detector and rotation engine | not-started | — | — | — |
|
|
||||||
| 5 | MC-05 | Handoff generation and re-entry | not-started | — | — | — |
|
|
||||||
| 6 | MC-06 | Operator surface and E2E validation | not-started | — | — | — |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Budget
|
|
||||||
|
|
||||||
| Milestone | Est. tokens | Parallelizable? |
|
|
||||||
| --------- | ----------- | ------------------ |
|
|
||||||
| MC-01 | 16K | No |
|
|
||||||
| MC-02 | 20K | No |
|
|
||||||
| MC-03 | 24K | Mostly after MC-01 |
|
|
||||||
| MC-04 | 20K | After MC-02 |
|
|
||||||
| MC-05 | 18K | After MC-04 |
|
|
||||||
| MC-06 | 26K | After MC-04/05 |
|
|
||||||
| **Total** | **~124K** | |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Session History
|
|
||||||
|
|
||||||
| Session | Date | Runtime | Outcome |
|
|
||||||
| ------- | ---------- | ------- | ------------------------------------------------------------------------ |
|
|
||||||
| S1 | 2026-05-06 | hermes | PRD, board, task plan, mission manifest, and worktree convention drafted |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Next Step
|
|
||||||
|
|
||||||
Kick off MC-02: implement the durable mission runtime model and wire the mission state into the coordinator.
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
# PRD: Mosaic Mission Control Plane
|
|
||||||
|
|
||||||
## Metadata
|
|
||||||
|
|
||||||
- **Owner:** Jason Woltje
|
|
||||||
- **Date:** 2026-05-06
|
|
||||||
- **Status:** draft
|
|
||||||
- **Framework:** Mosaic PRDy + coord + Kanban
|
|
||||||
- **Target Repo:** `git.mosaicstack.dev/mosaic/mosaic-stack`
|
|
||||||
- **Primary Modules:** `packages/prdy`, `packages/coord`, `packages/queue`, `apps/gateway`, `packages/brain`, `packages/cli`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Problem Statement
|
|
||||||
|
|
||||||
Mosaic already has the ingredients for durable agent work: PRD generation (`prdy`), mission coordination (`coord`), and task execution boards (`Kanban` / `TASKS.md`). Today those systems can still drift apart:
|
|
||||||
|
|
||||||
- A PRD can exist without a mission record.
|
|
||||||
- A mission can exist without a machine-readable execution board.
|
|
||||||
- Agents can short-cycle or compact repeatedly without a durable handoff.
|
|
||||||
- The next session may know the goal, but not the exact next step.
|
|
||||||
|
|
||||||
The result is brittle overnight autonomy: work continues only as long as a single session remains healthy.
|
|
||||||
|
|
||||||
This feature unifies those layers into one durable workflow so a mission can survive session rotation, compaction, and restarts with minimal state loss.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
1. Create one canonical pipeline from idea → PRD → mission → board → execution.
|
|
||||||
2. Let `prdy` generate a PRD that is immediately usable as a mission input.
|
|
||||||
3. Let `coord` own mission state, handoffs, and session rotation.
|
|
||||||
4. Let the board hold atomized tasks with dependencies and assignees.
|
|
||||||
5. Let agents read the mission and board to learn the next action without extra prompting.
|
|
||||||
6. Detect short-cycling and rotate sessions before quality degrades.
|
|
||||||
7. Preserve useful context across handoffs with a structured summary packet.
|
|
||||||
8. Give operators a single place to see mission status, task state, and the current session.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
1. Replacing the Mosaic agent runtime or gateway architecture.
|
|
||||||
2. Rewriting `prdy` or `coord` from scratch.
|
|
||||||
3. Turning the board into a general project-management system.
|
|
||||||
4. Building a full Gantt/charting product.
|
|
||||||
5. Removing human review or approval gates.
|
|
||||||
6. Allowing agents to create arbitrary mission state without schema.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## User Stories
|
|
||||||
|
|
||||||
### US-001: Create a mission from a feature idea
|
|
||||||
|
|
||||||
**Description:** As an orchestrator, I want to turn a feature idea into a PRD and mission so that agents can work from a durable spec instead of a chat transcript.
|
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
|
||||||
|
|
||||||
- [ ] `prdy` can emit a PRD with goals, non-goals, and requirements.
|
|
||||||
- [ ] The PRD is linked to a mission ID.
|
|
||||||
- [ ] The mission manifest references the PRD path.
|
|
||||||
- [ ] The mission is readable by downstream agent sessions.
|
|
||||||
|
|
||||||
### US-002: Atomize work into a board
|
|
||||||
|
|
||||||
**Description:** As an orchestrator, I want to split a PRD into board tasks so that work can be assigned to specialists.
|
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
|
||||||
|
|
||||||
- [ ] Each user story can become one or more tasks.
|
|
||||||
- [ ] Tasks have assignees, dependencies, and estimates.
|
|
||||||
- [ ] Tasks are machine-readable and durable.
|
|
||||||
- [ ] The board can be regenerated from the PRD without ambiguity.
|
|
||||||
|
|
||||||
### US-003: Rotate sessions without losing the mission
|
|
||||||
|
|
||||||
**Description:** As a coordinator, I want to restart or rotate a session when it short-cycles so that the mission continues with minimal loss.
|
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
|
||||||
|
|
||||||
- [ ] The coordinator detects compaction pressure or repeated loops.
|
|
||||||
- [ ] The coordinator writes a handoff summary before rotation.
|
|
||||||
- [ ] A new session can resume from the handoff packet.
|
|
||||||
- [ ] The mission state remains intact across the rotation.
|
|
||||||
|
|
||||||
### US-004: Let workers read the next step automatically
|
|
||||||
|
|
||||||
**Description:** As a worker agent, I want to read the mission and board at startup so I can do the next useful thing without waiting for a human prompt.
|
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
|
||||||
|
|
||||||
- [ ] Startup loads the active mission manifest.
|
|
||||||
- [ ] Startup loads the current board/task row.
|
|
||||||
- [ ] Startup exposes the next action clearly in the prompt.
|
|
||||||
- [ ] The agent can continue after compaction using the same mission context.
|
|
||||||
|
|
||||||
### US-005: Observe mission health from one place
|
|
||||||
|
|
||||||
**Description:** As an operator, I want a single view of mission health so that I can see progress, blocked tasks, and session churn.
|
|
||||||
|
|
||||||
**Acceptance Criteria:**
|
|
||||||
|
|
||||||
- [ ] Mission state shows current phase and progress.
|
|
||||||
- [ ] Board state shows task status by assignee.
|
|
||||||
- [ ] Short-cycle/rotation events are visible.
|
|
||||||
- [ ] Handoffs are inspectable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Functional Requirements
|
|
||||||
|
|
||||||
FR-1. The system must represent a mission as a durable object with an ID, goal, current phase, PRD path, board path, and active session ID.
|
|
||||||
|
|
||||||
FR-2. The system must represent a PRD as a markdown document with goals, user stories, functional requirements, non-goals, technical considerations, and success metrics.
|
|
||||||
|
|
||||||
FR-3. The system must represent execution work as a board of atomized tasks with status, assignee, dependency, and estimate fields.
|
|
||||||
|
|
||||||
FR-4. The coordinator must be able to derive a task board from a PRD.
|
|
||||||
|
|
||||||
FR-5. The coordinator must be able to write a handoff packet that includes goal, current state, completed work, blocked work, next steps, and constraints.
|
|
||||||
|
|
||||||
FR-6. The coordinator must detect short-cycling signals such as repeated compactions, repeated tool loops, repeated approval prompts, or no progress across several turns.
|
|
||||||
|
|
||||||
FR-7. The coordinator must rotate the session when the short-cycle threshold is exceeded.
|
|
||||||
|
|
||||||
FR-8. The coordinator must preserve mission continuity across session rotation.
|
|
||||||
|
|
||||||
FR-9. The worker session must read the mission state and board state at startup.
|
|
||||||
|
|
||||||
FR-10. The worker session must be able to resume from the last handoff summary without the operator rewriting the goal manually.
|
|
||||||
|
|
||||||
FR-11. The operator must be able to inspect the mission state, PRD, board, and latest handoff from one place.
|
|
||||||
|
|
||||||
FR-12. The mission system must keep a traceable link between PRD requirements and board tasks.
|
|
||||||
|
|
||||||
FR-13. The system must not allow a task to become active without a valid mission context.
|
|
||||||
|
|
||||||
FR-14. The system must keep durable history for rotation and handoff events.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Board Discussion: Features and Needs
|
|
||||||
|
|
||||||
This is the feature discussion board that should drive the mission design.
|
|
||||||
|
|
||||||
| Card | Need | Why it matters | Proposed decision |
|
|
||||||
| ------------------------ | -------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------ |
|
|
||||||
| Canonical mission record | One source of truth for goal/state | Prevents drift between chat, docs, and queue | Make mission manifest the durable root object |
|
|
||||||
| PRD → board derivation | Break feature ideas into executable work | Lets the plan be assigned and tracked | Keep PRD as the spec, generate board tasks from user stories |
|
|
||||||
| Session watchdog | Detect churn/short-cycling | Keeps overnight runs productive | Add short-cycle scoring and forced rotation |
|
|
||||||
| Structured handoff | Preserve context across session changes | Minimizes restart loss | Use a compact JSON/MD handoff packet |
|
|
||||||
| Worker auto-read | Let agents resume without human re-prompting | Reduces operator overhead | Load mission + board on session start |
|
|
||||||
| Status surface | Show progress and blockers clearly | Operators need confidence | Expose mission state via CLI and dashboard |
|
|
||||||
| Review gate | Keep quality high on autonomous work | Prevents silent regressions | Require review tasks before close |
|
|
||||||
| Recoverability | Resume after failure or restart | Mission should outlive a process | Persist session and handoff history |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Design Considerations
|
|
||||||
|
|
||||||
1. The PRD should stay human-readable markdown, because the board and mission references need to be reviewable in git.
|
|
||||||
2. The board should be machine-readable enough for automation but still readable by humans.
|
|
||||||
3. The mission manifest should point to the PRD and board, not duplicate them.
|
|
||||||
4. Handoff packets should be compact and structured so they can be injected into a new session with minimal token cost.
|
|
||||||
5. The coordinator should prefer rotation over forced context growth once the session is near the compaction threshold.
|
|
||||||
6. Existing Mosaic commands should be extended, not replaced, wherever possible.
|
|
||||||
7. The same mission should be resumable across CLI, gateway, and remote channels.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Technical Considerations
|
|
||||||
|
|
||||||
- Likely storage split:
|
|
||||||
- PRD/board/manifest in git-backed docs
|
|
||||||
- mission/session state in the Mosaic data layer
|
|
||||||
- runtime health in queue/session state
|
|
||||||
- Worktrees and long-lived agent working directories should live under `/src/<repo>-worktrees` rather than `/tmp` so they sit on the larger persistent drive and survive longer-running missions.
|
|
||||||
- The coordinator needs a stable session identity, even if the active session changes.
|
|
||||||
- Task dependencies must be enforced so workers do not start early.
|
|
||||||
- The handoff packet should include the top 3 immediate actions and the strongest constraints.
|
|
||||||
- Rotation triggers should be configurable per profile or per mission.
|
|
||||||
- The initial version can be file-first, with dashboard sync added later.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Success Metrics
|
|
||||||
|
|
||||||
- A mission can rotate sessions without losing the active goal.
|
|
||||||
- A new session can resume from the latest handoff in under one turn.
|
|
||||||
- Board tasks remain aligned to PRD user stories.
|
|
||||||
- Short-cycling sessions are replaced before repeated compaction harms quality.
|
|
||||||
- Operators can find mission state without spelunking across multiple chat logs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
1. What should the canonical mission ID format be?
|
|
||||||
2. Should the board live only in git, or also in the database?
|
|
||||||
3. Should rotation be automatic by default, or opt-in per mission?
|
|
||||||
4. What should the short-cycle threshold be initially?
|
|
||||||
5. Should handoffs be pure text, structured JSON, or both?
|
|
||||||
6. Which CLI command should be the primary mission entrypoint: `mosaic mission`, `mosaic coord`, or `mosaic prdy`?
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
# Tasks — Mosaic Mission Control Plane
|
|
||||||
|
|
||||||
> Single-writer: orchestrator only. Workers read but never modify.
|
|
||||||
>
|
|
||||||
> **Mission:** mission-control-plane-20260506
|
|
||||||
> **Schema:** `| id | status | description | issue | agent | branch | depends_on | estimate | notes |`
|
|
||||||
> **Status values:** `not-started` | `in-progress` | `done` | `blocked` | `failed` | `needs-qa`
|
|
||||||
> **Agent values:** `codex` | `glm-5.1` | `haiku` | `sonnet` | `opus` | `—` (auto)
|
|
||||||
>
|
|
||||||
> Scope: this file decomposes the combined PRD / mission / board workflow into atomized tasks.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Milestone 1 — PRD + mission schema foundation
|
|
||||||
|
|
||||||
Goal: create the durable doc structure and the minimal mission metadata needed to keep PRD, board, and mission aligned.
|
|
||||||
|
|
||||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
|
||||||
| -------- | ----------- | -------------------------------------------------------------------------------------------------------- | ----- | ------ | ----------------------------- | ------------------ | -------- | ------------------------------------------- |
|
|
||||||
| MC-01-01 | not-started | Write `docs/mission-control/PRD.md` with goals, non-goals, functional requirements, and success metrics. | — | sonnet | docs/mission-control-prd | — | 5K | Human-readable PRD becomes the spec anchor. |
|
|
||||||
| MC-01-02 | not-started | Write `docs/mission-control/BOARD.md` as a decision board for scope, priority, and open questions. | — | haiku | docs/mission-control-board | MC-01-01 | 3K | Keeps discussion separate from the spec. |
|
|
||||||
| MC-01-03 | not-started | Write `docs/mission-control/MISSION-MANIFEST.md` linking PRD, board, tasks, and mission identity. | — | sonnet | docs/mission-control-manifest | MC-01-01, MC-01-02 | 4K | Durable mission root object. |
|
|
||||||
| MC-01-04 | not-started | Write `docs/mission-control/TASKS.md` with the atomized execution plan and dependency graph. | — | sonnet | docs/mission-control-tasks | MC-01-03 | 4K | Board-backed execution plan. |
|
|
||||||
|
|
||||||
**Milestone 1 estimate:** ~16K tokens
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Milestone 2 — Mission runtime model
|
|
||||||
|
|
||||||
Goal: make missions first-class runtime objects that can survive session restarts and compaction.
|
|
||||||
|
|
||||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
|
||||||
| -------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ----- | -------------------------------------- | ---------------------------------- | -------- | ------------------------------------------ | ---------------------------------------------------- |
|
|
||||||
| MC-02-01 | not-started | Define mission schema in the data layer: mission ID, goal, phase, PRD path, board path, active session ID, last handoff, and churn score. | — | codex | feat/mission-control-schema | MC-01-03 | 6K | This is the durable root state. |
|
|
||||||
| MC-02-02 | not-started | Add mission read/write services to `packages/coord` so the coordinator can load and persist mission state. | — | codex | feat/mission-control-coord-store | MC-02-01 | 6K | Keep storage simple and explicit. |
|
|
||||||
| MC-02-03 | not-started | Add mission status reporting to `mosaic mission` and `mosaic coord status`. | — | codex | feat/mission-control-status-cli | MC-02-02 | 4K | Operators need one obvious status command. |
|
|
||||||
| MC-02-04 | not-started | Add tests for mission persistence and recovery after restart. | — | haiku | feat/mission-control-persistence-tests | MC-02-02 | 4K | Verify mission survives process churn. |
|
|
||||||
| | MC-02-05 | done | Add a worktree-root convention to the mission runtime notes and startup guidance so agents prefer `/src/<repo>-worktrees` over `/tmp`. | — | haiku | docs/mission-control-worktree-root | MC-01-03 | 3K | Keep long-lived work on the larger persistent drive. |
|
|
||||||
|
|
||||||
**Milestone 2 estimate:** ~20K tokens
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Milestone 3 — Board atomization and task linkage
|
|
||||||
|
|
||||||
Goal: derive assignable tasks from the PRD and keep them linked to mission state.
|
|
||||||
|
|
||||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
|
||||||
| -------- | ----------- | ------------------------------------------------------------------------------------------- | ----- | ------ | -------------------------------- | ------------------ | -------- | ------------------------------------------- |
|
|
||||||
| MC-03-01 | not-started | Add a PRD-to-task decomposition rule set: every user story maps to one or more board tasks. | — | sonnet | feat/mission-control-decompose | MC-01-01 | 5K | Start simple and deterministic. |
|
|
||||||
| MC-03-02 | not-started | Implement board generation from the PRD in a machine-readable format. | — | codex | feat/mission-control-board-gen | MC-03-01 | 6K | Output should be usable by the coordinator. |
|
|
||||||
| MC-03-03 | not-started | Add dependency validation so tasks cannot start before parent tasks complete. | — | codex | feat/mission-control-deps | MC-03-02 | 5K | Enforces ordering. |
|
|
||||||
| MC-03-04 | not-started | Add review-task support so a mission cannot close without a reviewer step. | — | sonnet | feat/mission-control-review-gate | MC-03-03 | 4K | Preserves quality. |
|
|
||||||
| MC-03-05 | not-started | Add tests proving the board stays traceable back to the PRD user stories. | — | haiku | feat/mission-control-trace-tests | MC-03-02, MC-03-03 | 4K | Traceability is the point. |
|
|
||||||
|
|
||||||
**Milestone 3 estimate:** ~24K tokens
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Milestone 4 — Short-cycle detector and rotation engine
|
|
||||||
|
|
||||||
Goal: detect when a session is stuck and rotate to a fresh session before quality falls off.
|
|
||||||
|
|
||||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
|
||||||
| -------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | ----------------------------------- | ---------- | -------- | ---------------------------------------------- |
|
|
||||||
| MC-04-01 | not-started | Define churn signals: repeated compaction, identical tool loops, repeated permission prompts, and no progress across several turns. | — | sonnet | feat/mission-control-churn-signals | MC-02-01 | 4K | Keep the rules explicit. |
|
|
||||||
| MC-04-02 | not-started | Implement churn scoring in the coordinator with configurable thresholds. | — | codex | feat/mission-control-churn-score | MC-04-01 | 6K | Weighted score makes tuning easier. |
|
|
||||||
| MC-04-03 | not-started | Implement automatic session rotation when churn crosses the threshold. | — | codex | feat/mission-control-rotate-session | MC-04-02 | 6K | The session is disposable; the mission is not. |
|
|
||||||
| MC-04-04 | not-started | Add tests for rotation triggers and for avoiding premature rotation. | — | haiku | feat/mission-control-rotation-tests | MC-04-03 | 4K | Prevent flapping. |
|
|
||||||
|
|
||||||
**Milestone 4 estimate:** ~20K tokens
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Milestone 5 — Handoff generation and re-entry
|
|
||||||
|
|
||||||
Goal: preserve the best context from the old session and inject it into the new session cleanly.
|
|
||||||
|
|
||||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
|
||||||
| -------- | ----------- | -------------------------------------------------------------------------------------------------------------------- | ----- | ------ | ----------------------------------- | ------------------ | -------- | ---------------------------------------- |
|
|
||||||
| MC-05-01 | not-started | Define the handoff packet schema: mission ID, session ID, completed work, blockers, next 3 actions, and constraints. | — | sonnet | feat/mission-control-handoff-schema | MC-02-01 | 4K | Keep it compact and structured. |
|
|
||||||
| MC-05-02 | not-started | Implement handoff packet writing during rotation. | — | codex | feat/mission-control-handoff-write | MC-05-01, MC-04-03 | 5K | Persist before the old session exits. |
|
|
||||||
| MC-05-03 | not-started | Implement handoff packet loading at session startup. | — | codex | feat/mission-control-handoff-load | MC-05-01, MC-04-03 | 5K | New session should know the next action. |
|
|
||||||
| MC-05-04 | not-started | Add tests proving a rotated session can continue the mission without manual re-prompting. | — | haiku | feat/mission-control-handoff-tests | MC-05-02, MC-05-03 | 4K | Resume quality is the key metric. |
|
|
||||||
|
|
||||||
**Milestone 5 estimate:** ~18K tokens
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Milestone 6 — Operator surface and E2E validation
|
|
||||||
|
|
||||||
Goal: expose the whole workflow through commands and verify it end-to-end.
|
|
||||||
|
|
||||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
|
||||||
| -------- | ----------- | --------------------------------------------------------------------------------------------------------- | ----- | ------ | -------------------------------- | ------------------ | -------- | -------------------------------------------- |
|
|
||||||
| MC-06-01 | not-started | Add a CLI command to inspect the active mission, PRD path, board path, task statuses, and latest handoff. | — | codex | feat/mission-control-inspect-cli | MC-02-03, MC-05-03 | 5K | One place to inspect the whole stack. |
|
|
||||||
| MC-06-02 | not-started | Add a compact dashboard or TUI summary view for mission health. | — | codex | feat/mission-control-summary-ui | MC-06-01 | 6K | Nice to have, but not before the core works. |
|
|
||||||
| MC-06-03 | not-started | Build an E2E harness that simulates compaction / rotation and verifies the mission can continue. | — | sonnet | feat/mission-control-e2e-harness | MC-04-03, MC-05-03 | 8K | This is the proof that the design works. |
|
|
||||||
| MC-06-04 | not-started | Add final docs for operators explaining how PRD, mission, and board fit together. | — | haiku | feat/mission-control-ops-docs | MC-06-03 | 4K | Make it usable by humans. |
|
|
||||||
| MC-06-05 | not-started | Consolidate review findings and close the mission with a release note. | — | sonnet | chore/mission-control-close | MC-06-04 | 3K | Only after the E2E passes. |
|
|
||||||
|
|
||||||
**Milestone 6 estimate:** ~26K tokens
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Execution Notes
|
|
||||||
|
|
||||||
- `sonnet` is best for planning, decomposition, and the review-gate tasks.
|
|
||||||
- `codex` is best for schema, coordinator, and CLI implementation.
|
|
||||||
- `haiku` is best for validation, traceability checks, and docs.
|
|
||||||
- The first implementation pass should stay file-first and keep the runtime state thin.
|
|
||||||
- The mission should not close until the PRD, board, mission manifest, and E2E harness all agree.
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
# Hermes-Mosaic Alignment Plan
|
|
||||||
|
|
||||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
|
||||||
|
|
||||||
**Goal:** Package Mosaic's mechanical coordination primitives as a native Hermes toolset so any Hermes profile gets mission management, task decomposition, handoff, and session continuity without depending on the Mosaic gateway or OpenClaw runtime.
|
|
||||||
|
|
||||||
**Architecture:** Extract the coordination logic from Mosaic's `packages/coord` (TypeScript, file-first) into a Hermes Python toolset that wraps the same file conventions. The Mosaic Stack repo remains the canonical upstream for the file formats (TASKS.md schema, mission.json schema, handoff packet schema). Hermes implements native Python tools that read/write those same files, plus tool-calls for churn detection and handoff generation that have no Mosaic equivalent today.
|
|
||||||
|
|
||||||
**Tech Stack:** Python (Hermes toolset), SQLite (Hermes Kanban), JSON + Markdown (Mosaic file conventions)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Alignment Map
|
|
||||||
|
|
||||||
### What Mosaic has that Hermes needs
|
|
||||||
|
|
||||||
| Mosaic Component | What it does | Natural Hermes home | Why |
|
|
||||||
| -------------------------------- | --------------------------------------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `packages/coord` (mission.ts) | Mission CRUD, session tracking, milestone state | **Hermes toolset: `mission`** | Mission state is session-scoped, not gateway-scoped. Hermes sessions already have identity, process tracking, and context windows. |
|
|
||||||
| `packages/coord` (tasks-file.ts) | Parse/write TASKS.md tables | **Hermes toolset: `mission`** (same) | Hermes already reads/writes files. The TASKS.md parser is ~300 lines of pure string manipulation — trivial Python port. |
|
|
||||||
| `packages/coord` (runner.ts) | Spawn claude/codex workers with continuation prompts | **Already covered by `delegate_task`** | Hermes delegate_task already does isolated subagent spawning with restricted toolsets. The runner's "find next task and build continuation prompt" logic moves into a tool-call. |
|
|
||||||
| `packages/coord` (status.ts) | Mission health, task progress, next task | **Hermes toolset: `mission`** (same) | Status readout fits naturally as a tool-call. No gateway needed. |
|
|
||||||
| `packages/prdy` | PRD generation wizard | **Hermes skill: `prdy`** | PRD generation is a prompt + template problem, not infrastructure. A Hermes skill with templates is the right fit. |
|
|
||||||
| `plugins/mosaic-framework` | before_agent_start + subagent_spawning hooks | **Hermes system prompt injection** | Hermes already injects system context via skills and config. The framework preamble and worktree rules become standard Hermes skills loaded by the orchestrator profile. |
|
|
||||||
| `plugins/macp` | OpenClaw ACP bridge (spawn codex/claude) | **Already covered by `delegate_task` + ACP** | Hermes already has ACP support and delegate_task. The MACP bridge is redundant when running natively in Hermes. |
|
|
||||||
| Churn detection (planned) | Detect compaction loops, repeated tool calls, no progress | **Hermes middleware** | This needs to live inside Hermes's turn loop where it can observe tool-call patterns. Mosaic can't see this from outside. |
|
|
||||||
| Handoff packet (planned) | Structured context summary for session rotation | **Hermes toolset: `mission`** | Handoff is a serialization of mission + session state. Hermes owns the session, so it should own the handoff. |
|
|
||||||
|
|
||||||
### What Hermes already has that replaces Mosaic infrastructure
|
|
||||||
|
|
||||||
| Mosaic concept | Hermes equivalent | Notes |
|
|
||||||
| -------------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
||||||
| Gateway (NestJS) | Hermes gateway | Hermes already has a gateway with WebSocket, Discord, Telegram, CLI. No need for a second one. |
|
|
||||||
| Pi SDK agent runtime | Hermes agent loop | Hermes IS the agent runtime. OpenClaw's Pi SDK is a different runtime that Mosaic targets. |
|
|
||||||
| MACP ACP bridge | `delegate_task` + ACP tools | Same capability, already native. |
|
|
||||||
| Session identity | Hermes session IDs + process_registry | Hermes already tracks session identity, PIDs, and background processes. |
|
|
||||||
| Task execution board | Hermes Kanban | Fully functional SQLite-backed Kanban with dispatcher, triage, events, comments. |
|
|
||||||
| Worker spawning | Hermes dispatcher + cron | Kanban dispatcher + cron already handle this. |
|
|
||||||
| Context injection | Hermes skills + system prompt | Skills are loaded at session start and injected into context. Exactly what mosaic-framework plugin does. |
|
|
||||||
| File checkpoints | Hermes checkpoint_manager | Already tracks file mutations with shadow git. |
|
|
||||||
|
|
||||||
### What Mosaic keeps as its own entity
|
|
||||||
|
|
||||||
| Component | Why it stays in Mosaic |
|
|
||||||
| --------------------- | --------------------------------------------------- |
|
|
||||||
| `apps/gateway` | NestJS API surface — Mosaic's web platform offering |
|
|
||||||
| `apps/web` | Next.js dashboard — Mosaic's UI offering |
|
|
||||||
| `packages/types` | Shared TS contracts for Mosaic gateway plugins |
|
|
||||||
| `packages/db` | Drizzle ORM + PG — Mosaic's data layer |
|
|
||||||
| `packages/auth` | BetterAuth — Mosaic's auth system |
|
|
||||||
| `packages/brain` | PG-backed data layer for Mosaic web app |
|
|
||||||
| `packages/queue` | Valkey task queue for Mosaic gateway |
|
|
||||||
| `plugins/discord` | OpenClaw Discord plugin |
|
|
||||||
| `plugins/telegram` | OpenClaw Telegram plugin |
|
|
||||||
| `packages/mosaic` CLI | The `mosaic` CLI — Mosaic's own command surface |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture: `mission` Toolset for Hermes
|
|
||||||
|
|
||||||
### New files under `/opt/hermes/tools/`
|
|
||||||
|
|
||||||
```
|
|
||||||
mission_tools.py — Tool-call surface (mission_create, mission_status,
|
|
||||||
mission_next_task, mission_update_task, mission_handoff,
|
|
||||||
mission_resume)
|
|
||||||
mission_state.py — State management (read/write mission.json, parse TASKS.md,
|
|
||||||
parse MISSION-MANIFEST.md)
|
|
||||||
mission_churn.py — Churn detection (tool-loop counter, compaction counter,
|
|
||||||
progress scorer)
|
|
||||||
mission_handoff.py — Handoff packet generation and loading
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tool-calls exposed to the agent
|
|
||||||
|
|
||||||
| Tool | What it does | When the agent calls it |
|
|
||||||
| --------------------- | --------------------------------------------------------------------------------- | ------------------------------------------- |
|
|
||||||
| `mission_create` | Initialize mission.json + TASKS.md + MISSION-MANIFEST.md in a project dir | When starting a new mission |
|
|
||||||
| `mission_status` | Read current mission state, milestone progress, next task, active session | At session start, or when checking progress |
|
|
||||||
| `mission_next_task` | Find the next `not-started` task whose dependencies are met, return its full spec | When the agent needs work to do |
|
|
||||||
| `mission_update_task` | Update a task row status in TASKS.md | When completing or blocking a task |
|
|
||||||
| `mission_handoff` | Generate a handoff packet from current session context + mission state | Before session rotation or at session end |
|
|
||||||
| `mission_resume` | Load a handoff packet and inject it as context for the new session | At session start after rotation |
|
|
||||||
|
|
||||||
### Toolset registration
|
|
||||||
|
|
||||||
The `mission` toolset follows the same pattern as `kanban`:
|
|
||||||
|
|
||||||
1. **Gating**: Tools are available when:
|
|
||||||
- The profile has `mission` in its toolsets config, OR
|
|
||||||
- A `HERMES_MISSION_DIR` env var is set (cron/dispatcher spawned workers)
|
|
||||||
2. **File conventions**: The toolset reads/writes the same file formats as Mosaic `packages/coord`:
|
|
||||||
- `.mosaic/orchestrator/mission.json` — mission state
|
|
||||||
- `docs/TASKS.md` — task table
|
|
||||||
- `docs/MISSION-MANIFEST.md` — mission manifest
|
|
||||||
- `docs/scratchpads/<id>.md` — session scratchpad
|
|
||||||
|
|
||||||
3. **Kanban bridge**: Optional bidirectional sync between mission TASKS.md rows and Kanban task cards, so the dashboard sees mission tasks.
|
|
||||||
|
|
||||||
### Churn detection (middleware)
|
|
||||||
|
|
||||||
Churn detection lives in Hermes's turn loop, NOT as a tool-call. It observes:
|
|
||||||
|
|
||||||
- Repeated compaction events (context window pressure)
|
|
||||||
- Identical tool-call sequences (loop detection)
|
|
||||||
- No file state changes across N turns
|
|
||||||
- Repeated permission denials
|
|
||||||
|
|
||||||
When churn score exceeds threshold:
|
|
||||||
|
|
||||||
1. `mission_handoff` is called automatically
|
|
||||||
2. Session is rotated (fresh context window)
|
|
||||||
3. `mission_resume` is called in the new session
|
|
||||||
|
|
||||||
This is new infrastructure that only Hermes can provide (Mosaic runs outside the agent loop).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Tasks
|
|
||||||
|
|
||||||
### Phase 1: Core state management (Python port of coord)
|
|
||||||
|
|
||||||
| Task | Files | Estimate |
|
|
||||||
| -------------------------------------------------- | ----------------------------- | -------- |
|
|
||||||
| 1.1 Port mission.json read/write to Python | `mission_state.py` | 2h |
|
|
||||||
| 1.2 Port TASKS.md parser to Python | `mission_state.py` | 2h |
|
|
||||||
| 1.3 Port MISSION-MANIFEST.md reader to Python | `mission_state.py` | 1h |
|
|
||||||
| 1.4 Implement `mission_create` tool-call | `mission_tools.py` | 1h |
|
|
||||||
| 1.5 Implement `mission_status` tool-call | `mission_tools.py` | 1h |
|
|
||||||
| 1.6 Implement `mission_next_task` tool-call | `mission_tools.py` | 1h |
|
|
||||||
| 1.7 Implement `mission_update_task` tool-call | `mission_tools.py` | 1h |
|
|
||||||
| 1.8 Register `mission` toolset in Hermes registry | `tools/registry.py` | 30m |
|
|
||||||
| 1.9 Add `mission` to orchestrator profile toolsets | `config.yaml` | 10m |
|
|
||||||
| 1.10 Write unit tests for mission_state | `tests/test_mission_state.py` | 2h |
|
|
||||||
| 1.11 Write unit tests for TASKS.md parser | `tests/test_tasks_parser.py` | 1h |
|
|
||||||
|
|
||||||
**Phase 1 estimate:** ~13h
|
|
||||||
|
|
||||||
### Phase 2: Handoff and session continuity
|
|
||||||
|
|
||||||
| Task | Files | Estimate |
|
|
||||||
| ------------------------------------------------- | ---------------------------------------- | -------- |
|
|
||||||
| 2.1 Define handoff packet schema (JSON) | `mission_handoff.py` | 1h |
|
|
||||||
| 2.2 Implement `mission_handoff` tool-call | `mission_handoff.py`, `mission_tools.py` | 2h |
|
|
||||||
| 2.3 Implement `mission_resume` tool-call | `mission_handoff.py`, `mission_tools.py` | 2h |
|
|
||||||
| 2.4 Wire handoff into session start (auto-resume) | agent loop hook | 2h |
|
|
||||||
| 2.5 Write tests for handoff round-trip | `tests/test_mission_handoff.py` | 1h |
|
|
||||||
|
|
||||||
**Phase 2 estimate:** ~8h
|
|
||||||
|
|
||||||
### Phase 3: Churn detection
|
|
||||||
|
|
||||||
| Task | Files | Estimate |
|
|
||||||
| -------------------------------------------------------------- | ----------------------------- | -------- |
|
|
||||||
| 3.1 Define churn signal weights and thresholds | `mission_churn.py` | 1h |
|
|
||||||
| 3.2 Implement tool-loop detector (consecutive identical calls) | `mission_churn.py` | 2h |
|
|
||||||
| 3.3 Implement compaction pressure detector | `mission_churn.py` | 1h |
|
|
||||||
| 3.4 Implement progress scorer (file state delta) | `mission_churn.py` | 2h |
|
|
||||||
| 3.5 Wire churn scoring into agent turn loop | agent loop middleware | 2h |
|
|
||||||
| 3.6 Implement auto-rotation trigger | agent loop + handoff | 2h |
|
|
||||||
| 3.7 Write tests for churn scoring | `tests/test_mission_churn.py` | 1h |
|
|
||||||
|
|
||||||
**Phase 3 estimate:** ~11h
|
|
||||||
|
|
||||||
### Phase 4: Kanban bridge + CLI surface
|
|
||||||
|
|
||||||
| Task | Files | Estimate |
|
|
||||||
| ---------------------------------------------------- | ------------------------ | -------- |
|
|
||||||
| 4.1 Implement TASKS.md → Kanban sync (one-way first) | `mission_kanban_sync.py` | 2h |
|
|
||||||
| 4.2 Add `hermes mission` CLI subcommand | `mission_cli.py` | 2h |
|
|
||||||
| 4.3 Add `hermes mission status` command | `mission_cli.py` | 1h |
|
|
||||||
| 4.4 Add `hermes mission init` command | `mission_cli.py` | 1h |
|
|
||||||
| 4.5 Add `hermes mission handoff` command | `mission_cli.py` | 1h |
|
|
||||||
| 4.6 Add `hermes mission resume` command | `mission_cli.py` | 1h |
|
|
||||||
|
|
||||||
**Phase 4 estimate:** ~8h
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Format Compatibility
|
|
||||||
|
|
||||||
The Python implementation MUST read and write the exact same file formats as Mosaic's TypeScript `packages/coord`. This means:
|
|
||||||
|
|
||||||
1. **mission.json** schema is identical to `Mission` type in `packages/coord/src/types.ts`
|
|
||||||
2. **TASKS.md** table format is identical to what `packages/coord/src/tasks-file.ts` parses
|
|
||||||
3. **MISSION-MANIFEST.md** is free-form markdown (no parser needed — just read the file)
|
|
||||||
4. **Handoff packets** are a new JSON format defined in this toolset (Mosaic doesn't have them yet)
|
|
||||||
|
|
||||||
This way a project can use Hermes mission tools OR Mosaic `mosaic coord` commands interchangeably. The files are the contract.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Relationship Diagram
|
|
||||||
|
|
||||||
```
|
|
||||||
Mosaic Stack (TypeScript) Hermes Agent (Python)
|
|
||||||
┌─────────────────────────┐ ┌─────────────────────────┐
|
|
||||||
│ packages/coord │ │ tools/mission_tools.py │
|
|
||||||
│ ├─ mission.ts │◄──────►│ ├─ mission_state.py │
|
|
||||||
│ ├─ tasks-file.ts │ same │ ├─ mission_handoff.py │
|
|
||||||
│ ├─ status.ts │ files │ ├─ mission_churn.py │
|
|
||||||
│ └─ runner.ts │ │ └─ mission_tools.py │
|
|
||||||
│ │ │ │
|
|
||||||
│ packages/prdy │ │ skills/prdy/ │
|
|
||||||
│ └─ templates, wizard │◄──────►│ └─ SKILL.md + templates │
|
|
||||||
│ │ │ │
|
|
||||||
│ plugins/mosaic-framework│ │ skills/ (existing) │
|
|
||||||
│ └─ context injection │◄──────►│ └─ kanban-orchestrator │
|
|
||||||
│ │ │ + mosaic-coding-* │
|
|
||||||
│ plugins/macp │ │ tools/delegate_task.py │
|
|
||||||
│ └─ ACP bridge │◄──────►│ └─ already covers this │
|
|
||||||
│ │ │ │
|
|
||||||
│ (stays in Mosaic) │ │ tools/kanban_tools.py │
|
|
||||||
│ apps/gateway │ │ └─ Hermes Kanban DB │
|
|
||||||
│ apps/web │ │ │
|
|
||||||
│ packages/db │ │ tools/cronjob_tools.py │
|
|
||||||
│ packages/queue │ │ └─ already covers cron │
|
|
||||||
└─────────────────────────┘ └─────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
1. **Should the `mission` toolset ship with Hermes core, or as a plugin?**
|
|
||||||
- Recommendation: ship as a **built-in toolset** (like `kanban`) since mission coordination is a core agent capability, not an optional integration. The file formats are stable and the code is small.
|
|
||||||
|
|
||||||
2. **Should churn detection be per-profile configurable?**
|
|
||||||
- Recommendation: yes. Add `mission.churn_threshold` and `mission.churn_weights` to profile config.yaml. Default threshold = 5 consecutive no-progress turns.
|
|
||||||
|
|
||||||
3. **Should handoff packets live in the project dir or in Hermes home?**
|
|
||||||
- Recommendation: **project dir** (`.mosaic/handoffs/<session-id>.json`). This keeps them version-controlled and accessible regardless of which agent runtime picks up the project.
|
|
||||||
|
|
||||||
4. **Bidirectional Kanban sync?**
|
|
||||||
- Recommendation: **one-way first** (TASKS.md → Kanban). Bidirectional adds conflict resolution complexity. Ship one-way, add reverse sync in v2 if needed.
|
|
||||||
|
|
||||||
5. **PRD generation — skill or tool-call?**
|
|
||||||
- Recommendation: **skill** (`prdy`). PRD generation is a prompt engineering problem with templates. Skills already handle this pattern perfectly.
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
# Mosaic Stack ↔ Hermes Coordination Resilience
|
|
||||||
|
|
||||||
> Purpose: document the self-healing coordination patterns that emerged while implementing the Hermes mission toolset, distress-card protocol, and auto-heal watchers, so the same mechanics can be reimplemented in Mosaic Stack or any similar agent platform.
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
The coordination layer should be treated as a system of mechanical recovery loops rather than a single interactive agent session.
|
|
||||||
|
|
||||||
## SIBKISS operational summary
|
|
||||||
|
|
||||||
- mission on
|
|
||||||
- heartbeat always
|
|
||||||
- resume from packet
|
|
||||||
- block with `[BLOCKED]`
|
|
||||||
- reassign
|
|
||||||
- keep tasks tiny
|
|
||||||
- auto-heal dead workers
|
|
||||||
|
|
||||||
The design has four parts:
|
|
||||||
|
|
||||||
1. Atomic task decomposition — workers operate only within a small, explicit scope.
|
|
||||||
2. Distress signaling — workers create a standardized `[BLOCKED]` card when they encounter a blocker outside their scope.
|
|
||||||
3. Mechanical fallback — if the worker cannot phone home because of rate limits or dead context, a cron-style watcher synthesizes the distress card for them.
|
|
||||||
4. Auto-heal / reassignment — stale workers are reaped, crash-loops are reset, and rate-limited work is reassigned to a different profile/provider.
|
|
||||||
|
|
||||||
## Why this exists
|
|
||||||
|
|
||||||
Observed failure modes:
|
|
||||||
|
|
||||||
- Scope creep: a worker completes the target fix, then spends the rest of its budget chasing downstream cascade work.
|
|
||||||
- Silent failure / dead worker: the worker PID is gone, but the task remains running or blocked.
|
|
||||||
- Rate-limited worker: the worker is too constrained to create a help card itself, so it spins or fails without a clean handoff.
|
|
||||||
|
|
||||||
The answer is not to raise iteration caps or ask the worker to keep trying longer. The answer is to make the coordination layer self-healing and the work items atomic.
|
|
||||||
|
|
||||||
## Core workflow
|
|
||||||
|
|
||||||
### 1) Atomic task boundaries
|
|
||||||
|
|
||||||
Every task should have:
|
|
||||||
|
|
||||||
- one concern
|
|
||||||
- explicit files/packages in scope
|
|
||||||
- explicit files/packages out of scope
|
|
||||||
- a maximum file count if possible
|
|
||||||
- a stated expected iteration budget
|
|
||||||
|
|
||||||
When a worker discovers work outside scope, it must stop fixing it and hand off.
|
|
||||||
|
|
||||||
### 2) Worker-authored distress card
|
|
||||||
|
|
||||||
If the worker can still report status, it creates a card like:
|
|
||||||
|
|
||||||
- Title: `[BLOCKED] t_<source_id> <blocker_type>`
|
|
||||||
- Assignee: `tuesday` / orchestrator role
|
|
||||||
- Status: `ready`
|
|
||||||
- Body: standardized distress template with source task, blocker type, completed work, cannot-touch scope, and needed action
|
|
||||||
|
|
||||||
The orchestrator receives the card, acts on it, and closes the loop.
|
|
||||||
|
|
||||||
## Routing rules
|
|
||||||
|
|
||||||
### Distress card routing
|
|
||||||
|
|
||||||
- Title: `[BLOCKED] t_<source_id> <blocker_type>`
|
|
||||||
- Assignee: `tuesday` / orchestrator role
|
|
||||||
- Status: `ready`
|
|
||||||
- Body: standardized distress template with source task, blocker type, completed work, cannot-touch scope, and needed action
|
|
||||||
- Source task stays linked to the distress card so the recovery trail is auditable
|
|
||||||
|
|
||||||
The orchestrator receives the card, acts on it, and closes the loop.
|
|
||||||
|
|
||||||
### 3) Mechanical fallback for rate-limited workers
|
|
||||||
|
|
||||||
If the worker is too rate-limited or unstable to create the distress card itself, a no-agent watcher must synthesize the card from the task row and failure metadata.
|
|
||||||
|
|
||||||
That watcher should:
|
|
||||||
|
|
||||||
- inspect running / blocked tasks
|
|
||||||
- detect repeated 429 / 503 / overload errors
|
|
||||||
- create the same standardized `[BLOCKED]` card on behalf of the worker
|
|
||||||
- link the distress card to the source task
|
|
||||||
- add a comment to the source task
|
|
||||||
- allow the dispatcher to pick up the new card immediately
|
|
||||||
|
|
||||||
This is the key fix for the logic issue: the worker does not need to be able to phone home if the watcher can do it mechanically.
|
|
||||||
|
|
||||||
### 4) Auto-heal for dead workers
|
|
||||||
|
|
||||||
A separate no-agent watcher should:
|
|
||||||
|
|
||||||
- reap dead PIDs stuck in `running`
|
|
||||||
- reset crash-loops whose failures are infrastructure-related
|
|
||||||
- escalate tasks that have been reset too many times
|
|
||||||
|
|
||||||
This watcher prevents stale tasks from clogging the board and keeps the dispatch queue moving.
|
|
||||||
|
|
||||||
## Distress card contract
|
|
||||||
|
|
||||||
### Canonical title
|
|
||||||
|
|
||||||
```text
|
|
||||||
[BLOCKED] t_<source_task_id> <blocker_type>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Canonical blocker types
|
|
||||||
|
|
||||||
- `scope_boundary`
|
|
||||||
- `env_blocker`
|
|
||||||
- `credential_failure`
|
|
||||||
- `dependency`
|
|
||||||
- `iteration_budget`
|
|
||||||
- `rate_limited`
|
|
||||||
|
|
||||||
### Canonical body
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## Distress Signal
|
|
||||||
|
|
||||||
- Blocked task: t_xxx
|
|
||||||
- Worker: <profile_name>
|
|
||||||
- Branch: <git_branch_name>
|
|
||||||
- Workspace: <path>
|
|
||||||
- Blocker type: <type>
|
|
||||||
- Completed: <what was done>
|
|
||||||
- Cannot touch: <out-of-scope packages/files>
|
|
||||||
- Needs: <what the orchestrator should do>
|
|
||||||
- State: committed | uncommitted | stashed(<stash_name>)
|
|
||||||
|
|
||||||
## Scope Guard
|
|
||||||
|
|
||||||
DO NOT touch: anything outside diagnosing and remediating the blocker described above
|
|
||||||
Only fix: assign, split, reassign, or unblock the source task
|
|
||||||
```
|
|
||||||
|
|
||||||
## Routing rules
|
|
||||||
|
|
||||||
### Distress card routing
|
|
||||||
|
|
||||||
- `[BLOCKED]` title prefix should bypass normal triage.
|
|
||||||
- The card should go directly to the orchestration profile.
|
|
||||||
- The orchestrator should start from a clean session each time.
|
|
||||||
|
|
||||||
### Rate-limit fallback
|
|
||||||
|
|
||||||
When the source task is rate-limited:
|
|
||||||
|
|
||||||
- do not keep retrying in the worker
|
|
||||||
- let the watcher synthesize the distress card
|
|
||||||
- have the orchestrator reassign the source task to a different profile/provider combo
|
|
||||||
|
|
||||||
### Provider fallback principle
|
|
||||||
|
|
||||||
Never reassign rate-limited work back to the same provider if the failure was provider pressure. Use a different provider when possible.
|
|
||||||
|
|
||||||
### Suggested fallback order
|
|
||||||
|
|
||||||
1. Keep the current task body and scope guards intact.
|
|
||||||
2. Reassign to a different profile on a different provider.
|
|
||||||
3. If that is impossible, reassign to a different profile on the same provider only for non-rate-limit blockers.
|
|
||||||
4. If repeated failures continue, split the task into a narrower atomic card.
|
|
||||||
|
|
||||||
## Related recovery docs
|
|
||||||
|
|
||||||
- Mission packet recovery contract: `/opt/hermes/docs/mission-toolset-heartbeat.md`
|
|
||||||
- Hermes mission implementation plan: `/opt/hermes/docs/plans/mission-toolset-implementation.md`
|
|
||||||
- The same packet-first resume rule applies: inspect the latest packet before re-reading mission files.
|
|
||||||
- New-session trigger: when a profile config changes, start a fresh session or `/reset` so the updated toolset is actually loaded.
|
|
||||||
|
|
||||||
## Watchers to implement
|
|
||||||
|
|
||||||
### Auto-heal watcher
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
|
|
||||||
- reap stale workers
|
|
||||||
- reset dead-PID crash loops
|
|
||||||
- track reset counts
|
|
||||||
- escalate after repeated resets
|
|
||||||
|
|
||||||
### Distress synthesizer watcher
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
|
|
||||||
- detect rate-limited / stuck workers
|
|
||||||
- create `[BLOCKED]` cards mechanically
|
|
||||||
- link the card to the source task
|
|
||||||
- leave a comment for traceability
|
|
||||||
|
|
||||||
### Iteration-budget watcher
|
|
||||||
|
|
||||||
Responsibilities:
|
|
||||||
|
|
||||||
- detect long-running tasks and repeated failure patterns
|
|
||||||
- recommend splits when a task is clearly over-scoped
|
|
||||||
- report tasks that need human review after multiple resets
|
|
||||||
|
|
||||||
## Operational principle
|
|
||||||
|
|
||||||
If a task cannot cleanly finish within its atomic scope, the right response is to surface a smaller coordination problem, not to keep burning context.
|
|
||||||
|
|
||||||
This is what makes the system robust across compaction, rate limits, and dead workers.
|
|
||||||
|
|
||||||
## Suggested implementation order
|
|
||||||
|
|
||||||
1. Atomic task metadata in task bodies
|
|
||||||
2. Worker-authored distress card protocol
|
|
||||||
3. Mechanical distress synthesizer watcher
|
|
||||||
4. Auto-heal watcher for dead workers
|
|
||||||
5. Orchestrator routing rules for `[BLOCKED]`
|
|
||||||
6. Rate-limit fallback / model reassignment table
|
|
||||||
|
|
||||||
## Where this fits in Hermes
|
|
||||||
|
|
||||||
- Kanban = durable work graph and status engine
|
|
||||||
- Watchers = mechanical healing and distress synthesis
|
|
||||||
- Orchestrator = split / reassign / unblock decision-maker
|
|
||||||
- Workers = execution inside atomic task boundaries
|
|
||||||
|
|
||||||
## Where this fits in Mosaic Stack
|
|
||||||
|
|
||||||
- PRD / coordination infra should encode the same patterns
|
|
||||||
- Mosaic can use the same distress-card contract and watcher logic
|
|
||||||
- The coordination model should be runtime-agnostic: any agent system can use it if it can write a task card and react to a ready queue
|
|
||||||
|
|
||||||
## Cross-project takeaway
|
|
||||||
|
|
||||||
The important pattern is not the specific tool names. It is the mechanical feedback loop:
|
|
||||||
|
|
||||||
- detect failure without requiring the failing worker to succeed
|
|
||||||
- create a standardized help artifact
|
|
||||||
- route that artifact to a fresh orchestrator context
|
|
||||||
- repair the assignment graph
|
|
||||||
- continue the mission
|
|
||||||
|
|
||||||
That pattern is reusable anywhere.
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
# PRD — Agent Reflection Loop (durable kernel)
|
|
||||||
|
|
||||||
**Issue:** [#544](http://git.mosaicstack.dev/mosaicstack/stack/issues/544)
|
|
||||||
**Source design:** jarvis-brain `docs/planning/AGENT-REFLECTION-LOOP.md` (commit df6576fc, debate-hardened v2)
|
|
||||||
**Status:** in-progress
|
|
||||||
**Scope rule:** Build the **durable kernel** only. The closed calibration/skill-synthesis loop
|
|
||||||
(design §7–§8) is **gated** behind Phase-0 experiments P1/P2/P3 and is explicitly out of scope here.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Problem
|
|
||||||
|
|
||||||
At end-of-run an agent holds context that never reaches the diff or the "done" message —
|
|
||||||
assumptions, shortcuts, untested paths, the single most-likely way the work is wrong. That context
|
|
||||||
is what a lead/human needs to judge trust, and it evaporates when the session ends. Capture it
|
|
||||||
mechanically as **structured data** (`reflection.v1`), and derive a **review risk-floor** from the
|
|
||||||
change surface so risky diffs are flagged for independent review.
|
|
||||||
|
|
||||||
## 2. Non-goals (gated on Phase-0)
|
|
||||||
|
|
||||||
- No closed calibration loop (predicted-vs-actual scoring as a routing input).
|
|
||||||
- No skill synthesis.
|
|
||||||
- No automated reviewer routing/dispatch. The kernel **writes** the sidecar; pickup is future work.
|
|
||||||
|
|
||||||
## 3. Components & exact placement (main-branch truth)
|
|
||||||
|
|
||||||
| # | Component | Path | Mirror |
|
|
||||||
| --- | -------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------- |
|
|
||||||
| a | Stop hook (capture) | `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh` | `tools/qa/prevent-memory-write.sh` |
|
|
||||||
| a | Hook registration | `packages/mosaic/framework/runtime/claude/settings.json` (`hooks.Stop`) | existing `PreToolUse`/`PostToolUse` |
|
|
||||||
| b | JSON Schema | `packages/macp/src/schemas/reflection.v1.schema.json` | `schemas/task.schema.json` |
|
|
||||||
| b | TS types (zod) + DTO | `packages/types/src/reflection/{index.ts,reflection.dto.ts}` + re-export from `src/index.ts` | `packages/types/src/federation/*` |
|
|
||||||
| c | Diff risk-floor | `packages/macp/src/risk-floor.ts` (+ `__tests__/risk-floor.test.ts`, export from `src/index.ts`) | `packages/macp/src/gate-runner.ts` |
|
|
||||||
| d | Phase-0 scripts | `scripts/analysis/reflect-{git-history,board-history,calibration}.sh` | `scripts/publish-npmjs.sh` |
|
|
||||||
|
|
||||||
**Activation note (deliberate deviation):** the `settings-overlays/` directory has **no merge
|
|
||||||
mechanism** (referenced only in docs), so a hooks overlay there would be inert. The Stop hook is
|
|
||||||
registered in the canonical `runtime/claude/settings.json` — the same file the `mosaic` launcher
|
|
||||||
reflects into `~/.claude/settings.json` (verified byte-identical hooks live there). Still fully
|
|
||||||
vendored in-repo.
|
|
||||||
|
|
||||||
## 4. `reflection.v1` schema (authoritative field list)
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
{
|
|
||||||
"schema": "reflection.v1", // literal
|
|
||||||
"task_ref": "string", // canonical task ref; kernel derives from REFLECTION_TASK_REF or repo+branch
|
|
||||||
"agent": "string", // persona/runtime id (REFLECTION_AGENT or "unknown")
|
|
||||||
"session_id": "string", // from Stop payload session_id, else "unknown"
|
|
||||||
"timestamp": "string", // ISO-8601 UTC
|
|
||||||
"repo": "string", // repo root basename
|
|
||||||
"confidence": 0.0, // FLOAT [0,1] — SELF-REPORTED (optional; null if not supplied)
|
|
||||||
"most_likely_wrong": {
|
|
||||||
// SELF-REPORTED (optional)
|
|
||||||
"surface": "auth|data|infra|ui|build|test|docs|none",
|
|
||||||
"description": "string",
|
|
||||||
},
|
|
||||||
"known_not_in_diff": "string|null", // SELF-REPORTED: "what I know that isn't visible in the diff"
|
|
||||||
"risk": {
|
|
||||||
// MECHANICAL — from risk-floor
|
|
||||||
"needs_review": true,
|
|
||||||
"score": 0.0, // [0,1]
|
|
||||||
"surface": "auth|data|infra|ui|build|test|docs|none",
|
|
||||||
"reason": "string",
|
|
||||||
},
|
|
||||||
"files_changed": ["string"], // MECHANICAL — git diff name-only
|
|
||||||
"provenance": {
|
|
||||||
"source": "stop-hook",
|
|
||||||
"reflection_attempt": 1,
|
|
||||||
"degraded": false, // true if self-report inputs missing/unreadable
|
|
||||||
"reflection_mode": "off|solo|orchestrated",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Mechanical vs self-reported.** A bash Stop hook cannot author the agent's self-assessment. The
|
|
||||||
hook populates the **mechanical** fields deterministically (risk, files_changed, provenance, ids).
|
|
||||||
The **self-reported** fields are read from an optional agent-supplied input file
|
|
||||||
(`$REFLECTION_INPUT`, default `<repo>/.mosaic/reflection-input.json`) and merged if present;
|
|
||||||
absent/unreadable → those fields null and `provenance.degraded=true`. This realizes the design's
|
|
||||||
"hook is a pre-seed, not the asker" (§4).
|
|
||||||
|
|
||||||
## 5. Stop hook behavior (fail-closed, non-blocking)
|
|
||||||
|
|
||||||
1. Read Stop payload JSON from stdin.
|
|
||||||
2. **Fail-closed:** if `REFLECTION_MODE` is unset or `off` → `exit 0` immediately (strict no-op). This
|
|
||||||
is the global-registration safety guarantee.
|
|
||||||
3. **Sentinel guard:** if `<sidecar>.lock` exists → `exit 0` (prevents re-fire loops). Create it,
|
|
||||||
`trap` cleanup.
|
|
||||||
4. Determine output dir: `$REFLECTION_DIR` else `<repo>/.mosaic/reflections/`. `mkdir -p`.
|
|
||||||
5. Compute mechanical fields: `git diff --name-only` (HEAD + staged + worktree, best-effort),
|
|
||||||
call risk-floor logic (inline bash port OR `node -e` into `@mosaicstack/macp` — see §6), session
|
|
||||||
ids from payload + env.
|
|
||||||
6. Merge optional `$REFLECTION_INPUT` self-report if readable JSON.
|
|
||||||
7. Write `reflection.v1` to a temp file, `mv` (atomic) to `<dir>/<session>-<ts>.reflection.json`.
|
|
||||||
8. Always `exit 0`. **Never** emit a `decision` field (Stop hooks are observational).
|
|
||||||
|
|
||||||
Hook must never fail the session: wrap risky steps, default to `degraded:true` on any error, exit 0.
|
|
||||||
|
|
||||||
## 6. Risk-floor (`packages/macp/src/risk-floor.ts`)
|
|
||||||
|
|
||||||
Pure, deterministic, no IO. Single source of truth for the verdict; the hook calls it via
|
|
||||||
`node --input-type=module -e` (importing the built package) **or**, to avoid a node dependency in the
|
|
||||||
hook path, the hook ports the same surface table. **Decision:** implement the canonical logic in TS
|
|
||||||
(tested), and have the hook shell out to node when available, else fall back to a minimal inline
|
|
||||||
classifier flagged `degraded:true`. (Keep the TS the authority; the inline path is a safety net.)
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export type ReviewSurface = 'auth' | 'data' | 'infra' | 'ui' | 'build' | 'test' | 'docs' | 'none';
|
|
||||||
export interface RiskFloorInput {
|
|
||||||
filesChanged: string[];
|
|
||||||
insertions?: number;
|
|
||||||
deletions?: number;
|
|
||||||
}
|
|
||||||
export interface RiskFloorVerdict {
|
|
||||||
needs_review: boolean;
|
|
||||||
score: number;
|
|
||||||
surface: ReviewSurface;
|
|
||||||
reason: string;
|
|
||||||
}
|
|
||||||
export function evaluateRiskFloor(input: RiskFloorInput): RiskFloorVerdict;
|
|
||||||
```
|
|
||||||
|
|
||||||
Surface classification by path regex (first match wins, highest-risk surface dominates):
|
|
||||||
|
|
||||||
- `auth` (weight 1.0): `auth`, `login`, `session`, `token`, `permission`, `rbac`, `credential`, `secret`
|
|
||||||
- `data` (0.9): `migration`, `prisma`, `schema`, `\.sql`, `entity`, `repository`, `seed`
|
|
||||||
- `infra` (0.85): `docker`, `\.woodpecker`, `compose`, `traefik`, `deploy`, `helm`, `k8s`, `terraform`
|
|
||||||
- `build` (0.6): `package.json`, `tsconfig`, `turbo.json`, `pnpm-`, `\.config\.`, `eslint`, `vite`
|
|
||||||
- `ui` (0.4): `\.tsx`, `\.css`, `components/`, `apps/web/`
|
|
||||||
- `test` (0.2): `\.spec\.`, `\.test\.`, `__tests__/`
|
|
||||||
- `docs` (0.1): `\.md`, `docs/`
|
|
||||||
- `none` (0.0): anything else
|
|
||||||
|
|
||||||
`needs_review = score >= THRESHOLD` (default `0.5`, overridable). `reason` names the files+surface
|
|
||||||
that tripped it. **Subordinate to CI:** this is a _floor_ (minimum review requirement) only;
|
|
||||||
consumers MUST treat CI/tests as authoritative above the floor (precedence: CI/tests > human merge >
|
|
||||||
reviewer verdict > self-reflection). Documented in the module header.
|
|
||||||
|
|
||||||
## 7. Phase-0 experiment scripts (`scripts/analysis/`)
|
|
||||||
|
|
||||||
Offline, no-infra bash. Each script: `#!/usr/bin/env bash`, `set -euo pipefail`, header `Usage:` +
|
|
||||||
`Requirements:`, flag parsing, **prints its pre-registered kill condition**, emits structured
|
|
||||||
(JSON/markdown) output. They are harnesses + rubrics — real corpora are wired later.
|
|
||||||
|
|
||||||
- `reflect-git-history.sh` (**P2** — only-self-reflection bucket): scan `git log` for failure signals
|
|
||||||
(reverts, `fix:`/`hotfix` shortly after a feature merge) over a window; classify each by which gate
|
|
||||||
would catch it (CI / human-review / only-self-reflection) via a pre-registered heuristic; tally.
|
|
||||||
Kill: bucket-3 near-empty → no §7/§8.
|
|
||||||
- `reflect-board-history.sh` (**P3** — outcome detectability): given a task/board export (or the
|
|
||||||
git history of `data/` task files), measure the fraction of completed tasks with a
|
|
||||||
machine-detectable correct/wrong signal within 30 days. Kill: base-rate < 20% → caveat-notes only.
|
|
||||||
- `reflect-calibration.sh` (**P1** — confidence signal): consume a labeled corpus (JSONL of
|
|
||||||
`{confidence, correct}`), compute discrimination (AUC/lift) on the self-rated-high subset, print
|
|
||||||
the metric vs the pre-registered chance threshold. Kill: AUC ≈ chance on the high subset → no §7/§8.
|
|
||||||
|
|
||||||
## 8. CI / quality gates
|
|
||||||
|
|
||||||
- TS packages: `pnpm typecheck` (tsc --noEmit), `pnpm lint` (eslint), `pnpm format:check`
|
|
||||||
(prettier), `pnpm test` (vitest). ESM, NodeNext, `.js` import specifiers, `*.dto.ts` at boundaries.
|
|
||||||
- New files in existing packages need no CI config change; add ≥1 vitest spec per new TS module.
|
|
||||||
- Bash scripts/hook are dev/runtime tooling, not CI-built; keep them `shellcheck`-clean.
|
|
||||||
|
|
||||||
## 9. Acceptance criteria
|
|
||||||
|
|
||||||
1. `REFLECTION_MODE` unset → hook is a strict no-op (`exit 0`, no file written). **(test)**
|
|
||||||
2. With `REFLECTION_MODE=solo`, hook writes a schema-valid `reflection.v1` with correct mechanical
|
|
||||||
fields; self-report merged when `$REFLECTION_INPUT` present, `degraded:true` when absent.
|
|
||||||
3. `evaluateRiskFloor` deterministic across all surfaces; unit-tested incl. auth/data/infra → review,
|
|
||||||
docs/test → no review, empty → `none`/no review.
|
|
||||||
4. `reflection.v1` zod type + JSON Schema agree; sidecar validates against the schema.
|
|
||||||
5. Phase-0 scripts run offline, print kill conditions, emit structured output, shellcheck-clean.
|
|
||||||
6. `pnpm typecheck && pnpm lint && pnpm format:check && pnpm test` green; independent review passed.
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# Fleet CLI Local Canary Dogfood — 2026-06-20
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Move the durable tmux fleet PoC into a functional local canary on this server. This is **not** production deployment. It is a canary/dogfood path for a small local agent fleet using an isolated tmux socket.
|
|
||||||
|
|
||||||
## Issue
|
|
||||||
|
|
||||||
- Gitea issue: #562 — `feat(fleet): local CLI canary dogfood`
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
Implement enough product surface to use the fleet locally:
|
|
||||||
|
|
||||||
- `mosaic fleet init/install/start/stop/restart/status/verify`
|
|
||||||
- `mosaic agent roster/status/send/reset/tail`
|
|
||||||
- roster schema and examples
|
|
||||||
- local canary docs and rollback instructions
|
|
||||||
- tests for CLI behavior where practical
|
|
||||||
- canary verification on named tmux socket `mosaic-fleet`
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
- No production rollout.
|
|
||||||
- No migration of existing default tmux sessions.
|
|
||||||
- No image build/deploy work.
|
|
||||||
- No hardcoded USC/local roster as product default.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- CLI can initialize a minimal roster outside product defaults.
|
|
||||||
- CLI can install user systemd units and fleet helper scripts to a configurable Mosaic home.
|
|
||||||
- CLI can start/stop/status/verify a canary fleet using `mosaic-fleet`.
|
|
||||||
- `mosaic agent send` uses existing named-socket/exact-target tmux tooling.
|
|
||||||
- `mosaic agent reset` targets only the named agent session on the named socket.
|
|
||||||
- Verification proves default tmux sessions remain untouched.
|
|
||||||
- Baseline repo gates pass.
|
|
||||||
- PR CI is green before merge.
|
|
||||||
- Local canary evidence is captured after merge/install.
|
|
||||||
|
|
||||||
## Budget / Routing
|
|
||||||
|
|
||||||
- Agent: codex preferred.
|
|
||||||
- Estimate: 25K-40K tokens.
|
|
||||||
- Worker owns implementation/tests/docs in branch `feat/fleet-cli-local-canary`.
|
|
||||||
- Orchestrator owns `docs/TASKS.md`, issue/PR/merge, and local canary install verification.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- 2026-06-20: #557 PoC primitives merged to `main` as `45e2c2a`.
|
|
||||||
- 2026-06-20: issue #562 created for local CLI canary dogfood.
|
|
||||||
- 2026-06-20: worktree created at `/home/jarvis/src/mosaicstack-stack-worktrees/fleet-cli-local-canary`.
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# Fleet release hardening
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Harden the Mosaic local fleet release path for operator sends, tmux/systemd verification, package contents, and dogfood release documentation.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
|
|
||||||
- Do not edit `docs/TASKS.md`.
|
|
||||||
- Do not change production deployment refs.
|
|
||||||
- Keep fleet transport generic and named-socket safe.
|
|
||||||
- Preserve strict roster validation.
|
|
||||||
- Add tests first or alongside fixes.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Add regression tests for deterministic `mosaic agent send` source labels.
|
|
||||||
2. Strengthen fleet status/verify/package/install-systemd coverage.
|
|
||||||
3. Implement focused CLI/source-label changes.
|
|
||||||
4. Update local canary documentation with dogfood preflight.
|
|
||||||
5. Run formatting, targeted tests, typecheck, lint, and package dry-run evidence.
|
|
||||||
|
|
||||||
## Evidence Log
|
|
||||||
|
|
||||||
- Started from existing `docs/PRD.md`; durable local fleet canary is in v0.1.0 scope.
|
|
||||||
- Loaded `mosaic-fleet-operations` skill; key constraints are isolated tmux sockets, no default tmux positive tests, and `active (exited)` is not liveness.
|
|
||||||
- TDD red: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts` initially failed because `node_modules` was absent; after `pnpm install`, the new source-label tests failed on missing `-S`, missing helper, and unknown `--source-label`.
|
|
||||||
- Green implementation: `mosaic agent send` now passes `-S <hostname>:operator` by default and accepts `--source-label` / `--source` overrides.
|
|
||||||
- Test coverage added for tmux-based fleet verify liveness, package `files` allowlist containing `framework`, and explicit operator source-label command construction.
|
|
||||||
- Formatting: `pnpm exec prettier --write packages/mosaic/src/commands/fleet.ts packages/mosaic/src/commands/fleet.spec.ts docs/guides/fleet-local-canary.md docs/scratchpads/2026-06-20-fleet-release-hardening.md`.
|
|
||||||
- Targeted tests: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts src/cli-smoke.spec.ts` passed with 49 tests.
|
|
||||||
- Typecheck: `pnpm typecheck` passed.
|
|
||||||
- Lint: `pnpm lint` passed.
|
|
||||||
- Package dry-run: `npm pack --dry-run --json` from `packages/mosaic` included `framework/fleet`, `framework/systemd/user`, `framework/tools/fleet/start-agent-session.sh`, and `framework/tools/tmux/{agent-send.sh,send-message.sh}`.
|
|
||||||
- Review: `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` approved the supplied diff with no findings; the review tool noted its read-only sandbox could not inspect files directly.
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# Scratchpad — FED-M3-04 Scope Service
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Implement `apps/gateway/src/federation/server/scope.service.ts` for the M3 inbound federation scope-enforcement pipeline.
|
|
||||||
|
|
||||||
## Scope / Constraints
|
|
||||||
|
|
||||||
- Task: FED-M3-04, issue #462.
|
|
||||||
- Branch: `feat/federation-m3-scope-service` from `origin/main` @ 0.0.48.
|
|
||||||
- Pure service: no direct DB access; native RBAC/data access is injected per evaluation call.
|
|
||||||
- Reuse `parseFederationScope` from M2-03.
|
|
||||||
- Workers do not edit `docs/federation/TASKS.md` per repo AGENTS.md.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
1. Resource allowlist and `excluded_resources` enforced.
|
|
||||||
2. Native RBAC evaluated as `subjectUserId` through an injected evaluator.
|
|
||||||
3. Scope filter intersection supports `include_teams` and `include_personal` without widening native RBAC.
|
|
||||||
4. `max_rows_per_query` caps requested limits.
|
|
||||||
5. Service returns `{ allowed: true, filter }` or a structured deny reason usable by M4 audit.
|
|
||||||
6. Unit tests cover every deny path.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Inspect existing federation scope/schema/auth guard contracts.
|
|
||||||
2. Add pure `FederationScopeService` plus typed result/filter/deny interfaces.
|
|
||||||
3. Add focused unit tests for happy paths, filter intersection, row cap, and deny paths.
|
|
||||||
4. Export/register service for future verb controllers.
|
|
||||||
5. Run situational tests, baseline gates, code review, then PR.
|
|
||||||
|
|
||||||
## Budget
|
|
||||||
|
|
||||||
- Provided model tier: sonnet.
|
|
||||||
- Estimate from task row: 10K tokens.
|
|
||||||
- Working cap assumption: keep implementation focused to FED-M3-04 surfaces only.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- Intake complete; dirty base worktree avoided by creating isolated worktree at `/home/jarvis/src/mosaic-mono-v1-fed-m3-04`.
|
|
||||||
- Project PRD and federation task spec reviewed.
|
|
||||||
- Added `FederationScopeService` with structured allow/deny result types and injected native RBAC evaluator contract.
|
|
||||||
- Added unit coverage for happy path, row cap, filter intersection, and every deny path.
|
|
||||||
- Exported/registered the service for upcoming M3 verb controllers.
|
|
||||||
|
|
||||||
## Verification Evidence
|
|
||||||
|
|
||||||
- `pnpm --filter @mosaicstack/gateway test -- src/federation/server/__tests__/scope.service.spec.ts` — pass (10 tests before review update; 11 tests after adding include_personal no-leak coverage).
|
|
||||||
- `pnpm build` — pass (23 successful tasks).
|
|
||||||
- `pnpm typecheck` — pass (41 successful tasks; re-run after review update).
|
|
||||||
- `pnpm lint` — pass (23 successful tasks; re-run after review update).
|
|
||||||
- `pnpm format:check` — pass (re-run after review update).
|
|
||||||
- `pnpm test` — pass after starting local `postgres`/`valkey` and running `pnpm --filter @mosaicstack/db db:push` for the DB-backed cross-user isolation suite (41 successful tasks; gateway 477 passed / 11 skipped).
|
|
||||||
- Code review: `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings.
|
|
||||||
- Security review: `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — risk none, 0 findings.
|
|
||||||
|
|
||||||
## Risks / Blockers
|
|
||||||
|
|
||||||
- Issue #462 is already closed in provider output; likely milestone tracking mismatch. Will still reference #462 in PR body unless orchestrator redirects.
|
|
||||||
- Local full-test setup required `docker compose up -d postgres valkey` + `db:push`; containers were stopped with `docker compose down` after verification.
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
# Issue 536 Wrapper Login Pin Scratchpad
|
|
||||||
|
|
||||||
## Metadata
|
|
||||||
|
|
||||||
- Date: 2026-06-12
|
|
||||||
- Worktree: `/home/hermes/agent-work/536-wrapper-audit`
|
|
||||||
- Branch: `fix/536-wrapper-login-pin`
|
|
||||||
- Coordinator: `mos-claude`
|
|
||||||
- Issue: `mosaicstack/stack#536`
|
|
||||||
- Scope: Audit and fix Gitea git wrappers that hardcode or incorrectly inherit tea login/instance selection.
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Fix the framework git wrappers so Gitea issue/PR operations resolve the tea login from the target repository host instead of pinning `mosaicstack`. The fix must cover the class of bug across `packages/mosaic/framework/tools/git/`, not only `issue-close.sh`.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
1. `issue-close.sh` no longer uses `--login mosaicstack` for non-mosaic hosts.
|
|
||||||
2. All wrappers in `packages/mosaic/framework/tools/git/` avoid hardcoded Gitea login fallback where host-specific resolution is available.
|
|
||||||
3. Host-specific resolution works for `git.mosaicstack.dev` and `git.uscllc.com` using configured credentials / tea login data.
|
|
||||||
4. Read-only verification runs against both Gitea instances where possible.
|
|
||||||
5. Queue guard passes before push, PR is opened referencing #536, and merge is left to the coordinator.
|
|
||||||
|
|
||||||
## Progress Log
|
|
||||||
|
|
||||||
- Read required Mosaic hard-gate docs and coordinator briefing.
|
|
||||||
- Read issue #536 via Gitea API with mosaicstack credentials.
|
|
||||||
- Initial audit found hardcoded `${GITEA_LOGIN:-mosaicstack}` in issue and PR wrappers, plus shared `get_gitea_repo_args`.
|
|
||||||
- Added host-aware Gitea login resolution in `detect-platform.sh`, including exact host matching for `tea login list` entries and HTTPS remotes with embedded credentials.
|
|
||||||
- Updated Gitea issue, PR, milestone, and CI wrappers to use resolved host-specific tea login arguments instead of defaulting to `mosaicstack`.
|
|
||||||
- Added authenticated API fallbacks for close/reopen paths so wrappers can still operate when a matching `tea` login is absent but token credentials are available.
|
|
||||||
- Added regression coverage for stale `GITEA_LOGIN`, exact host matching, `--repo` override flows, USC issue close routing, mosaicstack API fallback, and PR metadata/merge fallbacks.
|
|
||||||
- Delta after PR #538 review: extended host-aware login/repo resolution to PowerShell wrappers, Bash milestone wrappers, and API-only `--repo` fallback paths.
|
|
||||||
- Delta after live USC `pr-create.sh` repro: tightened `GITEA_LOGIN` trust so stale login names are ignored unless the tea login itself matches the target host, and added USC API fallback coverage for `pr-create.sh`.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- `bash -n packages/mosaic/framework/tools/git/*.sh`
|
|
||||||
- `packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh`
|
|
||||||
- `packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh`
|
|
||||||
- `packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh`
|
|
||||||
- `pwsh -NoProfile` parse check for all `packages/mosaic/framework/tools/git/*.ps1`
|
|
||||||
- `pnpm typecheck`
|
|
||||||
- `pnpm lint`
|
|
||||||
- `pnpm format:check`
|
|
||||||
- `pnpm --filter @mosaicstack/mosaic test -- src/commands/git-wrapper-redirects.spec.ts`
|
|
||||||
- `pnpm test` progressed past wrapper redirect assertions; local run then stopped on `apps/gateway` Postgres connection refused at `localhost:5433`, which CI provides as a service.
|
|
||||||
- Live read-only: direct Gitea API read of `mosaicstack/stack#536` with `User-Agent: curl/8`.
|
|
||||||
- Live read-only: USC temporary repo remote to `https://git.uscllc.com/USC/uconnect.git`; `issue-list.sh -n 1` resolved the USC login and returned USC issues.
|
|
||||||
- Independent Codex review final verdict: approve, no findings.
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# Scratchpad — #544 Agent Reflection Loop (durable kernel)
|
|
||||||
|
|
||||||
**Started:** 2026-06-16 · **Branch:** `feat/agent-reflection-loop` · **Base:** `main` @ c461380
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Bake the durable kernel of the agent reflection loop into the Mosaic Stack
|
|
||||||
monorepo through full delivery gates. Kernel only; closed loop (§7–§8) gated on
|
|
||||||
Phase-0. Authoritative spec: `docs/plans/agent-reflection-loop-PRD.md`. Task
|
|
||||||
breakdown: `docs/tasks/544-agent-reflection-loop.md`.
|
|
||||||
|
|
||||||
## Timeline / decisions
|
|
||||||
|
|
||||||
- Mapped house style against `main` truth (the earlier recon had mapped a dirty
|
|
||||||
feature branch and returned non-existent paths; re-cloned `main` clean).
|
|
||||||
- macp uses co-located `*.spec.ts`; types uses `src/<mod>/{*.ts, *.dto.ts, __tests__/*.spec.ts}`.
|
|
||||||
- zod v4 + class-validator/class-transformer present in `@mosaicstack/types`;
|
|
||||||
`packages/types/tsconfig.json` enables `experimentalDecorators`/`emitDecoratorMetadata`.
|
|
||||||
- **Gotcha (fixed):** `class-transformer`'s `@Type` calls `Reflect.getMetadata`
|
|
||||||
at module-load time; the types vitest env has no `reflect-metadata`, so any test
|
|
||||||
importing the reflection barrel crashed on import. `chat.dto.ts` avoids this by
|
|
||||||
using class-validator only. Fix: dropped `@Type`/`@ValidateNested` from the DTO;
|
|
||||||
zod owns deep nested validation.
|
|
||||||
- **Gotcha (fixed):** Stop hook `EXIT` trap referenced a `main`-local `lock` →
|
|
||||||
`unbound variable` under `set -u` at exit. Promoted to a global `LOCKFILE`.
|
|
||||||
- **Gotcha (fixed):** the hook's own lock + `.mosaic/` scratch leaked into
|
|
||||||
`files_changed`. Excluded `^\.mosaic/` from the change-surface scan.
|
|
||||||
|
|
||||||
## Verification evidence
|
|
||||||
|
|
||||||
- macp: typecheck OK, lint OK, **88 tests pass** (15 new risk-floor).
|
|
||||||
- types: typecheck OK, lint OK, **64 tests pass** (10 new reflection).
|
|
||||||
- Root: `pnpm typecheck` (41 tasks), `pnpm lint` (23), `pnpm format:check`, `pnpm build` (23) — all green.
|
|
||||||
- Stop hook smoke (throwaway git repo): TEST1 no-op (mode unset, 0 files);
|
|
||||||
TEST2 solo degraded, `.mosaic/` excluded, auth→needs_review; TEST3 self-report
|
|
||||||
merged, degraded=false; TEST4 lock suppresses re-fire. All pass, always exit 0.
|
|
||||||
- shellcheck clean: hook + `reflect-{git-history,board-history,calibration}.sh`.
|
|
||||||
- Phase-0 smoke: P2 on this repo (142 failures classified), P1 AUC=0.875 on a
|
|
||||||
synthetic fixture, P3 base-rate on a synthetic board — all emit structured output
|
|
||||||
- kill conditions.
|
|
||||||
|
|
||||||
## Open risks / follow-ups
|
|
||||||
|
|
||||||
- Full `pnpm test` (DB-bound packages) validated via CI's postgres service, not
|
|
||||||
locally; affected packages (macp, types) are DB-independent and green here.
|
|
||||||
- sequential-thinking MCP was registered mid-session (effective next session);
|
|
||||||
this session compensated with the written PRD as the planning artifact.
|
|
||||||
- Phase-0 corpora are not yet wired — scripts are harnesses + pre-registered
|
|
||||||
rubrics (P1/P2/P3 tasks tracked in jarvis-brain `agent-reflection-loop` project).
|
|
||||||
|
|
||||||
## Gate status
|
|
||||||
|
|
||||||
- [x] PRD authored · [x] issue #544 created + linked · [x] code + tests
|
|
||||||
- [x] local gates green · [ ] independent code review · [ ] PR opened
|
|
||||||
- [ ] CI terminal green · [ ] merged to main · [ ] issue closed
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
# Wrapper hardening fold-in: #559 (eval removal) + #560 (host-derived login)
|
|
||||||
|
|
||||||
**Branch:** `fix/wrapper-hardening-tls-credpath-cicwait` (PR #551)
|
|
||||||
**Worker:** coderlite0 (Sonnet lane) · coordinated by mos-claude
|
|
||||||
**Date:** 2026-06-20
|
|
||||||
**Scope:** `packages/mosaic/framework/tools/git/*.sh` only
|
|
||||||
|
|
||||||
## What the issues asked for vs. what was already landed
|
|
||||||
|
|
||||||
Both issues were largely satisfied by prior merged work; this fold-in closes the
|
|
||||||
remaining gaps (regression tests + a loud diagnostic + one residual word-split site)
|
|
||||||
rather than re-implementing finished functionality.
|
|
||||||
|
|
||||||
### #559 — remove `eval` from issue-create.sh (and siblings)
|
|
||||||
|
|
||||||
- `eval`-based command construction was already removed across the wrapper surface
|
|
||||||
(landed in #549). A full scan of `tools/git/*.sh` finds **zero** `eval` usages.
|
|
||||||
- `issue-create.sh`, `pr-create.sh`, `issue-edit.sh`, `issue-assign.sh` already build
|
|
||||||
their `tea`/`gh` invocations as argv arrays (`CMD=(...)`, `"${CMD[@]}"`), so Markdown
|
|
||||||
bodies pass through verbatim.
|
|
||||||
- **Residual found & fixed:** `issue-comment.sh` still used unquoted
|
|
||||||
`$(get_gitea_repo_args)` word-splitting (the comment body itself was already safely
|
|
||||||
quoted, so no injection bug — but it was the inconsistent, fragile pattern #559 targets,
|
|
||||||
and it failed silently when no login resolved). Converted to an argv array with an
|
|
||||||
explicit, loud login-resolution error.
|
|
||||||
- **Added regression test:** `test-issue-create-body-safety.sh` — feeds a hostile
|
|
||||||
Markdown body (`$(touch SENTINEL)`, backticks, single/double quotes, `$HOME`/`${PATH}`,
|
|
||||||
pipes/`&&`/`;`) through `issue-create.sh` and asserts (1) no command substitution
|
|
||||||
executes (sentinel file never created) and (2) the `--description` `tea` receives is
|
|
||||||
byte-for-byte the original body.
|
|
||||||
|
|
||||||
### #560 — auto-detect Gitea `--login` from repo origin host
|
|
||||||
|
|
||||||
- Centralized host→login resolution already exists in `detect-platform.sh`
|
|
||||||
(`get_gitea_login_for_host` → `find_tea_login_for_host`, matching `urlparse(url).hostname`).
|
|
||||||
Every wrapper routes through it (or `get_gitea_login` / `get_gitea_login_for_repo_override`);
|
|
||||||
**no wrapper hardcodes `${GITEA_LOGIN:-mosaicstack}`**. Explicit `GITEA_LOGIN` wins only
|
|
||||||
when it matches the host (`tea_login_matches_host`), so stale overrides are rejected.
|
|
||||||
- **Gap fixed — silent failure → loud diagnostic:** the failure path of
|
|
||||||
`get_gitea_login_for_host` returned non-zero with no message. Added
|
|
||||||
`print_gitea_login_diagnostic`, emitted to **stderr** on resolution failure: names the
|
|
||||||
unresolved host, lists available tea logins (name + host), and gives the `GITEA_LOGIN`
|
|
||||||
override + `tea login add` fix. Stderr-only, so it never contaminates stdout (the
|
|
||||||
resolved login name) or the log-grep assertions in the existing harnesses. Callers with
|
|
||||||
an API fallback (pr-merge, issue-close, pr-create, issue-create) still follow with their
|
|
||||||
own "using API fallback" line, giving a clear "no login → fallback" trail.
|
|
||||||
- **Extended test:** `test-gitea-login-resolution.sh` now also asserts (a) the loud
|
|
||||||
diagnostic fires and lists available logins for an unresolved host, (b) login is derived
|
|
||||||
from origin host for **both** instances (mosaicstack + usc) via a scoped second `tea`
|
|
||||||
mock, and (c) a valid `GITEA_LOGIN` override is honored. The scoped mock keeps the
|
|
||||||
existing API-fallback assertions (which require mosaicstack to have _no_ tea login) valid.
|
|
||||||
|
|
||||||
## Files changed (wrapper surface only)
|
|
||||||
|
|
||||||
- `detect-platform.sh` — add `print_gitea_login_diagnostic`; call it on the
|
|
||||||
`get_gitea_login_for_host` failure path.
|
|
||||||
- `issue-comment.sh` — argv array + loud login-resolution error (was unquoted
|
|
||||||
`$(get_gitea_repo_args)`).
|
|
||||||
- `test-issue-create-body-safety.sh` — **new** (#559 regression).
|
|
||||||
- `test-gitea-login-resolution.sh` — extended (#560 diagnostic + both-host + override).
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
All wrapper harnesses pass locally:
|
|
||||||
|
|
||||||
- `test-issue-create-body-safety.sh` — PASS
|
|
||||||
- `test-gitea-login-resolution.sh` — PASS
|
|
||||||
- `test-pr-merge-gitea-empty-uid.sh` — PASS
|
|
||||||
- `test-pr-metadata-gitea.sh` — PASS
|
|
||||||
- `test-lane-brief-pr-linkage.sh` — PASS
|
|
||||||
|
|
||||||
## Open items flagged to mos-claude (orchestrator decisions)
|
|
||||||
|
|
||||||
1. **CHANGELOG absent.** The task said "update CHANGELOG (append-only), keep the existing
|
|
||||||
#550/#551 entry." No CHANGELOG file exists anywhere in the repo, and #550/#551 are not
|
|
||||||
recorded in one. **ASSUMPTION:** documenting #559/#560 in this scratchpad + the PR
|
|
||||||
description (`Closes #559 Closes #560`) follows the repo's actual convention
|
|
||||||
(`docs/scratchpads/`). Did not invent a new CHANGELOG structure.
|
|
||||||
2. **`docs/TASKS.md` is orchestrator single-writer.** It carries a "Workers read but never
|
|
||||||
modify" banner. As a worker I did **not** edit it; task tracking is via the linked Gitea
|
|
||||||
issues #559/#560 + this scratchpad. Orchestrator may add a rollup row if desired.
|
|
||||||
3. **Wrapper `test-*.sh` are not CI-wired.** `.woodpecker/ci.yml` runs `pnpm
|
|
||||||
typecheck/lint/format:check/test` (`turbo run test`); the framework dir has no
|
|
||||||
`package.json`, so these shell harnesses run **locally/manually only** — they do not gate
|
|
||||||
the PR in Woodpecker. **ASSUMPTION:** out of scope to wire a shell-test step into CI in
|
|
||||||
this PR (would broaden the diff beyond the wrapper surface). Flagging for a follow-up if
|
|
||||||
the fleet wants these gated.
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# Issue #561 — Bare python on agent hosts
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Make the durable bootstrap/provisioning guidance ensure agent hosts provide a bare `python` command that resolves to Python 3.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Add Debian/Ubuntu `python-is-python3` to agent-host prerequisites in bootstrap docs.
|
|
||||||
- Check for actual OS package provisioning scripts and update only if an existing agent-host package install path exists.
|
|
||||||
- Do not touch live host state.
|
|
||||||
- Do not update `docs/TASKS.md`; repo guidance says workers read it but never modify it.
|
|
||||||
|
|
||||||
## Recon
|
|
||||||
|
|
||||||
- Issue #561 confirms repeated `python: command not found` failures from fleet agents that emit `python foo.py`.
|
|
||||||
- `guides/BOOTSTRAP.md` and `packages/mosaic/framework/guides/BOOTSTRAP.md` are the source and packaged framework copies of the bootstrap guide.
|
|
||||||
- Targeted repo sweep found no agent-host Debian package provisioning script. Existing `apt-get install` hits are CI/test helper paths or unrelated deployment docs.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Add a host prerequisite section to both bootstrap guide copies.
|
|
||||||
2. Include `python-is-python3` in the Debian/Ubuntu package list with an issue comment.
|
|
||||||
3. Note the non-Debian equivalent as a `/usr/bin/python -> python3` symlink.
|
|
||||||
4. Validate markdown/diff, run shell syntax checks where applicable, run required review, commit, queue guard, and push.
|
|
||||||
|
|
||||||
## Validation Log
|
|
||||||
|
|
||||||
- `rg` recon: no existing agent-host Debian package provisioning script; only CI/test helper `apt-get install` paths and unrelated deployment docs.
|
|
||||||
- `git diff --check`: passed.
|
|
||||||
- `bash -n packages/mosaic/framework/install.sh tools/install.sh packages/mosaic/framework/tools/bootstrap/init-project.sh packages/mosaic/framework/tools/_scripts/mosaic-bootstrap-repo`: passed. No touched shell scripts.
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted`: approved, 0 findings.
|
|
||||||
- `pnpm format:check`: initially blocked because `node_modules` was absent and `prettier` was unavailable; `pnpm install --frozen-lockfile` initially hit an invalid `/root` pnpm store path. Reran install with `--store-dir /home/hermes/agent-work/.pnpm-store`, then `pnpm format:check` passed.
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# #631 — re-seed must preserve user fleet data (CRITICAL data-loss)
|
|
||||||
|
|
||||||
- **Issue:** #631 · **Branch:** `fix/631-reseed-preserves-fleet-data`
|
|
||||||
|
|
||||||
## Root cause
|
|
||||||
|
|
||||||
`mosaic update` auto-runs `install.sh` keep-mode sync (#610). install.sh's rsync `--delete` (keep mode)
|
|
||||||
honored PRESERVE_PATHS, but `fleet/` wasn't listed → the sync WIPED `~/.config/mosaic/fleet/roster.yaml`
|
|
||||||
(+ run/, agents/). Any user running `mosaic update` lost their roster. (overwrite mode wipes by design;
|
|
||||||
the live loss was keep mode.)
|
|
||||||
|
|
||||||
## Fix (PRIMARY)
|
|
||||||
|
|
||||||
- install.sh PRESERVE_PATHS += `fleet/*.yaml`, `fleet/agents`, `fleet/run` — the framework still SEEDS
|
|
||||||
fleet/examples + fleet/roles + fleet/roster.schema.json (synced), but user files survive.
|
|
||||||
- Made the cp-fallback (no-rsync) GLOB-AWARE so `fleet/*.yaml` preserves every user roster there too;
|
|
||||||
fixed the restore to re-glob per-pattern (so only the user file is restored, not the whole fleet/ dir).
|
|
||||||
- file-adapter.ts (TS installer): mirrored the preserve list for parity. (TS syncDirectory is copy-only,
|
|
||||||
never --delete, so it never had the bug — belt-and-suspenders + parity.)
|
|
||||||
|
|
||||||
## Fix (SECONDARY)
|
|
||||||
|
|
||||||
- `refreshActiveFleetUnits()` (update-checker.ts): the re-seed updates ~/.config/mosaic/systemd/user but
|
|
||||||
systemd runs ~/.config/systemd/user, so unit fixes (#627) didn't take effect. After the re-seed,
|
|
||||||
`mosaic update` now copies the fresh mosaic-\*.service → the active dir + daemon-reload (best-effort,
|
|
||||||
only when a fleet is already installed). Wired into the cli.ts update flow.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- bash F6 fixture (6 checks: roster/custom-yaml/agents/run survive + examples refreshed + schema seeded);
|
|
||||||
20/20 migration matrix green. TS file-adapter test (roster/run/agents survive keep sync). 2 unit tests
|
|
||||||
for refreshActiveFleetUnits. tsc/eslint/prettier/sanitize clean.
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# #633 — comms-block emitter + FLEET-LAUNCH runbook
|
|
||||||
|
|
||||||
Branch: `feat/633-comms-block-runbook` (off `bf2a6745`, post-#632 merge)
|
|
||||||
Issue: #633 · Follow-up filed: #636 (PATH B)
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
PATH A of the orchestrator-launch fix: give every launch path the Fleet-Comms onboarding, and
|
|
||||||
document the canonical roster-driven launcher so the orchestrator stops being a bespoke snowflake.
|
|
||||||
|
|
||||||
## Deliverables
|
|
||||||
|
|
||||||
1. **`mosaic fleet comms-block <role> [--host <h>]`** — explicit-arg, comms-block-only emitter.
|
|
||||||
- Backed by new `resolveCommsBlock(mosaicHome, role, fleetHost?)` in `fleet/comms-onboarding.ts`
|
|
||||||
returning `{ ok, output, error }`.
|
|
||||||
- Unlike `readFleetCommsBlock` (returns `''` on any miss so `composeContract` can no-op silently
|
|
||||||
during launch), the emitter **fails loud**: unknown role / missing roster → `ok:false` → CLI
|
|
||||||
prints to stderr + sets `process.exitCode = 1`. A typo is never a silent no-op.
|
|
||||||
- Distinct from `mosaic compose-contract <runtime>` (whole prompt, env-coupled via
|
|
||||||
`MOSAIC_AGENT_NAME`); comms-block is the targeted, explicit-arg, comms-only view.
|
|
||||||
2. **`docs/fleet/FLEET-LAUNCH.md`** — worker path + orchestrator `.env` fold + 3 launch gotchas +
|
|
||||||
#632 preserve note + North-Star 4-field arc.
|
|
||||||
|
|
||||||
## Key findings (drove the design)
|
|
||||||
|
|
||||||
- `mosaic yolo claude` **already** forwards `--channels`/`--permission-mode` to the binary
|
|
||||||
(`launch.ts` claude case `cliArgs.push(...args)`) AND injects the comms block via
|
|
||||||
`composeContract` → `readFleetCommsBlock(home, env.MOSAIC_AGENT_NAME)`. So no `launch.ts` change
|
|
||||||
was needed — PATH A is `.env` + doc only.
|
|
||||||
- `start-agent-session.sh` line ~41 `[ -z "$MOSAIC_AGENT_COMMAND" ]` short-circuits the line-44
|
|
||||||
default, so an `.env` `MOSAIC_AGENT_COMMAND` override bypasses the hardcoded `yolo` entirely — the
|
|
||||||
yolo-conditional is therefore a PATH B (default-path) concern, not PATH A.
|
|
||||||
- `generateAgentEnv` (`fleet.ts` ~202-207) emits NAME/RUNTIME/MODEL but **not** `MOSAIC_AGENT_COMMAND`
|
|
||||||
— the seam PATH B (#636) closes.
|
|
||||||
|
|
||||||
## A → B → webUI arc (North Star)
|
|
||||||
|
|
||||||
- A = `.env` `MOSAIC_AGENT_COMMAND` hatch (manual, ships now, #632-safe).
|
|
||||||
- B (#636) = roster-native launch-config: harness ✅ + model ✅ already there; add **yolo** (line-44
|
|
||||||
conditional `MOSAIC_AGENT_YOLO`) + **command/channels** (`generateAgentEnv` emission).
|
|
||||||
- webUI binds dropdowns/toggles to those four roster fields. One launcher, no new launch path.
|
|
||||||
|
|
||||||
## Results
|
|
||||||
|
|
||||||
- TDD: spec first (`comms-onboarding.spec.ts`, 6 new `resolveCommsBlock` cases) → red → implement → green.
|
|
||||||
- `fleet.spec.ts` subcommand-list assertion extended with `comms-block`.
|
|
||||||
- 177 fleet+comms tests green; typecheck clean; eslint clean; prettier clean.
|
|
||||||
|
|
||||||
## Risks / notes
|
|
||||||
|
|
||||||
- Pre-existing local-only failure `uninstall.spec.ts > removeFramework > handles missing mosaicHome
|
|
||||||
gracefully` (EACCES on `/nonexistent` as non-root) — unrelated to #633, passes in CI as root.
|
|
||||||
- Did NOT run `mosaic update` / anything auto-reseed: installed CLI still 0.0.40 (roster-wipe live
|
|
||||||
until mos-claude-0 ships 0.0.41). All work is in-repo + vitest, never touches the live mosaic home.
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# Scratchpad — fleet-personas spec timeout
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Raise the `@mosaicstack/mosaic` Vitest timeout to 30s at config level so filesystem-backed fleet drift-guard specs (`fleet-personas`, `fleet-profiles`, and siblings) stop false-reding under contended CI.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Move timeout policy into `packages/mosaic/vitest.config.ts` with `testTimeout: 30_000`.
|
|
||||||
2. Remove the narrower `fleet-personas.spec.ts` local override so PR #677 fixes the suite class, not one file.
|
|
||||||
3. Run targeted fleet specs plus typecheck/lint/format gates.
|
|
||||||
4. Commit, queue guard, push, PR update.
|
|
||||||
|
|
||||||
## Evidence
|
|
||||||
|
|
||||||
- `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet-personas.spec.ts` — pass (8 tests; initial narrow fix).
|
|
||||||
- `pnpm typecheck` — pass (41 tasks; initial narrow fix).
|
|
||||||
- `pnpm lint` — pass (23 tasks; initial narrow fix).
|
|
||||||
- `pnpm format:check` — pass after formatting this scratchpad (initial narrow fix).
|
|
||||||
- Package-wide timeout follow-up:
|
|
||||||
- `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet-personas.spec.ts src/commands/fleet-profiles.spec.ts` — pass (24 tests).
|
|
||||||
- `pnpm --filter @mosaicstack/mosaic test` — pass (44 files / 618 tests).
|
|
||||||
- `pnpm typecheck` — pass (41 tasks).
|
|
||||||
- `pnpm lint` — pass (23 tasks).
|
|
||||||
- `pnpm format:check` — pass.
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# FED-M3-05 — Federation List Verb Scratchpad
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Implement `POST /api/federation/v1/list/:resource`.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Wire `FederationAuthGuard` → `FederationScopeService` → read-only list query layer.
|
|
||||||
- Apply `max_rows_per_query` row cap and return pagination metadata when truncated.
|
|
||||||
- Tag returned rows with `_source: "local"`.
|
|
||||||
- Keep audit writes deferred to M4.
|
|
||||||
- No request/response body persistence.
|
|
||||||
|
|
||||||
## Base / branch
|
|
||||||
|
|
||||||
- Branch: `feat/federation-m3-verb-list`
|
|
||||||
- Base: `main` after M3-04 scope service merged via PR #672 (`c739256a`).
|
|
||||||
|
|
||||||
## Implementation notes
|
|
||||||
|
|
||||||
- Added `ListController` under `apps/gateway/src/federation/server/verbs/`.
|
|
||||||
- Added `FederationListQueryService` as the read-only query layer and native RBAC evaluator.
|
|
||||||
- Query resources supported in M3 list path:
|
|
||||||
- `tasks`: project/mission scoped tasks visible through personal/team project access.
|
|
||||||
- `notes`: non-empty `mission_tasks.notes` rows visible through personal/team mission access.
|
|
||||||
- `memory`: user-owned `insights` and `preferences` rows.
|
|
||||||
- `credentials` / `api_keys`: denied by native RBAC in M3 even if present in scope; sensitive-resource implementation is not part of FED-M3-05.
|
|
||||||
- Cursor pagination uses an opaque base64url keyset cursor over `(createdAt, id)`; DB reads fetch at most `limit + 1` rows per resource query.
|
|
||||||
- Reviewer isolation fix: `mission_tasks.notes` rows are always constrained by `missionTasks.userId = subjectUserId` and accessible mission IDs; team scope narrows missions but never widens to other users' mission task notes.
|
|
||||||
- Follow-up review fix: memory listing now uses deterministic table-block pagination (`insights` first, then `preferences`) with cursor source metadata, so one table's cursor is never applied to the other.
|
|
||||||
- Follow-up hardening: missing auth-guard context returns a structured federation `unauthorized` envelope; unsupported resources and non-encodable truncated cursors throw instead of silently crashing/truncating.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
- `pnpm --filter @mosaicstack/gateway test -- list.controller.spec.ts list-query.service.spec.ts` — PASS (16 tests, including PGlite regression coverage for team-scoped notes isolation, unauthorized mission notes exclusion, `includePersonal: false`, deterministic memory pagination, missing context envelope, unsupported resource, and cursor encode failure).
|
|
||||||
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
|
|
||||||
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
|
|
||||||
- `pnpm format:check` — PASS.
|
|
||||||
- `pnpm typecheck` — PASS (41/41 turbo tasks).
|
|
||||||
- `pnpm lint` — PASS (23/23 turbo tasks).
|
|
||||||
- `pnpm --filter @mosaicstack/gateway test` — FAIL in pre-existing/live-DB integration suite: `apps/gateway/src/__tests__/cross-user-isolation.test.ts` cleanup cannot connect to local PostgreSQL on `localhost:5433`. New list tests pass; failure is outside FED-M3-05.
|
|
||||||
|
|
||||||
## Review evidence
|
|
||||||
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS after follow-up remediation; approve, no findings.
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS after follow-up remediation; risk level none, no findings.
|
|
||||||
- Security-review note: read-path audit logging remains intentionally deferred to M4 per orchestrator clarification and FED-M3-05 scope.
|
|
||||||
|
|
||||||
## Risks / follow-up
|
|
||||||
|
|
||||||
- Read-path audit logging remains intentionally deferred to M4.
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
# FED-M3-07 — Capabilities Verb Scratchpad
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Implement `GET /api/federation/v1/capabilities` in `apps/gateway/src/federation/server/verbs/capabilities.controller.ts`.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Add read-only capabilities controller under federation server verbs.
|
|
||||||
- Use `FederationAuthGuard` only; active grant is sufficient and no native RBAC/scope-service eval runs.
|
|
||||||
- Response shape: `{ resources, excluded_resources, max_rows_per_query, supported_verbs }` derived from grant scope.
|
|
||||||
- Register controller in `FederationModule`.
|
|
||||||
- Unit-test happy path, defaults, no-context guard seam, and invalid scope handling.
|
|
||||||
|
|
||||||
## Constraints / assumptions
|
|
||||||
|
|
||||||
- Issue: #462.
|
|
||||||
- Branch: `feat/federation-m3-verb-capabilities` from `origin/main` (`3eeed04e`).
|
|
||||||
- Depends on M3-03 auth guard; guard attaches `request.federationContext.scope` after active-grant validation.
|
|
||||||
- ASSUMPTION: `supported_verbs` is the M3 verb set from `@mosaicstack/types` (`list`, `get`, `capabilities`).
|
|
||||||
- ASSUMPTION: `filters`/`rate_limit` are intentionally omitted for FED-M3-07 because the card’s response shape lists only the four required fields.
|
|
||||||
- Budget: no explicit hard cap from orchestrator; working cap ~4K-8K tokens for card implementation + tests + PR cycle.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Write controller unit tests first.
|
|
||||||
2. Implement controller and module registration.
|
|
||||||
3. Run scoped tests + typecheck/lint/format.
|
|
||||||
4. Run Codex code/security review and remediate.
|
|
||||||
5. Commit, queue guard, push, PR via wrapper.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- 2026-06-24: Intake complete; fresh worktree created from origin/main.
|
|
||||||
- 2026-06-24: Added `CapabilitiesController`, registered it in `FederationModule`, and added 5 unit tests.
|
|
||||||
- 2026-06-24: Code/security reviews passed with no findings.
|
|
||||||
|
|
||||||
## Tests run
|
|
||||||
|
|
||||||
- `pnpm --filter @mosaicstack/gateway test -- capabilities.controller.spec.ts` — PASS (5 tests).
|
|
||||||
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
|
|
||||||
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
|
|
||||||
- `pnpm format:check` — PASS.
|
|
||||||
- `pnpm typecheck` — PASS (41/41 turbo tasks).
|
|
||||||
- `pnpm lint` — PASS (23/23 turbo tasks).
|
|
||||||
- `pnpm test` — FAIL in pre-existing/live-DB integration suite: `apps/gateway/src/__tests__/cross-user-isolation.test.ts` cleanup hit PostgreSQL connection/schema state for the `messages` table. Changed capabilities tests passed; failure is outside FED-M3-07 surface. No `fleet-personas.spec` flake encountered.
|
|
||||||
|
|
||||||
## Review evidence
|
|
||||||
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS/approve, no findings.
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS, risk level none, no findings.
|
|
||||||
|
|
||||||
## Risks / blockers
|
|
||||||
|
|
||||||
- Full repo `pnpm test` may hit known `fleet-personas.spec` flake per orchestrator; ignore that specific flake if encountered.
|
|
||||||
- Previous card saw local DB schema issue in `cross-user-isolation.test.ts`; scoped capabilities tests should be authoritative for this surface.
|
|
||||||
|
|
||||||
## Acceptance evidence mapping
|
|
||||||
|
|
||||||
| Acceptance criterion | Evidence |
|
|
||||||
| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| GET `/api/federation/v1/capabilities` exists | Route metadata test in `capabilities.controller.spec.ts`; scoped test PASS |
|
|
||||||
| Uses active-grant auth guard and no RBAC eval | Guard metadata test confirms only `FederationAuthGuard`; controller has no service injections/RBAC calls; scoped test PASS |
|
|
||||||
| Response enumerates resources/excluded/max rows/supported verbs from scope | Happy-path/default scope tests + response schema parse; scoped test PASS |
|
|
||||||
| Read-only/no persistence side effects | Controller only parses request `federationContext.scope` and returns a DTO; no DB/service dependency; code review PASS |
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# FED-M3-09 — Query Source Service Scratchpad
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Implement `apps/gateway/src/federation/client/query-source.service.ts` for `source: "local" | "federated:<host>" | "all"` routing.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Add QuerySourceService in gateway federation client layer.
|
|
||||||
- Unit-test local-only, single federated peer, all-source fan-out/merge, and per-peer partial failures.
|
|
||||||
- Keep `docs/federation/TASKS.md` read-only per project agent guidance.
|
|
||||||
|
|
||||||
## Constraints / assumptions
|
|
||||||
|
|
||||||
- Issue: #462.
|
|
||||||
- Branch: `feat/federation-m3-query-source` from `origin/main` (`e0e7be70`).
|
|
||||||
- ASSUMPTION: `federated:<host>` should match active outbound peers by `commonName` first and by `endpointUrl` host/hostname as compatibility fallback; source tags use `peer.commonName` per `@mosaicstack/types` source-tag docs.
|
|
||||||
- ASSUMPTION: QuerySourceService provides list/fan-out behavior; get/source routing can be layered later because card acceptance says merge rows.
|
|
||||||
- ASSUMPTION: `source: "all"` cannot safely return a single continuation cursor for multiple sub-sources; any subquery cursor marks the merged response `_partial: true` + `_truncated: true` while omitting `nextCursor`.
|
|
||||||
- Budget: no explicit hard cap from orchestrator; working cap ~8K-12K tokens for card 1 implementation + tests + PR cycle.
|
|
||||||
- OpenBrain unavailable: credential loader failed with missing `/home/jarvis/.config/mosaic/credentials.json`; not blocking code delivery.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Review federation client/types/db patterns.
|
|
||||||
2. Write unit tests for source behavior.
|
|
||||||
3. Implement QuerySourceService and export/register it in FederationModule.
|
|
||||||
4. Run scoped tests, typecheck, lint, format.
|
|
||||||
5. Run codex uncommitted review and remediate.
|
|
||||||
6. Commit, queue guard, push, PR via wrapper.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- 2026-06-24: Intake complete; using isolated worktree to avoid dirty orchestrator files in original checkout.
|
|
||||||
- 2026-06-24: Added QuerySourceService, module export, barrel export, and 7 unit tests.
|
|
||||||
- 2026-06-24: First Codex review found pagination and port-host matching issues; both remediated with tests.
|
|
||||||
|
|
||||||
## Tests run
|
|
||||||
|
|
||||||
- `pnpm --filter @mosaicstack/gateway test -- query-source.service.spec.ts` — PASS (7 tests).
|
|
||||||
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
|
|
||||||
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
|
|
||||||
- `pnpm format:check` — PASS.
|
|
||||||
- `pnpm typecheck` — PASS (41/41 turbo tasks).
|
|
||||||
- `pnpm lint` — PASS (23/23 turbo tasks).
|
|
||||||
- `pnpm test` — FAIL in pre-existing/live-DB integration suite: `apps/gateway/src/__tests__/cross-user-isolation.test.ts` cleanup hit `relation "messages" does not exist` against local PostgreSQL. Changed QuerySource unit tests passed; failure is outside FED-M3-09 surface and appears tied to local DB schema state.
|
|
||||||
|
|
||||||
## Review evidence
|
|
||||||
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — first pass request-changes, 2 should-fix findings (all-source cursor handling; endpoint port host matching).
|
|
||||||
- Remediation: `_partial` + `_truncated` when any all-source subquery has `nextCursor`; endpoint match accepts URL `host` and `hostname`; added tests for both.
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS/approve, no findings.
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS, risk level none, no findings.
|
|
||||||
|
|
||||||
## Risks / blockers
|
|
||||||
|
|
||||||
- Federation query layer is not yet wired; service API needs to be stable and easy to compose.
|
|
||||||
- Must avoid hard-failing `source: all` on remote peer failures.
|
|
||||||
|
|
||||||
## Acceptance evidence mapping
|
|
||||||
|
|
||||||
| Acceptance criterion | Evidence |
|
|
||||||
| ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
|
||||||
| local source returns local rows tagged `_source: local` | `query-source.service.spec.ts` local test; scoped test PASS |
|
|
||||||
| `federated:<host>` queries selected peer and tags rows with peer source | `query-source.service.spec.ts` commonName/endpoint-host tests; scoped test PASS |
|
|
||||||
| `all` fans out local + active outbound peers in parallel and merges tagged rows | `query-source.service.spec.ts` all-source call-order/merge test; scoped test PASS |
|
|
||||||
| per-peer failure on `all` returns `_partial: true`, not throw | `query-source.service.spec.ts` peer failure test; scoped test PASS |
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# F3-m3 — `mosaic update` re-seeds framework + relaunches agents (R13)
|
|
||||||
|
|
||||||
- **Issue:** #609 · **Branch:** `feat/f3-m3-update-reseed`
|
|
||||||
|
|
||||||
## Gap (found in 0.0.39 production validation)
|
|
||||||
|
|
||||||
`mosaic update` installs the new npm CLI but never re-seeds `~/.config/mosaic/` from the package's
|
|
||||||
bundled `framework/`. So the shipped custom Pi harness (agent-name export + native HB, 0.0.39) stays
|
|
||||||
DORMANT until a re-seed — operators get the new CLI on a stale framework.
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
- `update-checker.ts`: `resolveBundledFrameworkRoot()`, `buildReseedCommand()` (install.sh in
|
|
||||||
`MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep` — the P4 data-safe reconcile), `runFrameworkReseed()`,
|
|
||||||
`readRosterAgentNames()`, `buildRelaunchCommands()` (systemctl --user restart per agent).
|
|
||||||
- `cli.ts` `update`: after a successful CLI install that includes `@mosaicstack/mosaic`, re-seed the
|
|
||||||
framework (default-on; `--no-reseed` to skip). Then either `--relaunch` (restart rostered agents) or
|
|
||||||
print clear guidance to run `mosaic update --relaunch` / `mosaic fleet restart`.
|
|
||||||
|
|
||||||
## Flow
|
|
||||||
|
|
||||||
`update CLI → re-seed framework (data-safe) → relaunch agents (opt-in)` — closes R13, activates the
|
|
||||||
native harness for every operator.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- 6 new unit tests (reseed command/env, relaunch commands, roster parse, missing-installer guard).
|
|
||||||
- 19 runtime + 26 launch tests still green; tsc/eslint/prettier clean.
|
|
||||||
- Data-safety of the sync is already proven (P4 5-fixture matrix + live dragon-lin validation).
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# F4 — Orchestrator chat connector + Matrix (#616)
|
|
||||||
|
|
||||||
- **Issue:** #616 · **Branch:** `feat/f4-matrix-connector` (off main; independent of #615) · **Doctrine:** north-star #613.
|
|
||||||
|
|
||||||
## Phase 1 (this PR) — abstraction + scaffold
|
|
||||||
|
|
||||||
- `src/fleet/connectors/types.ts`: `OrchestratorConnector` (send/subscribe/health) + message/config types; thread-aware via optional `threadId`; `DEFAULT_CONNECTOR_KIND=tmux`.
|
|
||||||
- `src/fleet/connectors/registry.ts`: extensible factory registry; `resolveConnectorKind` (defaults tmux, back-compat); `createConnector` throws `ConnectorNotImplementedError` until Phase 2 registers factories.
|
|
||||||
- `roster.schema.json`: optional `connector` block (tmux|discord|matrix; matrix homeserver/user/room; secrets via env, never roster).
|
|
||||||
- Design doc `docs/fleet/f4-matrix-connector.md`: interface, config, Matrix CS-API mapping, Conduit-default infra, phasing.
|
|
||||||
- **No fleet.ts changes** → self-contained, zero conflict with stacked #615.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- 7 connector tests green; tsc/eslint/prettier/sanitize clean; schema valid JSON.
|
|
||||||
|
|
||||||
## Phase 2+ (follow-ups, in the doc)
|
|
||||||
|
|
||||||
Matrix CS-API client (fetch send/sync/health) + factory; init/configure connector-selection UX + roster-parse wiring; systemd launch wiring; Conduit deploy guide; first-party Mosaic Discord (threads) as a connector.
|
|
||||||
|
|
||||||
## Phase 2a (feat/f4-matrix-client, stacked on #617) — Matrix CS-API client
|
|
||||||
|
|
||||||
- `src/fleet/connectors/matrix.ts`: `MatrixConnector implements OrchestratorConnector` over the Matrix
|
|
||||||
client-server API (injectable fetch, no SDK). `send` → PUT m.room.message (thread-aware); `subscribe`
|
|
||||||
→ /sync long-poll loop using the pure `parseSyncResponse`; `health` → /versions + /whoami.
|
|
||||||
`registerMatrixConnector(env)` registers the factory (token from MATRIX_ACCESS_TOKEN, never roster).
|
|
||||||
- Pure helpers `buildMessageBody` + `parseSyncResponse` make send/receive unit-testable.
|
|
||||||
- 13 Matrix tests + 7 registry = 20 connector tests green; tsc/eslint/prettier clean.
|
|
||||||
- Remaining Phase 2: init/configure connector-selection UX + roster-parse wiring (touches fleet.ts —
|
|
||||||
after #615); systemd launch wiring; Conduit deploy guide.
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# Fleet CLI Local Canary Review Fixes
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Fix only the two should-fix code review findings:
|
|
||||||
|
|
||||||
1. Ensure `@mosaicstack/mosaic` declares `yaml` and lockfile state is current.
|
|
||||||
2. Validate `mosaic agent status [agent]` against the fleet roster before constructing/running the tmux target.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
|
|
||||||
- Do not modify `docs/TASKS.md`.
|
|
||||||
- Leave changes uncommitted.
|
|
||||||
- Run requested formatting and quality gates.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Inspect manifest/lockfile state for `yaml`.
|
|
||||||
2. Add failing regression test for `mosaic agent status typo`.
|
|
||||||
3. Patch `registerFleetAgentCommands` status validation.
|
|
||||||
4. Format touched files.
|
|
||||||
5. Run requested tests, typecheck, and lint.
|
|
||||||
6. Review final diff.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- Loaded required repo/global/runtime instructions.
|
|
||||||
- Confirmed `packages/mosaic/package.json` already declares `yaml`.
|
|
||||||
- Confirmed `pnpm-lock.yaml` already has `packages/mosaic` importer entry for `yaml`.
|
|
||||||
- Found `registerFleetAgentCommands` status path does not validate agent before building tmux target.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- TDD red check: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts`
|
|
||||||
failed before the production fix because `mosaic agent status typo` resolved instead of
|
|
||||||
rejecting.
|
|
||||||
- Focused green check: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts`
|
|
||||||
passed after adding roster validation.
|
|
||||||
- Formatting: `pnpm exec prettier --write packages/mosaic/src/commands/fleet.ts packages/mosaic/src/commands/fleet.spec.ts docs/scratchpads/fleet-cli-local-canary-review-fixes.md`
|
|
||||||
completed with all files unchanged.
|
|
||||||
- Requested tests: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts src/cli-smoke.spec.ts`
|
|
||||||
passed with 36 tests.
|
|
||||||
- Baseline typecheck: `pnpm typecheck` passed.
|
|
||||||
- Baseline lint: `pnpm lint` passed.
|
|
||||||
- Independent review: `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted`
|
|
||||||
returned approve with 0 findings. Note: reviewer reported broader context inspection was limited
|
|
||||||
by its read-only sandbox, so review was based on the supplied diff.
|
|
||||||
- `docs/TASKS.md` has no diff.
|
|
||||||
|
|
||||||
## Risks
|
|
||||||
|
|
||||||
- `docs/TASKS.md` intentionally untouched per user instruction.
|
|
||||||
- Review finding 1 required no file edit: `packages/mosaic/package.json` already declares
|
|
||||||
`yaml`, and the `packages/mosaic` importer in `pnpm-lock.yaml` already includes `yaml`.
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# Fleet onboarding-injection — comms cheat-sheet + peer roster (#620)
|
|
||||||
|
|
||||||
- **Issue:** #620 · **Branch:** `feat/fleet-comms-onboarding` (off main). Root cause of Mos's failed first send.
|
|
||||||
|
|
||||||
## What
|
|
||||||
|
|
||||||
Inject a `# Fleet Comms` block into each spawned fleet agent's system prompt (via composeContract — the
|
|
||||||
runtime-agnostic path every `mosaic yolo <runtime>` agent hits), so it boots knowing how to reach peers.
|
|
||||||
|
|
||||||
- `src/fleet/comms-onboarding.ts` (standalone, no fleet.ts coupling):
|
|
||||||
- `parseRosterAgents` (name/class/host/ssh, lenient), `renderPeerReach` (same-host `-s` vs cross-host
|
|
||||||
`-H <ssh> -s`), `buildFleetCommsBlock` (self [host:session] identity + agent-send path + peer table +
|
|
||||||
FLIP-to-reply + `agent send --verify`=ACCEPTED), `readFleetCommsBlock` (reads roster.yaml; '' if not a member).
|
|
||||||
- `composeContract` appends it only when MOSAIC_AGENT_NAME is set + the agent is in the roster.
|
|
||||||
- `roster.schema.json`: optional per-agent `host` + `ssh` (cross-host addresses; manual = pre-federation
|
|
||||||
stopgap, federation/W1 auto-discovers later).
|
|
||||||
|
|
||||||
## Acceptance criteria (Mos) — all covered
|
|
||||||
|
|
||||||
1. own [host:session] + agent-send path + peer roster ✓
|
|
||||||
2. cross-host correctness: local→`-s` (no -H); remote→`-H <ssh> -s` ✓ (concrete coder0-0@dragon-lin)
|
|
||||||
3. FLIP-the-preamble reply rule ✓
|
|
||||||
4. `agent send --verify` = ACCEPTED ✓
|
|
||||||
5. no `-L` (default socket); matches live tooling ✓
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- 10 onboarding unit tests (parse, render local/remote/fallback/equal-host, build, situational read) +
|
|
||||||
2 composeContract situational tests (injects for fleet agent w/ correct cross-host addr; no-op when
|
|
||||||
MOSAIC_AGENT_NAME unset). tsc/eslint/prettier/sanitize clean.
|
|
||||||
- Post-merge validation: Mos spawns a real w-jarvis agent → first-try reach to coder0-0@dragon-lin + a local peer.
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# Fleet enhancer role + two-agent floor (#614)
|
|
||||||
|
|
||||||
- **Issue:** #614 · **Branch:** `feat/fleet-enhancer-floor` (stacked on #612 `feat/fleet-polish-bundle`)
|
|
||||||
- **Doctrine:** `docs/fleet/north-star.md` (PR #613) — every fleet = orchestrator + enhancer minimum.
|
|
||||||
|
|
||||||
## Changes
|
|
||||||
|
|
||||||
- **Presets** (general, coding, research, hybrid): add `enhancer` (claude, `class: enhancer`,
|
|
||||||
`persistent_persona: true`) as a core always-on agent alongside the orchestrator. minimal/local-canary
|
|
||||||
unchanged.
|
|
||||||
- **fleet.ts**: `countEnhancers` helper; init guarantee extended — non-minimal profiles must yield
|
|
||||||
exactly 1 orchestrator AND >=1 enhancer (hard-fail otherwise); `removeAgentFromRoster` refuses to drop
|
|
||||||
the sole enhancer (symmetric with the sole-orchestrator guard) so the floor holds at runtime, not just init.
|
|
||||||
- **Role doc**: `framework/fleet/roles/enhancer.md` — the enhancer mandate (monitor → analyze → plan →
|
|
||||||
upgrade tools/skills/harness WITH orchestrator → file Mosaic Stack bug reports) + boundaries (does NOT
|
|
||||||
code or review).
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- 155 fleet tests green (new: countEnhancers; remove-sole-enhancer guard; remove-allows-when-another;
|
|
||||||
init two-agent-floor; every-non-minimal-preset-has-enhancer; updated preset rosters). tsc/eslint/
|
|
||||||
prettier/sanitize clean. TDD on the init guarantee + remove protection.
|
|
||||||
|
|
||||||
## Stacking
|
|
||||||
|
|
||||||
Built on #612's init-R5 code. PR shows #612 + enhancer until #612 merges; then rebase onto main → clean.
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
# Scratchpad — Fleet Phase 2: Observability (W-FLEET)
|
|
||||||
|
|
||||||
> Append-only. Mission `mvp-20260312` / workstream W-FLEET.
|
|
||||||
> Lead: Jarvis (Claude) at `W-jarvis:mos-claude-18`. Coordinating with `jwoltje@dragon-lin:coder0-0`.
|
|
||||||
|
|
||||||
## Mission prompt (2026-06-20)
|
|
||||||
|
|
||||||
Establish the north star for the Mosaic Fleet feature and prepare Phase-2 observability
|
|
||||||
for delivery. The USC tmux PoC is the proven base. Jason granted lead authority:
|
|
||||||
"The fleet is a great way to actually build the MVP — we are building the system that
|
|
||||||
builds the system." Dogfood actual agent construction + ad-hoc deployment; coordinate
|
|
||||||
with a second agent on `dragon-lin`.
|
|
||||||
|
|
||||||
## Decisions of record (with Jason, 2026-06-20)
|
|
||||||
|
|
||||||
- Agent model: config defines, session runs (gateway = definition/identity/auth; tmux = runtime).
|
|
||||||
- Tenancy: multi-tenant from the start; isolation = per-tenant Linux uid.
|
|
||||||
- Health: heartbeat required; dogfood stub implements protocol now.
|
|
||||||
- Lifecycle: hybrid (core always-on + ephemeral workers).
|
|
||||||
- Observation: read-only default, opt-in takeover.
|
|
||||||
- Multi-host: designed-for day one; control plane rides federation (W1), not a bespoke broker.
|
|
||||||
- Delivery: CLI-first, dogfood on the live stub fleet; webUI deferred to Phase 5.
|
|
||||||
- Fleet is dual-role: product AND means of production (bootstrapping the MVP).
|
|
||||||
- Code review = **dual-engine**: Claude **and** gpt-5.5/Codex, run together (Jason: the
|
|
||||||
combination produces the best results). Launch reviewers via `mosaic yolo pi` / `codex`
|
|
||||||
(proven path) or `~/.config/mosaic/tools/codex/codex-code-review.sh`. Applies to all
|
|
||||||
code-review gates incl. FLEET-OBS-008. Per Jason 2026-06-20.
|
|
||||||
- Worktree discipline: do fleet work in `~/src/mosaicstack-stack-worktrees/<branch>`, NOT
|
|
||||||
the shared main checkout — concurrent processes mutate `main` there (learned 2026-06-20).
|
|
||||||
|
|
||||||
## Environment facts (verified 2026-06-20)
|
|
||||||
|
|
||||||
- Fleet is live on `W-jarvis` (uid 1000, `jarvis`, `Linger=yes`) on tmux socket
|
|
||||||
`mosaic-fleet`: `_holder`, `canary-pi`, `dogfood-coder`, `dogfood-orchestrator`,
|
|
||||||
`dogfood-reviewer`. All panes run `~/.config/mosaic/fleet/dogfood-agent.py` (stub),
|
|
||||||
including `canary-pi` (roster says runtime=pi → **drift**).
|
|
||||||
- Holder + `mosaic-agent@*` units are `active (exited)` but `UnitFileState=disabled`
|
|
||||||
(reboot loses fleet → boot-enable gap to surface).
|
|
||||||
- Observation blocked by: isolated socket (hidden from default `tmux ls`), `capture-pane`
|
|
||||||
blank for TUIs, `attach` being read-write + resizing.
|
|
||||||
- Second agent: `jwoltje@dragon-lin`, session `coder0-0` (group `coder0`), running `node`,
|
|
||||||
default socket. ssh forward reach confirmed.
|
|
||||||
|
|
||||||
## Governance / collision-safety
|
|
||||||
|
|
||||||
- `mosaicstack-stack` has active mission `mvp-20260312` with single-writer locks on
|
|
||||||
`docs/MISSION-MANIFEST.md`, `docs/TASKS.md`, `docs/scratchpads/mvp-20260312.md`.
|
|
||||||
- This workstream touches NONE of those. All Fleet docs scoped under `docs/fleet/` +
|
|
||||||
this scratchpad. Rollup row proposed, not written.
|
|
||||||
|
|
||||||
## Session log
|
|
||||||
|
|
||||||
- 2026-06-20: Researched AI guide + fleet code + live state. Established north star with
|
|
||||||
Jason (8 forks decided). Branched `feat/fleet-observability`. Persisted
|
|
||||||
`docs/fleet/{north-star.md,PRD.md,TASKS.md}` + this scratchpad. Next: establish comms
|
|
||||||
with dragon-lin coder, commit docs, begin Phase-2 delivery (heartbeat + `fleet ps`).
|
|
||||||
- 2026-06-20 (session 2): Built Phase-2 CLI via worker (commit ab47831): `fleet ps`,
|
|
||||||
`agent watch`, `agent send --verify`, 62 tests. LIVE-verified `fleet ps` on
|
|
||||||
mosaic-fleet — correctly flagged canary-pi DRIFT + BOOT-ENABLE, tenant_id+host in JSON.
|
|
||||||
Heartbeat responder added to dogfood-agent.py (FLEET-OBS-002) — `fleet ps` HB now
|
|
||||||
`healthy` for all 4 agents.
|
|
||||||
- Coordination: dual-engine-reviewed (Claude+Codex) and merged framework PRs #572
|
|
||||||
(sanitization gate) + #575 (CONSTITUTION extraction) as Lead. Codex caught an Alpine
|
|
||||||
blocker on #572 (refuted by CI); Claude caught a CI-breaking format failure on #575.
|
|
||||||
- **FINDINGS (north-star / Phase-3 blockers):**
|
|
||||||
1. Ad-hoc `mosaic yolo {codex,pi}` via `start-agent-session.sh` DIE immediately in a
|
|
||||||
detached tmux pane (codex: "stdin is not a terminal"; pi: same). Only the python stub
|
|
||||||
survives. => Real runtimes have NEVER run durably in the fleet. Launch path (PATH/TTY
|
|
||||||
in the detached shell) must be fixed before Phase-3 real-runtime swap. `fleet ps`
|
|
||||||
caught both dead panes instantly (tool validated).
|
|
||||||
2. `MOSAIC_AGENT_NAME` (set in systemd EnvironmentFile) is NOT propagated into tmux's
|
|
||||||
global env, so agents defaulted to `unknown`. Worked around in dogfood-agent.py via
|
|
||||||
tmux session-name fallback; the systemd/tmux env handoff needs a real fix.
|
|
||||||
- Next: rebase on merged main, open Phase-2 PR, dual-engine review, merge, close
|
|
||||||
`fleet-observability-1`. Defer launch-path + env-propagation fixes to Phase 3.
|
|
||||||
- 2026-06-21 (session 3): Phase-2 PR #579 merged (3 dual-engine rounds hardened
|
|
||||||
verify+watch). Then closed the launch-path question with Jason's input — CORRECTING
|
|
||||||
earlier findings:
|
|
||||||
- The ad-hoc launch deaths were NOT a fundamental TTY blocker: (a) codex was a stale
|
|
||||||
version (Jason updated it); (b) pi was misconfigured to Claude auth (Jason removed it;
|
|
||||||
default is now Codex). The REAL durable-launch bug is **PATH**: the detached tmux
|
|
||||||
launch shell is login+non-interactive, so it misses `~/.npm-global/bin` (added only in
|
|
||||||
`~/.bashrc`) -> `mosaic: command not found` (127) -> pane dies. tmux panes inherit the
|
|
||||||
tmux _server_ env, so PATH must be baked into the pane command.
|
|
||||||
- **Durable real-agent recipe (validated live on gpt-5.5, Claude-free):**
|
|
||||||
`mosaic yolo pi --model openai-codex/gpt-5.5:high` — pi tolerates detached tmux; a raw
|
|
||||||
interactive TUI (codex CLI) exits without an attached client. Status line confirmed
|
|
||||||
`(openai-codex) gpt-5.5 • high`.
|
|
||||||
- PATH fix landed in `start-agent-session.sh` (commit 32efc13, branch
|
|
||||||
feat/fleet-launch-path): derive runtime-bin prefix (MOSAIC_RUNTIME_BIN | npm prefix |
|
|
||||||
~/.npm-global/bin | ~/.local/bin), bake `export PATH=...; exec <cmd>` into the pane;
|
|
||||||
`exec` also fixes the drift false-positive. Live-tested under stripped PATH -> durable.
|
|
||||||
- Boot-survival: Jason ran `systemctl --user enable` (+ linger). TODO: auto-enable in
|
|
||||||
**fleet init** so operators never have to remember it (agentic-enhancement cycle).
|
|
||||||
- Future custom Pi harness build: pi cannot self-report its model (track
|
|
||||||
runtime/model/effort as fleet metadata); drift detection should recognize `node` as
|
|
||||||
pi's pane command (a node-wrapped pane can currently read as drift).
|
|
||||||
- Findings recorded in AI Guide playbooks/tmux-fleet.md (aiguide PR #7, merged).
|
|
||||||
- Policy: avoid Claude outside Claude Code (API pricing for alt-harness use) — fleet
|
|
||||||
runtimes default to Codex / pi-on-Codex; Claude stays in Claude Code only.
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Fleet-polish bundle — boot-survival symmetry (#611)
|
|
||||||
|
|
||||||
- **Issue:** #611 · **Branch:** `feat/fleet-polish-bundle` · From the Lead's Codex symmetry-gap finding.
|
|
||||||
|
|
||||||
## Three fixes
|
|
||||||
|
|
||||||
1. **disable-on-remove (BUG, TDD).** `fleet remove` stopped + deleted roster/env/heartbeat but never
|
|
||||||
`systemctl --user disable mosaic-agent@NAME.service` → a removed-but-enabled unit could resurrect on
|
|
||||||
reboot pointing at deleted config. Fix: `buildSystemdDisableCommand` + disable in `remove`
|
|
||||||
(best-effort, gated on !--keep-files).
|
|
||||||
2. **add-enable.** `fleet add` now enables the new agent's unit for boot-survival (best-effort,
|
|
||||||
independent of --start) — symmetry with disable-on-remove.
|
|
||||||
3. **init-R5 guarantee.** `fleet init --write` now FAILS HARD when a non-minimal profile doesn't yield
|
|
||||||
exactly one orchestrator (was a soft warning). `minimal` (sanctioned no-orchestrator) still allowed.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- 4 new tests (disable builder; remove-invokes-disable; add-invokes-enable; init general → exactly 1
|
|
||||||
orchestrator) + 147 existing fleet tests green (151 total). tsc/eslint/prettier clean.
|
|
||||||
- TDD on the disable bug per contract.
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# Fleet stand-up fixes — model_hint→--model + socket-default trap (#626)
|
|
||||||
|
|
||||||
- **Issue:** #626 · **Branch:** `feat/fleet-standup-fixes` (off main). PoC-blocking, before doctrine doc.
|
|
||||||
|
|
||||||
## FIX 1 — model_hint consumed
|
|
||||||
|
|
||||||
- generateAgentEnv emits `MOSAIC_AGENT_MODEL=<modelHint>` (bare empty when unset).
|
|
||||||
- start-agent-session.sh default command → `mosaic yolo $RUNTIME ${MOSAIC_AGENT_MODEL:+--model $MOSAIC_AGENT_MODEL}`.
|
|
||||||
→ pi workers launch with `--model openai-codex/gpt-5.5:high`.
|
|
||||||
|
|
||||||
## FIX 2 — socket default trap (absent ⇒ literal default socket, no -L everywhere)
|
|
||||||
|
|
||||||
- THE TRAP (3 sites): parseRosterText fallback was DEFAULT_SOCKET_NAME; systemd unit had
|
|
||||||
`Environment=MOSAIC_TMUX_SOCKET=mosaic-fleet` + `ExecStop ${…:-mosaic-fleet}`; start-agent-session
|
|
||||||
defaulted `:-mosaic-fleet`. All fixed → absent socket = '' = default tmux socket (no -L).
|
|
||||||
- `socketArgs(name)` helper → `name ? ['-L', name] : []`; replaced all ~15 -L render sites in fleet.ts.
|
|
||||||
- shellEnvValue('') now emits a **bare** `VAR=` (not `''`) — unambiguous empty in systemd EnvironmentFile
|
|
||||||
(a quoted '' could become a literal socket named "''").
|
|
||||||
- start-agent-session.sh: `_tmux` wrapper passes -L only when socket set; mosaic-agent@.service: dropped the
|
|
||||||
socket default + conditional ExecStop. So spawn == observe == onboarding cheat-sheet.
|
|
||||||
- CONTAINMENT: all 6 shipped presets set socket_name: mosaic-fleet explicitly → unaffected; only
|
|
||||||
socket-less rosters (the PoC) get default-socket behavior. DEFAULT_SOCKET_NAME exported for explicit use.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- 158 fleet + 201 fleet-adjacent tests green; new: socketArgs none/named, model_hint→env, explicit-socket
|
|
||||||
renders -L, socket-less env bare. tsc/eslint/prettier/sanitize clean. Shell bash -n + end-to-end sim
|
|
||||||
(socket-less→no -L, model→--model).
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# H1 — heartbeat readiness detection
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Add runtime-agnostic readiness classification to `mosaic fleet ps` so an agent can be reported as working/idle/stuck/stale/dead/unknown instead of treating pane liveness as progress.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- `packages/mosaic/src/commands/fleet.ts`
|
|
||||||
- exported readiness state/types/default thresholds/helpers/classifier
|
|
||||||
- `AgentPsRow.readiness` additive JSON field
|
|
||||||
- table HB column and IDLE/STUCK flags
|
|
||||||
- `packages/mosaic/src/commands/fleet.spec.ts`
|
|
||||||
- pure classifier branch/boundary coverage
|
|
||||||
- threshold helper coverage
|
|
||||||
- legitimate render/JSON assertion updates for new HB text
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- Branches covered: dead, unknown, stale, busy working, null-idle working, stuck boundary, idle boundary, working below idle.
|
|
||||||
- Threshold env helpers default to 300s/900s and honor positive integer env values.
|
|
||||||
- `fleet ps` rows populate `readiness` for roster and unmanaged socket sessions.
|
|
||||||
- Table HB text becomes `<age>s/<readiness>` when heartbeat age exists; remains `unknown` when absent.
|
|
||||||
- Flags include `IDLE`/`STUCK` for matching readiness.
|
|
||||||
- Local gates green: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, fleet vitest.
|
|
||||||
- Pre-push queue guard passes; PR opened off `origin/main`; no merge by worker.
|
|
||||||
|
|
||||||
## Constraints / Assumptions
|
|
||||||
|
|
||||||
- Source branch: `origin/main` @ `e3adc6a`.
|
|
||||||
- No scope creep beyond readiness detection.
|
|
||||||
- `docs/TASKS.md` and `docs/fleet/TASKS.md` are orchestrator-owned; worker will not modify them.
|
|
||||||
- PRD alignment source: `docs/fleet/PRD.md` Phase 2 observability; this is a refinement of heartbeat observability, preserving existing unknown/stale behavior.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Install dependencies with requested PNPM environment.
|
|
||||||
2. Add readiness types/helpers/classifier near heartbeat constants.
|
|
||||||
3. Add `readiness` to `AgentPsRow` and populate both row paths.
|
|
||||||
4. Update table render and flags.
|
|
||||||
5. Add unit tests and update affected ps render/JSON assertions.
|
|
||||||
6. Run build precheck + required gates.
|
|
||||||
7. Run automated independent review, remediate findings.
|
|
||||||
8. Queue guard, push, open PR.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- 2026-06-24: Branch created from `origin/main` @ `e3adc6a`.
|
|
||||||
- 2026-06-24: Implemented readiness thresholds/classifier, JSON row field, HB column label, and IDLE/STUCK flags.
|
|
||||||
- 2026-06-24: Added classifier branch/boundary tests, threshold helper tests, JSON shape assertions, and readiness table rendering assertions.
|
|
||||||
|
|
||||||
## Verification Evidence
|
|
||||||
|
|
||||||
- `pnpm install --store-dir "$HOME/.pnpm-store"` — pass.
|
|
||||||
- `npx turbo build --filter=@mosaicstack/mosaic^...` — pass, 12/12 tasks successful.
|
|
||||||
- `pnpm typecheck` — pass, 41/41 tasks successful.
|
|
||||||
- `pnpm lint` — pass, 23/23 tasks successful.
|
|
||||||
- `pnpm format:check` — pass, all matched files use Prettier style.
|
|
||||||
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts` — pass, 171 tests.
|
|
||||||
- `pnpm --filter @mosaicstack/mosaic test` — pass, 39 files / 547 tests; `fleet.spec.ts` 171 tests.
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings (reviewed supplied diff; sandbox file-inspection limitation noted by tool).
|
|
||||||
|
|
||||||
## Risks / Blockers
|
|
||||||
|
|
||||||
- No current blocker.
|
|
||||||
- Review tool could not inspect repo files directly due sandbox wrapper limitation, but it reviewed the supplied diff and approved with no findings.
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# H1b — tmux pane idle signal wiring
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Feed `classifyReadiness()` a real idle signal on tmux 3.4 by deriving `idleSeconds` from the first available tmux timestamp source: pane activity, then window activity, then session activity.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- `packages/mosaic/src/commands/fleet.ts`
|
|
||||||
- Extend `buildTmuxListPanesCommand()` format to include `#{window_activity}` and `#{session_activity}` after the existing fields.
|
|
||||||
- Update `parseTmuxListPanes()` to choose the first non-empty finite positive timestamp and clamp future idle values to 0.
|
|
||||||
- `packages/mosaic/src/commands/fleet.spec.ts`
|
|
||||||
- Cover pane/window/session activity parsing behavior, empty-field index alignment, null idle, future clamping, math correctness, and exact tmux format.
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
- No changes to `classifyReadiness()`, thresholds, `AgentPsRow`, or `fleet ps` rendering.
|
|
||||||
- No merge by worker; orchestrator routes review/merge.
|
|
||||||
- Workers do not modify `docs/TASKS.md`.
|
|
||||||
|
|
||||||
## PRD Alignment
|
|
||||||
|
|
||||||
Aligned with `docs/fleet/PRD.md` FR-1 and acceptance criteria for truthful `mosaic fleet ps` pane/pid/idle observability.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Sync branch from latest `origin/main` and install dependencies with required pnpm env.
|
|
||||||
2. Add/confirm reproducer tests for tmux 3.4 empty `pane_activity` and new fallback behavior.
|
|
||||||
3. Implement the focused parser/format change only.
|
|
||||||
4. Run required build, baseline gates, fleet vitest, and independent review.
|
|
||||||
5. Run pre-push queue guard, push branch, and open PR to `main` with Mosaic wrapper.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- 2026-06-24: Branch `fix/fleet-pane-idle-activity` created from `origin/main` @ `ec8dd7c` after fetching.
|
|
||||||
- 2026-06-24: Session-start generated local `.mosaic/orchestrator/*` changes on the previous release branch; stashed as `coder1 session-start state before H1b` to keep this branch clean.
|
|
||||||
- 2026-06-24: Added TDD coverage for the tmux 3.4 production case (`pane_activity` empty, `window_activity` populated), exact new list-panes format, null/future/multiple-source behavior.
|
|
||||||
- 2026-06-24: Implemented parser fallback without changing readiness classifier thresholds or render shape.
|
|
||||||
|
|
||||||
## Verification Evidence
|
|
||||||
|
|
||||||
- `pnpm install --store-dir "$HOME/.pnpm-store"` — pass.
|
|
||||||
- Reproducer before implementation: `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts` — failed as expected (old format, no fallback, negative future idle).
|
|
||||||
- `npx turbo build --filter=@mosaicstack/mosaic^...` — pass, 12/12 tasks successful.
|
|
||||||
- `pnpm typecheck` — pass, 41/41 tasks successful.
|
|
||||||
- `pnpm lint` — pass, 23/23 tasks successful.
|
|
||||||
- `pnpm format:check` — pass, all matched files use Prettier style.
|
|
||||||
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts` — pass, 176 tests.
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings (reviewed supplied diff; sandbox file-inspection limitation noted by tool).
|
|
||||||
|
|
||||||
## Risks / Blockers
|
|
||||||
|
|
||||||
- No current blocker.
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
# H2 — readiness semantics: available, not stuck
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Correct fleet readiness semantics so a healthy long-idle agent is reported as `available` (good/assignable) instead of `stuck` (fault). Reserve `stuck` in the type/JSON value space for future positive block evidence.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- `packages/mosaic/src/commands/fleet.ts`
|
|
||||||
- replace `idle` readiness state with `available`
|
|
||||||
- keep `stuck` in the union but stop emitting it from idle-only heuristics
|
|
||||||
- remove stuck threshold helper/env handling
|
|
||||||
- remove IDLE/STUCK alarm flags from table rendering
|
|
||||||
- `packages/mosaic/src/commands/fleet.spec.ts`
|
|
||||||
- update classifier branch/boundary tests
|
|
||||||
- assert very long idle maps to `available`, not `stuck`
|
|
||||||
- update table/JSON assertions for available with no alarm flags
|
|
||||||
- remove stuck threshold helper tests
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- `classifyReadiness()` remains pure/total/never-throw and maps:
|
|
||||||
- dead/stale/unknown unchanged
|
|
||||||
- busy/null/undefined/non-finite idle to `working`
|
|
||||||
- idle >= activity threshold to `available`
|
|
||||||
- idle < activity threshold to `working`
|
|
||||||
- No idle-derived path emits `stuck`.
|
|
||||||
- `MOSAIC_HEARTBEAT_IDLE_THRESHOLD` remains backward compatible as the working→available activity threshold.
|
|
||||||
- `MOSAIC_HEARTBEAT_STUCK_THRESHOLD` and helper/default are removed.
|
|
||||||
- `fleet ps` keeps the idle-seconds column header `IDLE`, renders `available` in HB label, and does not add IDLE/STUCK warning flags.
|
|
||||||
- Local gates green: build precheck, typecheck, lint, format:check, fleet vitest.
|
|
||||||
- PR opened against `main`; no merge by worker.
|
|
||||||
|
|
||||||
## Constraints / Assumptions
|
|
||||||
|
|
||||||
- Source branch: `origin/main` @ `1020cfa`.
|
|
||||||
- `docs/TASKS.md` is orchestrator-owned; worker will not modify it.
|
|
||||||
- Documentation impact is captured in this scratchpad and PR description; no user/admin guide behavior beyond CLI readiness label semantics.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Install dependencies with requested PNPM environment.
|
|
||||||
2. Inspect current H1/H1b readiness implementation and tests.
|
|
||||||
3. Update classifier types/helpers/rendering.
|
|
||||||
4. Update focused tests.
|
|
||||||
5. Run build precheck + required gates.
|
|
||||||
6. Run automated code review, remediate any findings.
|
|
||||||
7. Queue guard, push, open PR.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- 2026-06-24: Branch created from `origin/main` @ `1020cfa`.
|
|
||||||
- 2026-06-24: Replaced idle-derived `idle`/`stuck` outputs with `available`; retained `stuck` in type union for future positive block evidence.
|
|
||||||
- 2026-06-24: Removed stuck threshold env/helper plumbing and IDLE/STUCK alarm flags.
|
|
||||||
- 2026-06-24: Updated classifier and table-render tests for available semantics.
|
|
||||||
|
|
||||||
## Verification Evidence
|
|
||||||
|
|
||||||
- `pnpm install --store-dir "$HOME/.pnpm-store"` — pass.
|
|
||||||
- `npx turbo build --filter=@mosaicstack/mosaic^...` — pass, 12/12 tasks successful.
|
|
||||||
- `pnpm typecheck` — pass, 41/41 tasks successful.
|
|
||||||
- `pnpm lint` — pass, 23/23 tasks successful.
|
|
||||||
- `pnpm format:check` — pass, all matched files use Prettier style.
|
|
||||||
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts` — pass, 177 tests.
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings (reviewed supplied diff; sandbox file-inspection limitation noted by tool).
|
|
||||||
|
|
||||||
## Risks / Blockers
|
|
||||||
|
|
||||||
- No current blocker.
|
|
||||||
- Review tool could not inspect repo files directly due sandbox wrapper limitation, but it reviewed the supplied diff and approved with no findings.
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# north-star doctrine consolidation (#620-adjacent doc PR)
|
|
||||||
|
|
||||||
- **Branch:** `feat/north-star-doctrine` (off main). Source: Mos's consolidated handoff + 2 drafts (budgeting/200k/delegation + control-plane). ONE conflict-free PR per the merge-map.
|
|
||||||
|
|
||||||
## Applied (merge-map, in order)
|
|
||||||
|
|
||||||
1. Stack table: +2 rows (Central register, Budget/spend governance) after Control plane + PoC-socket-hygiene note.
|
|
||||||
2. `## Budget & token governance` after Invariants (even-spread pacing [Jason override], hard-cap ladder, multi-sub auto-routing, historical learning, #558 CLI UX) + TTY OPS INVARIANT note.
|
|
||||||
3. `## Control plane & central register` after Observation model (Postgres fleet schema, gateway-API access, dispatcher = forge pipeline engine + forge-exec adapter [NOT a daemon], register backs forge, board = forge BOD).
|
|
||||||
4. Phased roadmap Phase 4/5 annotated (fleet schema migration + forge-exec; central register live).
|
|
||||||
5. Decisions of record (2026-06-22): doctrine §1(c) bullets (200k cap, worker bound #8, delegation, budget, spend mandate, unified identity Fleet, role-based session naming) + control-plane 6c `### Control plane & central register` subgroup.
|
|
||||||
6. Future enhancements: Matrix-future-transport (#10, F4 IS Matrix) + tmux security hardening (§5).
|
|
||||||
7. Assumptions: doctrine §1(d) (3) + control-plane 6e (1) + release-procedure note + tracked-separately note.
|
|
||||||
|
|
||||||
## Conflict checklist: all ✓
|
|
||||||
|
|
||||||
1 Decisions-2026-06-22; order Invariants→Budget→Observation→Control plane→Roadmap; 2 stack rows; even-spread (no opportunistic/HOLD); control-plane UNHELD; forge-exec = tracked #628 post-PoC; §7 drift re-captures all present (#8/#10/#558/TTY/release).
|
|
||||||
|
|
||||||
## Out of scope (cited in doc + PR): #622 (spend template std), #623 (telemetry product), #625 (tenant_id schema), #628 (forge-exec adapter). Doctrine only — no implementation.
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# P5 — Overlay composer + cross-harness (compose-contract)
|
|
||||||
|
|
||||||
- **Issue:** #604 · **Branch:** `feat/p5-overlay-composer` · **Lineage:** #542 → constitution alpha
|
|
||||||
- **Requirements:** R7 (compose-contract) + R8 (cross-harness) + R9 (composer test)
|
|
||||||
- **Design of record:** `docs/design/framework-constitution/{DESIGN.md §3.2, PRD.md §4}` (on `feat/framework-constitution-alpha`)
|
|
||||||
|
|
||||||
## Locked design (sequential-thinking)
|
|
||||||
|
|
||||||
Current `launch.ts` assembly (`buildComposedPrompt`) injects by value: mission + PRD + hard-gate +
|
|
||||||
CONSTITUTION + AGENTS + USER + TOOLS + runtime. It does **not** inject SOUL or STANDARDS (those are
|
|
||||||
read-on-demand per the gutted AGENTS dispatcher), and has no `.local` overlay support.
|
|
||||||
|
|
||||||
**Decision (ASSUMPTION — recorded for the PR):** overlays are injected as **deltas by value** under
|
|
||||||
labeled sections; base files keep their existing residency.
|
|
||||||
|
|
||||||
- `USER.local.md` → appended directly under the `# User Profile` block (USER is injected).
|
|
||||||
- `SOUL.local.md` + `STANDARDS.local.md` → a trailing `# Operator Overlays` section (their bases are
|
|
||||||
load-on-demand, so only the small delta is injected — not the full base prose).
|
|
||||||
- **Why:** honors DESIGN §3.2 ("model gets one pre-merged blob, no read-merge ritual") while preserving
|
|
||||||
the P3 byte-budget tiering (don't re-inject large SOUL/STANDARDS prose). Precedence order kept: base
|
|
||||||
layers first, operator overlays at recency.
|
|
||||||
- Base-only is automatic when a `.local` file is absent (`readOptional`).
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
| # | Task | File |
|
|
||||||
| --- | ------------------------------------------------------------------------------------------------------ | --------------------------------------- |
|
|
||||||
| 1 | Extract `composeContract({harness, mosaicHome})` pure fn; `buildComposedPrompt` delegates | `src/commands/launch.ts` |
|
|
||||||
| 2 | Overlay logic (USER.local under profile; SOUL/STANDARDS.local in `# Operator Overlays`) | `src/commands/launch.ts` |
|
|
||||||
| 3 | `mosaic compose-contract <harness>` command → prints blob to stdout | `src/commands/launch.ts` |
|
|
||||||
| 4 | Bare-launch overlay nudge in self-load fallback | `framework/defaults/AGENTS.md` |
|
|
||||||
| 5 | `compose-contract.spec.ts`: per-tier anchor, Tier-3 byte-equality, overlay present/absent, per-harness | `src/commands/compose-contract.spec.ts` |
|
|
||||||
|
|
||||||
## Deferred to P6
|
|
||||||
|
|
||||||
CONTRIBUTING.md + harness×gate compliance matrix; resident line-count CI ceiling; `aiguide` reconcile;
|
|
||||||
alpha tag `mosaic-vX.Y.Z-alpha`.
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
- [x] Phase scaffold (branch, issue #604, scratchpad, TASKS)
|
|
||||||
- [ ] Implementation (tasks 1–5)
|
|
||||||
- [ ] prettier + vitest green; PR via wrapper → Lead (rides 0.0.39; 0.0.38 mid-cut)
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# P6 — Docs, compliance matrix, alpha tag (constitution capstone)
|
|
||||||
|
|
||||||
- **Issue:** #606 · **Branch:** `feat/p6-docs-compliance-alpha` · **Lineage:** #542
|
|
||||||
- **Requirements:** R9 (resident line-count ceiling) + R10 (CONTRIBUTING + compliance matrix + aiguide) + alpha tag
|
|
||||||
|
|
||||||
## Delivered (in-repo)
|
|
||||||
|
|
||||||
- `framework/CONTRIBUTING.md` — layer model, operator-hygiene/PII prohibition, dedup rule, resident
|
|
||||||
budget, **dual-installer parity rule**, adding-a-harness, re-contamination rule, **harness×gate
|
|
||||||
compliance matrix** (hook-parity gap marked ⚠️ tracked-v2), known-limitations (§9 residuals), PR checklist.
|
|
||||||
- `framework/tools/quality/scripts/check-resident-budget.sh` — line-count ceiling over framework-owned
|
|
||||||
resident files (CONSTITUTION + AGENTS + each runtime/\*/RUNTIME.md); `--self-test`; replaces the crude
|
|
||||||
inline ci.yml loop. Wired blocking in `.woodpecker/ci.yml`.
|
|
||||||
- Composer unit test (R9) already runs via `pnpm test`; `verify-sanitized.sh` (P1) already wired.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- Sanitization gate green (CONTRIBUTING is operator-neutral). Resident-budget self-test + real run green.
|
|
||||||
- prettier clean. Current resident counts: CONSTITUTION 96, AGENTS 83, RUNTIME max 75 — all < ceiling.
|
|
||||||
|
|
||||||
## Remaining
|
|
||||||
|
|
||||||
- [ ] `aiguide` reconcile (separate repo `~/src/aiguide` / mosaicstack/aiguide) — consistency pass vs Constitution.
|
|
||||||
- [ ] Alpha tag `mosaic-vX.Y.Z-alpha` — propose version; Lead cuts after full DoD §8 green + all phases merged.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Alpha DoD (DESIGN §8): all phases P0–P6 merged + CI green. P5 (#605) pending merge after 0.0.38 publish.
|
|
||||||
- Hook parity (codex/opencode/pi) = tracked v2 gap, documented in the matrix, not closed here.
|
|
||||||
@@ -51,48 +51,3 @@ This repository currently has no root `CHANGELOG.md`; the scratchpad and `docs/T
|
|||||||
- PR #1908: `Dry run: would merge PR #1908 on git.uscllc.com with authenticated Gitea API fallback (base=main, method=squash).`
|
- PR #1908: `Dry run: would merge PR #1908 on git.uscllc.com with authenticated Gitea API fallback (base=main, method=squash).`
|
||||||
- PR: `https://git.mosaicstack.dev/mosaicstack/stack/pulls/518`, branch `fix/t-a292e96f-gitea-pr-metadata`.
|
- PR: `https://git.mosaicstack.dev/mosaicstack/stack/pulls/518`, branch `fix/t-a292e96f-gitea-pr-metadata`.
|
||||||
- CI: Recent PR/push pipelines failed before clone/test execution due Woodpecker/Kubernetes PVC API timeout: `dial tcp 10.43.0.1:443: i/o timeout`. No repository test step executed in CI; local targeted verification above remains clean.
|
- CI: Recent PR/push pipelines failed before clone/test execution due Woodpecker/Kubernetes PVC API timeout: `dial tcp 10.43.0.1:443: i/o timeout`. No repository test step executed in CI; local targeted verification above remains clean.
|
||||||
|
|
||||||
## 2026-06-18 — PR #549 functional blocker remediation
|
|
||||||
|
|
||||||
### Assignment
|
|
||||||
|
|
||||||
Coordinator `mos-claude` assigned remediation for PR #549: fix `packages/mosaic/framework/tools/git/pr-metadata.sh` tmpfile cleanup where an `EXIT` trap references function-local `body_file` after the function returns inside `RAW=$(...)`, producing `body_file: unbound variable` on the authenticated success path and failing to clean up safely on early `set -e` exits.
|
|
||||||
|
|
||||||
### Plan
|
|
||||||
|
|
||||||
1. Add a non-vacuous Gitea test that exercises `curl_gitea_pull` with stubbed `curl` and `GITEA_TOKEN` instead of `MOSAIC_GITEA_PR_METADATA_RAW_FILE`.
|
|
||||||
2. Prove the new test is RED against the current PR head.
|
|
||||||
3. Replace the function-local `EXIT` cleanup with robust function-scoped tmpfile cleanup.
|
|
||||||
4. Re-run targeted tests, `bash -n`, and review gates; commit and push branch only. Do not merge.
|
|
||||||
|
|
||||||
### Constraints / assumptions
|
|
||||||
|
|
||||||
- Do not modify prior injection/JSON fixes in `issue-edit`, `issue-assign`, or `milestone-create`.
|
|
||||||
- Worker role: do not modify `docs/TASKS.md`; orchestrator remains the single writer.
|
|
||||||
- Budget: no explicit token cap provided; keep scope to shell wrapper + targeted regression harness.
|
|
||||||
|
|
||||||
### Remediation results
|
|
||||||
|
|
||||||
- Rebased `fix/tooling-eval-injection-jq-json` onto `origin/main`; branch was already current.
|
|
||||||
- Added a curl-stub regression path that does not use `MOSAIC_GITEA_PR_METADATA_RAW_FILE`, so it exercises `curl_gitea_pull` and its temp body file.
|
|
||||||
- RED evidence: copied the new harness next to the pre-fix `HEAD` version of `pr-metadata.sh`; `MOSAIC_TEST_WORK_DIR=$PWD/.mosaic-test-work/pr-metadata-red-work .../test-pr-metadata-gitea.sh` failed with `body_file: unbound variable` on the curl success path.
|
|
||||||
- Fix: replaced `EXIT` temp-file cleanup with a `RETURN`-scoped cleanup function that removes the body file while the function-local variable is still in scope, preserves the original return status, and clears the `RETURN` trap.
|
|
||||||
- GREEN evidence:
|
|
||||||
- `MOSAIC_TEST_WORK_DIR=$PWD/.mosaic-test-work/pr-metadata-gitea-current packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh` passed.
|
|
||||||
- `bash -n packages/mosaic/framework/tools/git/pr-metadata.sh packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh` passed.
|
|
||||||
- `shellcheck -x -P . -e SC1090 packages/mosaic/framework/tools/git/pr-metadata.sh packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh` passed.
|
|
||||||
|
|
||||||
### Review remediation
|
|
||||||
|
|
||||||
- Codex review returned one should-fix: the early-exit test used `chmod 000`, which is not root-safe in container CI.
|
|
||||||
- Remediation: changed the stubbed 2xx/cat-failure mode to replace the curl output with a broken symlink, which fails deterministically even as root and still validates cleanup via `rm -f -- "$body_file"`.
|
|
||||||
|
|
||||||
### Second review remediation
|
|
||||||
|
|
||||||
- Codex review found the 2xx `cat "$body_file"` read could be masked under command substitution semantics because the branch returned 0 unconditionally.
|
|
||||||
- Remediation: both authenticated 2xx branches now use `cat "$body_file" || return $?` before returning success.
|
|
||||||
- Strengthened the broken-symlink test to require the body-read failure and reject the later `Gitea API returned non-JSON` parse-failure path, so the test verifies the helper-level failure propagation rather than eventual downstream failure.
|
|
||||||
|
|
||||||
### Final review gate
|
|
||||||
|
|
||||||
- Codex review after remediation: approved (`0 blockers, 0 should-fix, 0 suggestions`).
|
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
# 544: Agent Reflection Loop — durable kernel
|
|
||||||
|
|
||||||
**Issue:** [#544](http://git.mosaicstack.dev/mosaicstack/stack/issues/544)
|
|
||||||
**PRD:** [`docs/plans/agent-reflection-loop-PRD.md`](../plans/agent-reflection-loop-PRD.md)
|
|
||||||
**Branch:** `feat/agent-reflection-loop`
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
Build the **durable kernel** of the agent reflection loop: passive end-of-run
|
|
||||||
capture of the doer's end-state as structured `reflection.v1` data, plus a
|
|
||||||
deterministic diff **review risk-floor**. The closed calibration / skill-synthesis
|
|
||||||
loop (design §7–§8) stays **gated** behind Phase-0 experiments P1/P2/P3 and is
|
|
||||||
explicitly out of scope here. Source design: jarvis-brain
|
|
||||||
`docs/planning/AGENT-REFLECTION-LOOP.md` (debate-hardened v2).
|
|
||||||
|
|
||||||
Scope rule, non-goals, the full `reflection.v1` field list, and acceptance
|
|
||||||
criteria live in the PRD. This file is the task breakdown + status.
|
|
||||||
|
|
||||||
## Work items
|
|
||||||
|
|
||||||
| # | Item | Path | Status |
|
|
||||||
| --- | ----------------------------------------------------- | --------------------------------------------------------- | ------ |
|
|
||||||
| 1 | Diff risk-floor (pure, deterministic) + unit tests | `packages/macp/src/risk-floor.ts`, `risk-floor.spec.ts` | done |
|
|
||||||
| 2 | `reflection.v1` JSON Schema (documented contract) | `packages/macp/src/schemas/reflection.v1.schema.json` | done |
|
|
||||||
| 3 | `reflection.v1` zod schemas + self-report DTO + tests | `packages/types/src/reflection/*` | done |
|
|
||||||
| 4 | Stop hook (fail-closed capture) | `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh` | done |
|
|
||||||
| 5 | Hook registration (`hooks.Stop`) | `packages/mosaic/framework/runtime/claude/settings.json` | done |
|
|
||||||
| 6 | Phase-0 experiment harnesses (P1/P2/P3) | `scripts/analysis/reflect-*.sh` | done |
|
|
||||||
|
|
||||||
## Design decisions (this implementation)
|
|
||||||
|
|
||||||
- **Mechanical vs self-reported split.** A bash Stop hook cannot author the
|
|
||||||
agent's self-assessment, so it writes the mechanical fields (risk-floor verdict,
|
|
||||||
`files_changed`, ids, provenance) and merges an optional agent-supplied
|
|
||||||
`$REFLECTION_INPUT` self-report; absent/unreadable ⇒ those fields `null` and
|
|
||||||
`provenance.degraded = true`.
|
|
||||||
- **Risk-floor authority.** `evaluateRiskFloor` (TS, tested) is the source of
|
|
||||||
truth. The hook ports the same surface table inline to avoid a node/build
|
|
||||||
dependency on the hook path; the two are documented as kept in sync.
|
|
||||||
- **Hook registration deviation.** `settings-overlays/` has no merge mechanism
|
|
||||||
(docs-only), so a hooks overlay there would be inert. The Stop hook is
|
|
||||||
registered in the canonical `runtime/claude/settings.json` — the same file the
|
|
||||||
`mosaic` launcher reflects into `~/.claude/settings.json`. Still vendored in-repo.
|
|
||||||
- **DTO without class-transformer.** `reflection.dto.ts` uses class-validator only
|
|
||||||
(no `@Type`), matching `chat.dto.ts`, so the module imports without a
|
|
||||||
`reflect-metadata` shim in the types-package test env. Deep nested validation is
|
|
||||||
owned by the zod `ReflectionSelfReportSchema` (the runtime authority the hook uses).
|
|
||||||
- **`.mosaic/` excluded** from the change surface — it is agent scratch
|
|
||||||
(reflections, locks, self-report input), not part of the diff under review.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- `pnpm --filter @mosaicstack/macp test` → 88 passed (15 new risk-floor).
|
|
||||||
- `pnpm --filter @mosaicstack/types test` → 64 passed (10 new reflection).
|
|
||||||
- Root `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build` → green.
|
|
||||||
- Stop hook smoke: fail-closed no-op (mode unset), solo capture (degraded),
|
|
||||||
self-report merge (degraded=false), re-fire lock guard — all pass.
|
|
||||||
- All bash (hook + 3 Phase-0 scripts) shellcheck-clean; Phase-0 scripts emit
|
|
||||||
structured JSON/markdown and print their pre-registered kill conditions.
|
|
||||||
|
|
||||||
## Activation (post-merge, deployment concern — not a blocker)
|
|
||||||
|
|
||||||
The Stop hook only activates when a launcher/profile sets
|
|
||||||
`REFLECTION_MODE=solo|orchestrated`; unset/`off` is a strict no-op, so global
|
|
||||||
registration is safe. `framework/install.sh` rsyncs the hook into
|
|
||||||
`~/.config/mosaic/tools/qa/`, and the `mosaic` launcher reflects the updated
|
|
||||||
`settings.json` (`hooks.Stop`) into `~/.claude/settings.json`.
|
|
||||||
@@ -28,9 +28,7 @@ export default tseslint.config(
|
|||||||
'apps/web/e2e/helpers/*.ts',
|
'apps/web/e2e/helpers/*.ts',
|
||||||
'apps/web/playwright.config.ts',
|
'apps/web/playwright.config.ts',
|
||||||
'apps/gateway/vitest.config.ts',
|
'apps/gateway/vitest.config.ts',
|
||||||
'packages/db/vitest.config.ts',
|
|
||||||
'packages/storage/vitest.config.ts',
|
'packages/storage/vitest.config.ts',
|
||||||
'packages/mosaic/vitest.config.ts',
|
|
||||||
'packages/mosaic/__tests__/*.ts',
|
'packages/mosaic/__tests__/*.ts',
|
||||||
'tools/federation-harness/*.ts',
|
'tools/federation-harness/*.ts',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -15,22 +15,6 @@ This guide covers how to bootstrap a project so AI agents (Claude, Codex, etc.)
|
|||||||
7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges
|
7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges
|
||||||
8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention
|
8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention
|
||||||
|
|
||||||
## Agent Host Prerequisites
|
|
||||||
|
|
||||||
Agent hosts must provide the Python runtime shape that runtime agents and
|
|
||||||
Mosaic automation assume is present.
|
|
||||||
|
|
||||||
For Debian/Ubuntu hosts:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt-get update
|
|
||||||
# #561: bare python invocations from agents must resolve.
|
|
||||||
sudo apt-get install -y python3 python-is-python3
|
|
||||||
```
|
|
||||||
|
|
||||||
For non-Debian hosts, install the equivalent Python 3 runtime and ensure
|
|
||||||
`/usr/bin/python` resolves to `python3` (for example, via a managed symlink).
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -469,26 +453,6 @@ Initialize standard labels and the first pre-MVP milestone:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Secrets Bootstrap (Required for Every New App)
|
|
||||||
|
|
||||||
Every new application MUST complete the following secrets bootstrap before deploying to any non-local environment. This is a hard gate — deployment without completed secrets bootstrap is forbidden.
|
|
||||||
|
|
||||||
### Secrets bootstrap checklist
|
|
||||||
|
|
||||||
- [ ] Vault path created: `vault kv put secret/k3s/<app>/ ...` with all required secret fields
|
|
||||||
- [ ] Required secrets listed in project README under a "Secrets architecture" section, including:
|
|
||||||
- Vault path(s) used
|
|
||||||
- All required secret keys and their purpose
|
|
||||||
- Whether the app uses ESO bridge (default) or Direct-Vault (opt-in, with justification)
|
|
||||||
- [ ] `external-secret.yaml` manifest committed to repo's `deploy/` or `k8s/` directory
|
|
||||||
- [ ] Deployment YAML references the synced k8s Secret via `secretKeyRef` (not raw env vars or `.env` files)
|
|
||||||
- [ ] App startup has schema-based validation for all required env vars (zod / pydantic / envconfig equivalent) that exits non-zero on missing required values
|
|
||||||
- [ ] Direct-Vault opt-in (if applicable): justification documented in README + AppRole provisioned + bootstrap credentials stored in Vault and synced via a separate `ExternalSecret`
|
|
||||||
|
|
||||||
See `~/.config/mosaic/guides/VAULT-SECRETS.md` for full worked examples of the ESO bridge pattern, the Direct-Vault opt-in pattern, and the forbidden antipatterns.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
After bootstrapping, verify:
|
After bootstrapping, verify:
|
||||||
|
|||||||
@@ -203,374 +203,3 @@ Error: token expired
|
|||||||
3. **Audit logging** - All access is logged; act accordingly
|
3. **Audit logging** - All access is logged; act accordingly
|
||||||
4. **No local copies** - Don't store secrets in files or env vars long-term
|
4. **No local copies** - Don't store secrets in files or env vars long-term
|
||||||
5. **Rotate on compromise** - Immediately rotate any exposed secrets
|
5. **Rotate on compromise** - Immediately rotate any exposed secrets
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Secrets Architecture Decision Matrix
|
|
||||||
|
|
||||||
Use this table to choose between the ESO bridge (default) and Direct-Vault (opt-in) patterns for every new app or integration.
|
|
||||||
|
|
||||||
| Factor | ESO Bridge (default) | Direct-Vault (opt-in) |
|
|
||||||
| --------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| **Use-case** | All static secrets (DB creds, API keys, signing keys, OAuth secrets) | Dynamic creds with short TTLs (DB rotation, AWS STS, PKI), per-request audit trails, or lease renewal mid-pod-lifecycle |
|
|
||||||
| **App code change** | None — reads standard env vars via `secretKeyRef` | Requires Vault client (`hvac`, `node-vault`, `vault/api`) in application code |
|
|
||||||
| **Secret rotation** | ESO re-syncs on Vault write; pod restart or secret refresh picks up new value | App manages lease renewal or re-auth within the running process |
|
|
||||||
| **Audit granularity** | Access logged at Vault when ESO syncs; no per-request app audit | Every app request to Vault is a separate audit log entry |
|
|
||||||
| **Operational burden** | Low — ESO handles polling, sync, and k8s Secret lifecycle | Higher — app must handle auth, lease renewal, error paths, and token rotation |
|
|
||||||
| **Justification required?** | No — this is the default | Yes — document in project README under "Secrets architecture" |
|
|
||||||
| **Example use cases** | Web app DB password, OAuth client secret, JWT signing key, API token | HashiCorp DB secrets engine with 15-min TTL leases, AWS STS assume-role, Vault PKI short-lived certs |
|
|
||||||
|
|
||||||
**Decision rule:** If you are unsure, use ESO. Only justify Direct-Vault when the secret cannot be safely stored in a k8s Secret (too short-lived, per-request TTL required, or mid-lifecycle renewal needed).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ESO Bridge Pattern (Default)
|
|
||||||
|
|
||||||
This is the required default for all k8s workloads. Follow this exact pattern unless a documented dynamic-secrets requirement justifies Direct-Vault.
|
|
||||||
|
|
||||||
### 1. Provision Vault path
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Write the secrets for the app (run once; use IaC/Terraform for repeatable provisioning)
|
|
||||||
vault kv put secret/k3s/<app> \
|
|
||||||
db_password="..." \
|
|
||||||
api_key="..." \
|
|
||||||
jwt_secret="..."
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the canonical path structure: `secret/k3s/<app>` for k3s cluster workloads.
|
|
||||||
|
|
||||||
### 2. ExternalSecret manifest
|
|
||||||
|
|
||||||
Commit this to the repo's `deploy/` or `k8s/` directory:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# deploy/external-secret.yaml
|
|
||||||
apiVersion: external-secrets.io/v1beta1
|
|
||||||
kind: ExternalSecret
|
|
||||||
metadata:
|
|
||||||
name: <app>-secrets
|
|
||||||
namespace: <namespace>
|
|
||||||
spec:
|
|
||||||
refreshInterval: 1h
|
|
||||||
secretStoreRef:
|
|
||||||
name: vault-backend # ClusterSecretStore name — verify with cluster admin
|
|
||||||
kind: ClusterSecretStore
|
|
||||||
target:
|
|
||||||
name: <app>-secrets # k8s Secret name that will be created
|
|
||||||
creationPolicy: Owner
|
|
||||||
data:
|
|
||||||
- secretKey: DB_PASSWORD # key in the k8s Secret
|
|
||||||
remoteRef:
|
|
||||||
key: secret/k3s/<app> # Vault path
|
|
||||||
property: db_password # field within the Vault secret
|
|
||||||
- secretKey: API_KEY
|
|
||||||
remoteRef:
|
|
||||||
key: secret/k3s/<app>
|
|
||||||
property: api_key
|
|
||||||
- secretKey: JWT_SECRET
|
|
||||||
remoteRef:
|
|
||||||
key: secret/k3s/<app>
|
|
||||||
property: jwt_secret
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Deployment manifest — reference synced k8s Secret
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# deploy/deployment.yaml (env section)
|
|
||||||
env:
|
|
||||||
- name: DB_PASSWORD
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: <app>-secrets # matches ExternalSecret target.name
|
|
||||||
key: DB_PASSWORD
|
|
||||||
- name: API_KEY
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: <app>-secrets
|
|
||||||
key: API_KEY
|
|
||||||
- name: JWT_SECRET
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: <app>-secrets
|
|
||||||
key: JWT_SECRET
|
|
||||||
- name: PORT
|
|
||||||
value: '3000' # safe-default: non-secret, no Vault needed
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. App-side schema validation — TypeScript (zod)
|
|
||||||
|
|
||||||
Validate all required env vars at startup. Exit non-zero on missing values.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/env.ts
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const envSchema = z.object({
|
|
||||||
DB_PASSWORD: z.string().min(1, 'DB_PASSWORD is required'),
|
|
||||||
API_KEY: z.string().min(1, 'API_KEY is required'),
|
|
||||||
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 chars'),
|
|
||||||
PORT: z.coerce.number().default(3000),
|
|
||||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('production'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = envSchema.safeParse(process.env);
|
|
||||||
if (!result.success) {
|
|
||||||
console.error('Missing or invalid environment variables:');
|
|
||||||
console.error(result.error.flatten().fieldErrors);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const env = result.data;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4b. App-side schema validation — Python (pydantic)
|
|
||||||
|
|
||||||
```python
|
|
||||||
# src/config.py
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
db_password: str
|
|
||||||
api_key: str
|
|
||||||
jwt_secret: str
|
|
||||||
port: int = 3000
|
|
||||||
node_env: str = "production"
|
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_file=None) # no .env in prod
|
|
||||||
|
|
||||||
try:
|
|
||||||
settings = Settings()
|
|
||||||
except Exception as e:
|
|
||||||
import sys
|
|
||||||
print(f"Missing or invalid environment variables: {e}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4c. App-side schema validation — Go (envconfig)
|
|
||||||
|
|
||||||
```go
|
|
||||||
// config/config.go
|
|
||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"github.com/kelseyhightower/envconfig"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
DBPassword string `envconfig:"DB_PASSWORD" required:"true"`
|
|
||||||
APIKey string `envconfig:"API_KEY" required:"true"`
|
|
||||||
JWTSecret string `envconfig:"JWT_SECRET" required:"true"`
|
|
||||||
Port int `envconfig:"PORT" default:"3000"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
|
||||||
var cfg Config
|
|
||||||
if err := envconfig.Process("", &cfg); err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid environment: %w", err)
|
|
||||||
}
|
|
||||||
return &cfg, nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
In your `main.go`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
cfg, err := config.Load()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Direct-Vault Opt-In Pattern
|
|
||||||
|
|
||||||
Use this pattern ONLY when a documented dynamic-secrets requirement applies (DB rotation with short TTLs, AWS STS, PKI, per-request audit). Document the justification in the project README under "Secrets architecture" before implementing.
|
|
||||||
|
|
||||||
### When it is justified
|
|
||||||
|
|
||||||
- Vault DB secrets engine with lease TTLs shorter than a typical pod lifecycle (< 1 hour)
|
|
||||||
- AWS STS assume-role tokens generated per-request
|
|
||||||
- Vault PKI short-lived certificates (< 24 hours) that must be renewed within a running pod
|
|
||||||
- Per-request audit trail requirement (each app call must appear separately in Vault audit log)
|
|
||||||
|
|
||||||
### Provision an AppRole for the app
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Enable AppRole auth (if not already enabled)
|
|
||||||
vault auth enable approle
|
|
||||||
|
|
||||||
# Create a Vault policy for the app
|
|
||||||
# Note: KV v2 paths require both the exact path (for the top-level secret) and the
|
|
||||||
# wildcard (for sub-paths). Always include both to avoid permission denied errors.
|
|
||||||
vault policy write <app>-policy - <<EOF
|
|
||||||
path "secret/data/k3s/<app>" {
|
|
||||||
capabilities = ["read"]
|
|
||||||
}
|
|
||||||
path "secret/data/k3s/<app>/*" {
|
|
||||||
capabilities = ["read"]
|
|
||||||
}
|
|
||||||
path "database/creds/<app>-role" {
|
|
||||||
capabilities = ["read"]
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# Create the AppRole
|
|
||||||
vault write auth/approle/role/<app>-role \
|
|
||||||
token_policies="<app>-policy" \
|
|
||||||
token_ttl=1h \
|
|
||||||
token_max_ttl=4h \
|
|
||||||
secret_id_ttl=0
|
|
||||||
|
|
||||||
# Retrieve role-id and secret-id
|
|
||||||
vault read auth/approle/role/<app>-role/role-id
|
|
||||||
vault write -f auth/approle/role/<app>-role/secret-id
|
|
||||||
```
|
|
||||||
|
|
||||||
### Bootstrap AppRole credentials via ESO (solving the chicken-and-egg problem)
|
|
||||||
|
|
||||||
The AppRole `role-id` and `secret-id` are themselves secrets. Store them in Vault at a bootstrap path, then use ESO to sync them into a k8s Secret. The app reads that k8s Secret at startup to authenticate with Vault directly.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Store the bootstrap credentials in Vault
|
|
||||||
vault kv put secret/k3s/<app>-bootstrap \
|
|
||||||
role_id="<role-id>" \
|
|
||||||
secret_id="<secret-id>"
|
|
||||||
```
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# deploy/external-secret-bootstrap.yaml
|
|
||||||
apiVersion: external-secrets.io/v1beta1
|
|
||||||
kind: ExternalSecret
|
|
||||||
metadata:
|
|
||||||
name: <app>-vault-auth
|
|
||||||
namespace: <namespace>
|
|
||||||
spec:
|
|
||||||
refreshInterval: 24h
|
|
||||||
secretStoreRef:
|
|
||||||
name: vault-backend
|
|
||||||
kind: ClusterSecretStore
|
|
||||||
target:
|
|
||||||
name: <app>-vault-auth
|
|
||||||
creationPolicy: Owner
|
|
||||||
data:
|
|
||||||
- secretKey: VAULT_ROLE_ID
|
|
||||||
remoteRef:
|
|
||||||
key: secret/k3s/<app>-bootstrap
|
|
||||||
property: role_id
|
|
||||||
- secretKey: VAULT_SECRET_ID
|
|
||||||
remoteRef:
|
|
||||||
key: secret/k3s/<app>-bootstrap
|
|
||||||
property: secret_id
|
|
||||||
```
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# deploy/deployment.yaml (env section for Direct-Vault app)
|
|
||||||
env:
|
|
||||||
- name: VAULT_ADDR
|
|
||||||
value: 'https://vault.example.com' # safe-default: non-secret cluster address
|
|
||||||
- name: VAULT_ROLE_ID
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: <app>-vault-auth
|
|
||||||
key: VAULT_ROLE_ID
|
|
||||||
- name: VAULT_SECRET_ID
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: <app>-vault-auth
|
|
||||||
key: VAULT_SECRET_ID
|
|
||||||
```
|
|
||||||
|
|
||||||
### App-side Vault client pattern
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/vault-client.ts — only exists in Direct-Vault apps
|
|
||||||
import vault from 'node-vault';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
const bootstrapSchema = z.object({
|
|
||||||
VAULT_ADDR: z.string().url(),
|
|
||||||
VAULT_ROLE_ID: z.string().min(1),
|
|
||||||
VAULT_SECRET_ID: z.string().min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
const bootstrap = bootstrapSchema.parse(process.env);
|
|
||||||
|
|
||||||
const client = vault({ endpoint: bootstrap.VAULT_ADDR });
|
|
||||||
|
|
||||||
export async function getVaultClient() {
|
|
||||||
const { auth } = await client.approleLogin({
|
|
||||||
role_id: bootstrap.VAULT_ROLE_ID,
|
|
||||||
secret_id: bootstrap.VAULT_SECRET_ID,
|
|
||||||
});
|
|
||||||
client.token = auth.client_token;
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Document in README under "Secrets architecture": the Vault path, why Direct-Vault is required, and the lease/renewal strategy.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Forbidden Patterns (CI Lint Targets)
|
|
||||||
|
|
||||||
The following patterns are forbidden in all Mosaic projects. CI lint SHOULD catch these automatically (implementation tracked separately). Agents MUST NOT introduce these patterns.
|
|
||||||
|
|
||||||
### 1. Untagged fallback defaults for required values
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# FORBIDDEN — required secret with silent fallback
|
|
||||||
environment:
|
|
||||||
- DB_PASSWORD=${DB_PASSWORD:-changeme}
|
|
||||||
- API_KEY=${API_KEY:-}
|
|
||||||
|
|
||||||
# REQUIRED — fast-fail on missing required values
|
|
||||||
environment:
|
|
||||||
- DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
|
|
||||||
- API_KEY=${API_KEY:?API_KEY is required}
|
|
||||||
|
|
||||||
# ALLOWED — true convenience default, tagged
|
|
||||||
environment:
|
|
||||||
- PORT=${PORT:-3000} # safe-default: non-secret, app works at any port
|
|
||||||
```
|
|
||||||
|
|
||||||
This applies to: `docker-compose.yml`, k8s manifests, Helm `values.yaml`, any env file committed to git.
|
|
||||||
|
|
||||||
### 2. Vault KV calls in application source code (ESO-default projects)
|
|
||||||
|
|
||||||
```python
|
|
||||||
# FORBIDDEN in ESO-default apps — direct Vault client in app source
|
|
||||||
import hvac
|
|
||||||
client = hvac.Client(url=os.environ['VAULT_ADDR'])
|
|
||||||
secret = client.secrets.kv.v2.read_secret_version(path='myapp/db')
|
|
||||||
```
|
|
||||||
|
|
||||||
ESO-default apps read env vars only. Direct-Vault clients belong only in apps with a documented dynamic-secrets justification in README.
|
|
||||||
|
|
||||||
### 3. Hardcoded secrets or API keys in committed files
|
|
||||||
|
|
||||||
```python
|
|
||||||
# FORBIDDEN — hardcoded credential
|
|
||||||
DB_PASSWORD = "supersecret123"
|
|
||||||
API_KEY = "sk-live-abc123"
|
|
||||||
```
|
|
||||||
|
|
||||||
No exceptions. CI lint must flag any string matching common secret patterns (`password`, `secret`, `api_key`, `token` assigned a literal non-env-var value).
|
|
||||||
|
|
||||||
### 4. `.env` files in production deployment paths
|
|
||||||
|
|
||||||
```
|
|
||||||
# FORBIDDEN — .env file in a production deploy path
|
|
||||||
deploy/.env
|
|
||||||
k8s/.env
|
|
||||||
docker/.env
|
|
||||||
|
|
||||||
# ALLOWED — local dev only
|
|
||||||
.env.example # template only, no real values
|
|
||||||
.env # local dev, must be in .gitignore
|
|
||||||
```
|
|
||||||
|
|
||||||
`.env` files are acceptable in local-dev contexts only and MUST be in `.gitignore`. They are forbidden in any path that a CI pipeline or production deployment process reads directly.
|
|
||||||
|
|||||||
@@ -23,6 +23,5 @@
|
|||||||
"turbo": "^2.0.0",
|
"turbo": "^2.0.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vitest": "^2.0.0"
|
"vitest": "^2.0.0"
|
||||||
},
|
}
|
||||||
"license": "MIT"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,116 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { AGENTS_ACCOUNT_DATA_TYPE, AgentTokenStore } from '../agent-store.js';
|
|
||||||
import type { AppserviceIntent } from '../intent.js';
|
|
||||||
|
|
||||||
/** Fake intent: in-memory account_data, no-op user provisioning. Only the
|
|
||||||
* surface AgentTokenStore touches is implemented. */
|
|
||||||
const makeFakeIntent = () => {
|
|
||||||
const store: Record<string, Record<string, unknown>> = {};
|
|
||||||
const fake = {
|
|
||||||
domain: 'hs.example',
|
|
||||||
getSenderAccountData: async (type: string): Promise<Record<string, unknown> | null> =>
|
|
||||||
store[type] ?? null,
|
|
||||||
setSenderAccountData: async (type: string, content: Record<string, unknown>): Promise<void> => {
|
|
||||||
store[type] = structuredClone(content);
|
|
||||||
},
|
|
||||||
ensureRegistered: async (agent: string): Promise<string> => `@agent-${agent}:hs.example`,
|
|
||||||
setDisplayName: async (): Promise<void> => {},
|
|
||||||
};
|
|
||||||
return { intent: fake as unknown as AppserviceIntent, store };
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('AgentTokenStore', () => {
|
|
||||||
it('mints a magt_ token and stores only its sha256 (never plaintext)', async () => {
|
|
||||||
const { intent, store } = makeFakeIntent();
|
|
||||||
const s = new AgentTokenStore(intent);
|
|
||||||
const { agentUserId, token } = await s.register({ alias: 'pi0', host: 'web1' });
|
|
||||||
|
|
||||||
expect(agentUserId).toBe('@agent-pi0-web1:hs.example');
|
|
||||||
expect(token.startsWith('magt_')).toBe(true);
|
|
||||||
|
|
||||||
const raw = JSON.stringify(store[AGENTS_ACCOUNT_DATA_TYPE]);
|
|
||||||
expect(raw).not.toContain(token);
|
|
||||||
// The stored hash is sha256hex(token), 64 hex chars.
|
|
||||||
const { createHash } = await import('node:crypto');
|
|
||||||
const hash = createHash('sha256').update(token).digest('hex');
|
|
||||||
expect(raw).toContain(hash);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('verifyToken returns the agentUserId for a fresh token, null otherwise', async () => {
|
|
||||||
const { intent } = makeFakeIntent();
|
|
||||||
const s = new AgentTokenStore(intent);
|
|
||||||
const { agentUserId, token } = await s.register({ alias: 'pi0', host: 'web1' });
|
|
||||||
|
|
||||||
expect(await s.verifyToken(token)).toBe(agentUserId);
|
|
||||||
expect(await s.verifyToken('magt_garbage')).toBeNull();
|
|
||||||
expect(await s.verifyToken('not-a-token')).toBeNull();
|
|
||||||
expect(await s.verifyToken('')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('revoke invalidates tokens, returns count, and hides agent from list', async () => {
|
|
||||||
const { intent } = makeFakeIntent();
|
|
||||||
const s = new AgentTokenStore(intent);
|
|
||||||
const { agentUserId, token } = await s.register({ alias: 'pi0', host: 'web1' });
|
|
||||||
|
|
||||||
expect((await s.list()).map((a) => a.agent_user_id)).toContain(agentUserId);
|
|
||||||
|
|
||||||
const count = await s.revoke(agentUserId);
|
|
||||||
expect(count).toBe(1);
|
|
||||||
expect(await s.verifyToken(token)).toBeNull();
|
|
||||||
expect((await s.list()).map((a) => a.agent_user_id)).not.toContain(agentUserId);
|
|
||||||
|
|
||||||
// Idempotent on unknown / already-revoked.
|
|
||||||
expect(await s.revoke(agentUserId)).toBe(0);
|
|
||||||
expect(await s.revoke('@agent-nope:hs.example')).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('re-register after revoke yields a working token and the agent reappears', async () => {
|
|
||||||
const { intent } = makeFakeIntent();
|
|
||||||
const s = new AgentTokenStore(intent);
|
|
||||||
const { agentUserId, token: t1 } = await s.register({ alias: 'pi0', host: 'web1' });
|
|
||||||
await s.revoke(agentUserId);
|
|
||||||
|
|
||||||
const { token: t2 } = await s.register({ alias: 'pi0', host: 'web1' });
|
|
||||||
expect(await s.verifyToken(t1)).toBeNull();
|
|
||||||
expect(await s.verifyToken(t2)).toBe(agentUserId);
|
|
||||||
expect((await s.list()).map((a) => a.agent_user_id)).toContain(agentUserId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('agent A token never verifies as agent B', async () => {
|
|
||||||
const { intent } = makeFakeIntent();
|
|
||||||
const s = new AgentTokenStore(intent);
|
|
||||||
const a = await s.register({ alias: 'pi0', host: 'web1' });
|
|
||||||
const b = await s.register({ alias: 'pi1', host: 'web2' });
|
|
||||||
|
|
||||||
expect(await s.verifyToken(a.token)).toBe(a.agentUserId);
|
|
||||||
expect(await s.verifyToken(b.token)).toBe(b.agentUserId);
|
|
||||||
expect(a.agentUserId).not.toBe(b.agentUserId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an ambiguous re-registration that collides on one Matrix id', async () => {
|
|
||||||
const { intent } = makeFakeIntent();
|
|
||||||
const s = new AgentTokenStore(intent);
|
|
||||||
// alias="a-b",host="c" and alias="a",host="b-c" both -> @agent-a-b-c.
|
|
||||||
const first = await s.register({ alias: 'a-b', host: 'c' });
|
|
||||||
expect(first.agentUserId).toBe('@agent-a-b-c:hs.example');
|
|
||||||
|
|
||||||
await expect(s.register({ alias: 'a', host: 'b-c' })).rejects.toThrow(/collision/);
|
|
||||||
|
|
||||||
// The original registration is untouched: still one active token, correct pair.
|
|
||||||
expect(await s.verifyToken(first.token)).toBe(first.agentUserId);
|
|
||||||
const summary = (await s.list()).find((x) => x.agent_user_id === first.agentUserId);
|
|
||||||
expect(summary?.alias).toBe('a-b');
|
|
||||||
expect(summary?.host).toBe('c');
|
|
||||||
expect(summary?.active_token_count).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('display_name is stored and surfaced in list', async () => {
|
|
||||||
const { intent } = makeFakeIntent();
|
|
||||||
const s = new AgentTokenStore(intent);
|
|
||||||
await s.register({ alias: 'pi0', host: 'web1', displayName: 'Pi Zero' });
|
|
||||||
const summary = (await s.list())[0];
|
|
||||||
expect(summary?.display_name).toBe('Pi Zero');
|
|
||||||
expect(summary?.active_token_count).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
/** DTOs for agent registration + scoped/revocable bridge tokens (US-007). */
|
|
||||||
|
|
||||||
export interface RegisterAgentDto {
|
|
||||||
/** Agent alias slug, e.g. "pi0". Combined with host into the agent slug. */
|
|
||||||
alias: string;
|
|
||||||
/** Host slug, e.g. "web1". Combined with alias into the agent slug. */
|
|
||||||
host: string;
|
|
||||||
display_name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RevokeAgentDto {
|
|
||||||
agent_user_id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RegisterAgentResponse {
|
|
||||||
agent_user_id: string;
|
|
||||||
bridge_token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentSummary {
|
|
||||||
agent_user_id: string;
|
|
||||||
alias: string;
|
|
||||||
host: string;
|
|
||||||
display_name?: string;
|
|
||||||
created_at: string;
|
|
||||||
active_token_count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SLUG_RE = /^[a-z0-9][a-z0-9_.-]*$/;
|
|
||||||
|
|
||||||
/** Combined agent slug, e.g. alias="pi0", host="web1" -> "pi0-web1". */
|
|
||||||
export function agentSlug(alias: string, host: string): string {
|
|
||||||
return `${alias}-${host}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const assertSlug = (value: unknown, field: string): void => {
|
|
||||||
if (typeof value !== 'string' || value.length === 0 || !SLUG_RE.test(value)) {
|
|
||||||
throw new Error(`${field} must match [a-z0-9][a-z0-9_.-]* (lowercase, non-empty)`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export function validateRegisterAgent(input: unknown): asserts input is RegisterAgentDto {
|
|
||||||
const o = input as Partial<RegisterAgentDto> | null | undefined;
|
|
||||||
if (!o || typeof o !== 'object') throw new Error('payload must be an object');
|
|
||||||
assertSlug(o.alias, 'alias');
|
|
||||||
assertSlug(o.host, 'host');
|
|
||||||
if (o.display_name !== undefined) {
|
|
||||||
if (typeof o.display_name !== 'string' || o.display_name.length === 0) {
|
|
||||||
throw new Error('display_name must be a non-empty string');
|
|
||||||
}
|
|
||||||
if (o.display_name.length > 100) {
|
|
||||||
throw new Error('display_name must be at most 100 chars');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateRevokeAgent(input: unknown): asserts input is RevokeAgentDto {
|
|
||||||
const o = input as Partial<RevokeAgentDto> | null | undefined;
|
|
||||||
if (!o || typeof o !== 'object') throw new Error('payload must be an object');
|
|
||||||
if (typeof o.agent_user_id !== 'string' || !o.agent_user_id.startsWith('@')) {
|
|
||||||
throw new Error('agent_user_id must be a Matrix user id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
||||||
|
|
||||||
import { agentSlug } from './agent-registry.dto.js';
|
|
||||||
import type { AgentSummary } from './agent-registry.dto.js';
|
|
||||||
import type { AppserviceIntent } from './intent.js';
|
|
||||||
|
|
||||||
/** account_data type holding the agent registry on the AS sender user. */
|
|
||||||
export const AGENTS_ACCOUNT_DATA_TYPE = 'org.uscllc.mosaic_as.agents';
|
|
||||||
|
|
||||||
const TOKEN_PREFIX = 'magt_';
|
|
||||||
|
|
||||||
interface StoredAgent {
|
|
||||||
alias: string;
|
|
||||||
host: string;
|
|
||||||
display_name?: string;
|
|
||||||
created_at: string;
|
|
||||||
/** sha256hex of each active token. Plaintext tokens are NEVER stored. */
|
|
||||||
token_hashes: string[];
|
|
||||||
revoked_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AgentRegistry {
|
|
||||||
agents: Record<string, StoredAgent>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sha256hex = (value: string): string => createHash('sha256').update(value).digest('hex');
|
|
||||||
|
|
||||||
const mintToken = (): string => `${TOKEN_PREFIX}${randomBytes(32).toString('base64url')}`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persists scoped/revocable bridge tokens for agent virtual users in Matrix
|
|
||||||
* account_data on the AS sender user (no new infra; survives restart).
|
|
||||||
*
|
|
||||||
* Tokens are stored only as sha256 hashes (the high-entropy `magt_` token makes
|
|
||||||
* plain sha256 safe — no salt/KDF needed since brute force is infeasible).
|
|
||||||
*
|
|
||||||
* KNOWN v1 LIMIT: Synapse caps a single account_data object (default
|
|
||||||
* max_account_data_size, ~100KB). Each agent + hash entry is small, so this
|
|
||||||
* supports thousands of agents, but a very large fleet would eventually need a
|
|
||||||
* dedicated store. Revoked agents with no active tokens are pruned of hashes
|
|
||||||
* (kept as tombstones) to bound growth.
|
|
||||||
*/
|
|
||||||
export class AgentTokenStore {
|
|
||||||
constructor(private readonly intent: AppserviceIntent) {}
|
|
||||||
|
|
||||||
/** Read the registry fresh from account_data (low-frequency ops favor
|
|
||||||
* correctness over caching; verifyToken/list also read fresh). */
|
|
||||||
private async read(): Promise<AgentRegistry> {
|
|
||||||
const data = await this.intent.getSenderAccountData(AGENTS_ACCOUNT_DATA_TYPE);
|
|
||||||
const agents = data?.agents;
|
|
||||||
if (agents && typeof agents === 'object') {
|
|
||||||
return { agents: agents as Record<string, StoredAgent> };
|
|
||||||
}
|
|
||||||
return { agents: {} };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async write(registry: AgentRegistry): Promise<void> {
|
|
||||||
await this.intent.setSenderAccountData(AGENTS_ACCOUNT_DATA_TYPE, {
|
|
||||||
agents: registry.agents,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ensure the virtual user exists, mint a fresh token, store its hash, and
|
|
||||||
* return the plaintext token ONCE. Clears any prior revocation. */
|
|
||||||
async register(opts: {
|
|
||||||
alias: string;
|
|
||||||
host: string;
|
|
||||||
displayName?: string;
|
|
||||||
}): Promise<{ agentUserId: string; token: string }> {
|
|
||||||
const slug = agentSlug(opts.alias, opts.host);
|
|
||||||
const agentUserId = await this.intent.ensureRegistered(slug);
|
|
||||||
if (opts.displayName !== undefined) {
|
|
||||||
await this.intent.setDisplayName(slug, opts.displayName);
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = mintToken();
|
|
||||||
const hash = sha256hex(token);
|
|
||||||
|
|
||||||
const registry = await this.read();
|
|
||||||
const existing = registry.agents[agentUserId];
|
|
||||||
if (existing) {
|
|
||||||
// The agent slug `<alias>-<host>` joins with a `-`, which is also a legal
|
|
||||||
// slug char, so distinct pairs can collide on one Matrix id (e.g.
|
|
||||||
// a/b-c and a-b/c both -> @agent-a-b-c). They ARE the same Matrix user,
|
|
||||||
// but silently overwriting the stored alias/host of a different pair
|
|
||||||
// would conflate two logical agents into one token bucket. Reject the
|
|
||||||
// ambiguous re-registration instead of overwriting.
|
|
||||||
if (existing.alias !== opts.alias || existing.host !== opts.host) {
|
|
||||||
throw new Error(
|
|
||||||
`agent id collision: ${agentUserId} already registered as ` +
|
|
||||||
`${existing.alias}/${existing.host}, refusing ${opts.alias}/${opts.host}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (opts.displayName !== undefined) existing.display_name = opts.displayName;
|
|
||||||
existing.token_hashes = [...existing.token_hashes, hash];
|
|
||||||
delete existing.revoked_at;
|
|
||||||
} else {
|
|
||||||
registry.agents[agentUserId] = {
|
|
||||||
alias: opts.alias,
|
|
||||||
host: opts.host,
|
|
||||||
...(opts.displayName !== undefined ? { display_name: opts.displayName } : {}),
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
token_hashes: [hash],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
await this.write(registry);
|
|
||||||
return { agentUserId, token };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Return the agentUserId bound to an active (non-revoked) token, else null.
|
|
||||||
* Constant-time hash comparison; no early-out on match. */
|
|
||||||
async verifyToken(token: string): Promise<string | null> {
|
|
||||||
if (!token.startsWith(TOKEN_PREFIX)) return null;
|
|
||||||
const presented = Buffer.from(sha256hex(token), 'hex');
|
|
||||||
|
|
||||||
const registry = await this.read();
|
|
||||||
let matched: string | null = null;
|
|
||||||
for (const [agentUserId, agent] of Object.entries(registry.agents)) {
|
|
||||||
if (agent.revoked_at) continue;
|
|
||||||
for (const stored of agent.token_hashes) {
|
|
||||||
const candidate = Buffer.from(stored, 'hex');
|
|
||||||
if (candidate.length === presented.length && timingSafeEqual(candidate, presented)) {
|
|
||||||
// No early break: keep scanning so timing does not reveal match position.
|
|
||||||
matched = agentUserId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return matched;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Revoke all active tokens for an agent. Idempotent; returns count revoked. */
|
|
||||||
async revoke(agentUserId: string): Promise<number> {
|
|
||||||
const registry = await this.read();
|
|
||||||
const agent = registry.agents[agentUserId];
|
|
||||||
if (!agent) return 0;
|
|
||||||
const count = agent.token_hashes.length;
|
|
||||||
agent.token_hashes = [];
|
|
||||||
agent.revoked_at = new Date().toISOString();
|
|
||||||
await this.write(registry);
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** List agents with at least one active token (never advertise revoked/phantom). */
|
|
||||||
async list(): Promise<AgentSummary[]> {
|
|
||||||
const registry = await this.read();
|
|
||||||
const out: AgentSummary[] = [];
|
|
||||||
for (const [agentUserId, agent] of Object.entries(registry.agents)) {
|
|
||||||
if (agent.revoked_at || agent.token_hashes.length === 0) continue;
|
|
||||||
out.push({
|
|
||||||
agent_user_id: agentUserId,
|
|
||||||
alias: agent.alias,
|
|
||||||
host: agent.host,
|
|
||||||
...(agent.display_name !== undefined ? { display_name: agent.display_name } : {}),
|
|
||||||
created_at: agent.created_at,
|
|
||||||
active_token_count: agent.token_hashes.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -50,34 +50,3 @@ export function validateBridgeTyping(input: unknown): asserts input is BridgeTyp
|
|||||||
assertAgentSlug(o.agent);
|
assertAgentSlug(o.agent);
|
||||||
if (typeof o.typing !== 'boolean') throw new Error('typing must be a boolean');
|
if (typeof o.typing !== 'boolean') throw new Error('typing must be a boolean');
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProvisionRoomDto {
|
|
||||||
name: string;
|
|
||||||
alias?: string;
|
|
||||||
topic?: string;
|
|
||||||
invite?: string[];
|
|
||||||
space_id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateProvisionRoom(input: unknown): asserts input is ProvisionRoomDto {
|
|
||||||
const o = input as Partial<ProvisionRoomDto> | null | undefined;
|
|
||||||
if (!o || typeof o !== 'object') throw new Error('payload must be an object');
|
|
||||||
if (typeof o.name !== 'string' || o.name.length === 0) throw new Error('name is required');
|
|
||||||
if (o.alias !== undefined && (!/^[a-z0-9_.-]+$/.test(o.alias) || o.alias.length > 200)) {
|
|
||||||
throw new Error('alias must match [a-z0-9_.-]+ (max 200 chars)');
|
|
||||||
}
|
|
||||||
if (o.invite !== undefined) {
|
|
||||||
if (
|
|
||||||
!Array.isArray(o.invite) ||
|
|
||||||
o.invite.some((u) => typeof u !== 'string' || !u.startsWith('@'))
|
|
||||||
) {
|
|
||||||
throw new Error('invite must be a list of Matrix user ids');
|
|
||||||
}
|
|
||||||
if (o.invite.length > 50) {
|
|
||||||
throw new Error('invite list exceeds maximum of 50');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (o.space_id !== undefined && (typeof o.space_id !== 'string' || !o.space_id.startsWith('!'))) {
|
|
||||||
throw new Error('space_id must be a Matrix room id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,20 +4,8 @@ export { TransactionHandler } from './transactions.js';
|
|||||||
export type { TransactionHandlerOptions } from './transactions.js';
|
export type { TransactionHandlerOptions } from './transactions.js';
|
||||||
export { buildRegistration, registrationToYaml } from './registration.js';
|
export { buildRegistration, registrationToYaml } from './registration.js';
|
||||||
export type { RegistrationOptions } from './registration.js';
|
export type { RegistrationOptions } from './registration.js';
|
||||||
export {
|
export { validateBridgeMessage, validateBridgeTyping } from './bridge.dto.js';
|
||||||
validateBridgeMessage,
|
export type { BridgeMessageDto, BridgeTypingDto } from './bridge.dto.js';
|
||||||
validateBridgeTyping,
|
|
||||||
validateProvisionRoom,
|
|
||||||
} from './bridge.dto.js';
|
|
||||||
export type { BridgeMessageDto, BridgeTypingDto, ProvisionRoomDto } from './bridge.dto.js';
|
|
||||||
export { agentSlug, validateRegisterAgent, validateRevokeAgent } from './agent-registry.dto.js';
|
|
||||||
export type {
|
|
||||||
RegisterAgentDto,
|
|
||||||
RevokeAgentDto,
|
|
||||||
RegisterAgentResponse,
|
|
||||||
AgentSummary,
|
|
||||||
} from './agent-registry.dto.js';
|
|
||||||
export { AgentTokenStore, AGENTS_ACCOUNT_DATA_TYPE } from './agent-store.js';
|
|
||||||
export type {
|
export type {
|
||||||
AppserviceConfig,
|
AppserviceConfig,
|
||||||
EventHandler,
|
EventHandler,
|
||||||
|
|||||||
@@ -172,58 +172,6 @@ export class AppserviceIntent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a room as the AS sender: agents get PL 50 by namespace via the
|
|
||||||
* sender (PL 100); humans invited at default PL. Optionally link into a
|
|
||||||
* space (m.space.child + m.space.parent). Returns the room id. */
|
|
||||||
async createRoom(options: {
|
|
||||||
name: string;
|
|
||||||
alias?: string;
|
|
||||||
topic?: string;
|
|
||||||
invite?: string[];
|
|
||||||
spaceId?: string;
|
|
||||||
}): Promise<{ roomId: string; spaceLinked: boolean; spaceError?: string }> {
|
|
||||||
const body: Record<string, unknown> = {
|
|
||||||
name: options.name,
|
|
||||||
preset: 'private_chat',
|
|
||||||
invite: options.invite ?? [],
|
|
||||||
power_level_content_override: {
|
|
||||||
users: { [this.senderUserId]: 100 },
|
|
||||||
// state_default 50 stays; the AS sender can grant agents as needed.
|
|
||||||
},
|
|
||||||
};
|
|
||||||
if (options.alias) body.room_alias_name = options.alias;
|
|
||||||
if (options.topic) body.topic = options.topic;
|
|
||||||
const res = await this.request('POST', '/_matrix/client/v3/createRoom', {
|
|
||||||
userId: this.senderUserId,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
const roomId = res.room_id;
|
|
||||||
if (typeof roomId !== 'string') throw new Error('createRoom returned no room_id');
|
|
||||||
if (!options.spaceId) {
|
|
||||||
return { roomId, spaceLinked: false };
|
|
||||||
}
|
|
||||||
// Space-link failures must NOT throw: the room already exists, and an
|
|
||||||
// exception would hide the room_id (orphaned room, no recovery path).
|
|
||||||
const encodedSpaceId = encodeURIComponent(options.spaceId);
|
|
||||||
const encodedRoomId = encodeURIComponent(roomId);
|
|
||||||
try {
|
|
||||||
await this.request(
|
|
||||||
'PUT',
|
|
||||||
`/_matrix/client/v3/rooms/${encodedSpaceId}/state/m.space.child/${encodedRoomId}`,
|
|
||||||
{ userId: this.senderUserId, body: { via: [this.cfg.domain], suggested: true } },
|
|
||||||
);
|
|
||||||
await this.request(
|
|
||||||
'PUT',
|
|
||||||
`/_matrix/client/v3/rooms/${encodedRoomId}/state/m.space.parent/${encodedSpaceId}`,
|
|
||||||
{ userId: this.senderUserId, body: { via: [this.cfg.domain], canonical: true } },
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
return { roomId, spaceLinked: false, spaceError: message };
|
|
||||||
}
|
|
||||||
return { roomId, spaceLinked: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Set display name for an agent's virtual user. */
|
/** Set display name for an agent's virtual user. */
|
||||||
async setDisplayName(agent: string, displayName: string): Promise<void> {
|
async setDisplayName(agent: string, displayName: string): Promise<void> {
|
||||||
const userId = await this.ensureRegistered(agent);
|
const userId = await this.ensureRegistered(agent);
|
||||||
@@ -233,30 +181,4 @@ export class AppserviceIntent {
|
|||||||
body: { displayname: displayName },
|
body: { displayname: displayName },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read an account_data object on the AS sender user. Returns null when the
|
|
||||||
* key has never been written (M_NOT_FOUND), so callers can treat that as an
|
|
||||||
* empty store; any other error propagates. */
|
|
||||||
async getSenderAccountData(type: string): Promise<Record<string, unknown> | null> {
|
|
||||||
const user = encodeURIComponent(this.senderUserId);
|
|
||||||
const key = encodeURIComponent(type);
|
|
||||||
try {
|
|
||||||
return await this.request('GET', `/_matrix/client/v3/user/${user}/account_data/${key}`, {
|
|
||||||
userId: this.senderUserId,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof MatrixApiError && err.errcode === 'M_NOT_FOUND') return null;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Write an account_data object on the AS sender user. */
|
|
||||||
async setSenderAccountData(type: string, content: Record<string, unknown>): Promise<void> {
|
|
||||||
const user = encodeURIComponent(this.senderUserId);
|
|
||||||
const key = encodeURIComponent(type);
|
|
||||||
await this.request('PUT', `/_matrix/client/v3/user/${user}/account_data/${key}`, {
|
|
||||||
userId: this.senderUserId,
|
|
||||||
body: content,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
CREATE TYPE "public"."backlog_status" AS ENUM('ready', 'claimed', 'blocked', 'done');--> statement-breakpoint
|
|
||||||
CREATE TABLE "backlog" (
|
|
||||||
"id" text PRIMARY KEY NOT NULL,
|
|
||||||
"title" text NOT NULL,
|
|
||||||
"body" text,
|
|
||||||
"phase" text,
|
|
||||||
"priority" integer DEFAULT 0 NOT NULL,
|
|
||||||
"status" "backlog_status" DEFAULT 'ready' NOT NULL,
|
|
||||||
"depends_on" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
|
||||||
"claim_owner" text,
|
|
||||||
"claim_ttl_seconds" integer,
|
|
||||||
"claimed_at" timestamp with time zone,
|
|
||||||
"attempts" integer DEFAULT 0 NOT NULL,
|
|
||||||
"idempotency_key" text,
|
|
||||||
"acceptance" jsonb,
|
|
||||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE INDEX "backlog_status_priority_idx" ON "backlog" USING btree ("status","priority");--> statement-breakpoint
|
|
||||||
CREATE INDEX "backlog_status_claimed_at_idx" ON "backlog" USING btree ("status","claimed_at");--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX "backlog_idempotency_key_idx" ON "backlog" USING btree ("idempotency_key");
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user