Compare commits

..

5 Commits

Author SHA1 Message Date
Jarvis
2708de55b9 docs(prd): fold 2026-07-09 debate-pass findings into Mosaic Platform PRD
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
All 24 panel findings (P0#1-5, P1#6-13, P2/P3#14-24) accepted and folded:
- DEBATE-FINDINGS.md: disposition record + open-disagreement judgment calls
- north-star: NS-14, J2a/J2b split, J6 wake router, K3 push pipeline,
  M1 memory subsystem, workstream L (MALS lineage), real DAG edges
- J: workspace lease J-R16, resume protocol J-R18, write-side trust,
  needs-decision lifecycle, atomic card publish
- P: CAS approval state machine, gateway-only credentials, default-closed
  gating, principal resolution + policy snapshot (Codex #2/#7)
- W: butt-in exclusive lease, structured control plane, break-glass
  doctrine, trial metric panels
- Q: external_refs table, pending-link, echo-loop guard, crash barriers
- X: storage authority (Postgres sole record), machine-generated census,
  memory retrieval-eval retirement gate, honest rollback scope,
  4 artifact-existence machine gates, bounded day-30 review
- README: Gate Zero, conflict register, DoD line, silent-roster rule

One item OPEN for Jason at ratification: gate superstructure (§3.1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMoEx7hfdFGjUiCHuN1RRi
2026-07-09 16:29:37 -05:00
Jason Woltje
6b8c3c5d3a docs(fleet): refine Mosaic Platform PRD per Jason's review (R1–R7)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Post-review refinements to the DRAFT PRD set (decisions D1–D12 unchanged):

- R1: PRD-permission-relay is now self-contained — its cited design source
  (guard-rails-capability-permissions.md) is absent from origin/main, so the
  essential model (prepare-freely/execute-with-approval, permission levels,
  resource:action grants) is folded in as the authoritative spec.
- R2: Phase-1 conversation channel is tmux/CLI via the f4 Phase-1 tmux
  connector (operator issues /remote-control); Matrix room (J5/K1) is Phase 2.
- R3: Jarvis is a Level-0 orchestrator (delegation + subagents, never executes
  coding/infra); AC-NS-8 latency isolation is a separate-model-capacity
  guarantee, not just a separate process (NS-10, AC-NS-8, ASM-5).
- R4: jarvis-brain reframed as the P0 (prototype) Mosaic Stack; tenant-1
  migration targets the proper stack storage layer (vector DB + enhanced
  memory + flat-file backends). Agent memory/runbooks migrate live into the
  memory subsystem and are NOT frozen; repo retires read-only only after both
  PA data and agent memory are verified migrated (X2/X-R7).
- R5: homelab→USC promotion is an explicit owner sign-off gate, not an
  automated metric (X deployment-scope).
- R6: verified K1 is covered by f4-matrix-connector.md Phase 2 (no new doc).
- R7: clarified always-available Opus ≠ standing cost (idle = no bill) (J-R2).
- Also normalized PRD-backlog-providers.md and PRD-webui-fleet-control.md with
  prettier (whitespace-only) — they were non-conformant as originally pushed
  and would have re-broken repo-wide format:check on merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMoEx7hfdFGjUiCHuN1RRi
2026-07-09 14:50:54 -05:00
Hermes Agent
d1f69bfd37 docs(fleet): DRAFT PRD proposal — Jarvis HMI main agent, Matrix-first transport, webUI fleet control, permission relay, backlog providers, Hermes decommission
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Ratification draft (Jason, 2026-07-09) extending NORTH_STAR.yaml with NS-10..NS-13
and workstreams J/K/W/P/Q/X. Written against origin/main's fleet suite (F4/F6
anchors, H2 profiles, native backlog per ASM-1). Homelab is the trial
environment (D12); USC/web1 adopts post-trial. See proposals/mosaic-platform-prd/README.md
for the full decision record D1-D12.

Not yet merged into NORTH_STAR.yaml — landing and card decomposition are the
homelab orchestrator's mission.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:19:05 -05:00
4e9e053800 fix(tools/tmux): unique per-invocation paste buffer; track auto-submit-drafts.sh (#697)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-09 17:37:40 +00:00
851c67c27b docs(bootstrap): add python-is-python3 to agent-host prerequisites (#694)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-03 09:38:40 +00:00
55 changed files with 1143 additions and 3140 deletions

View File

@@ -1,5 +1,5 @@
# Build, publish npm packages, and push Docker images # Build, publish npm packages, and push Docker images
# Runs on main for stable publishes and on next for integration-line prereleases/images # Runs only on main branch push/tag
variables: variables:
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine + # Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
@@ -23,21 +23,9 @@ variables:
- 'docs/**' - 'docs/**'
- '**/*.md' - '**/*.md'
- '.woodpecker/**' - '.woodpecker/**'
- event: [push, manual]
branch: next
- &main_image_build_when
- event: tag
- event: [push, manual]
branch: main
path:
exclude:
- 'packages/mosaic/**'
- 'docs/**'
- '**/*.md'
- '.woodpecker/**'
when: when:
- branch: [main, next] - branch: [main]
event: [push, manual, tag] event: [push, manual, tag]
steps: steps:
@@ -115,84 +103,6 @@ steps:
depends_on: depends_on:
- build - build
publish-next-npm:
image: *node_image
# Durable @next integration-line publish. Runs only on next; never writes
# the latest dist-tag and never commits the computed prerelease versions.
when:
- event: [push, manual]
branch: next
environment:
NPM_TOKEN:
from_secret: gitea_token
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_PIPELINE_NUMBER: ${CI_PIPELINE_NUMBER}
commands:
- *enable_pnpm
- |
if [ "$CI_COMMIT_BRANCH" != "next" ]; then
echo "[publish-next] FATAL: publish-next-npm may only run on next (got '$CI_COMMIT_BRANCH')" >&2
exit 1
fi
if [ -z "$CI_PIPELINE_NUMBER" ]; then
echo "[publish-next] FATAL: CI_PIPELINE_NUMBER is required for prerelease versioning" >&2
exit 1
fi
echo "//git.mosaicstack.dev/api/packages/mosaicstack/npm/:_authToken=$NPM_TOKEN" > ~/.npmrc
echo "@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/" >> ~/.npmrc
DIST_TAGS_JSON="$(npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json)"
DIST_TAGS_JSON="$DIST_TAGS_JSON" node -e 'const tags = JSON.parse(process.env.DIST_TAGS_JSON || "{}"); if (!tags || typeof tags !== "object" || !Object.hasOwn(tags, "latest")) { throw new Error("Gitea npm registry did not return a usable dist-tags object"); } console.log("[publish-next] registry dist-tags OK: latest=" + tags.latest);'
node <<'NODE'
const fs = require('node:fs');
const path = require('node:path');
const pipelineNumber = process.env.CI_PIPELINE_NUMBER;
const roots = ['apps', 'packages', 'plugins'];
const updated = [];
function walk(dir) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.turbo') continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
const packagePath = path.join(fullPath, 'package.json');
if (fs.existsSync(packagePath)) updatePackage(packagePath);
walk(fullPath);
}
}
}
function updatePackage(packagePath) {
const manifest = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
if (!manifest.name?.startsWith('@mosaicstack/') || manifest.private) return;
const stableMatch = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(manifest.version);
if (!stableMatch) {
throw new Error(manifest.name + " has unsupported semver version '" + manifest.version + "'");
}
const [, major, minor, patch] = stableMatch;
const oldVersion = manifest.version;
manifest.version = major + '.' + minor + '.' + (Number(patch) + 1) + '-next.' + pipelineNumber;
fs.writeFileSync(packagePath, JSON.stringify(manifest, null, 2) + '\n');
updated.push(manifest.name + ' ' + oldVersion + ' -> ' + manifest.version);
}
for (const root of roots) walk(root);
if (updated.length === 0) throw new Error('No publishable @mosaicstack/* packages found');
console.log('[publish-next] computed prerelease versions for ' + updated.length + ' packages:');
for (const line of updated) console.log('[publish-next] ' + line);
NODE
pnpm --filter "@mosaicstack/*" --filter "!@mosaicstack/web" --filter "!@mosaicstack/mosaic-as" publish --no-git-checks --access public --tag next
EXPECTED_VERSION="$(node -p "require('./packages/mosaic/package.json').version")"
RESOLVED_VERSION="$(npm view @mosaicstack/mosaic@next version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/)"
if [ "$RESOLVED_VERSION" != "$EXPECTED_VERSION" ]; then
echo "[publish-next] FATAL: @mosaicstack/mosaic@next resolved '$RESOLVED_VERSION', expected '$EXPECTED_VERSION'" >&2
exit 1
fi
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
depends_on:
- build
# TODO: Uncomment when ready to publish to npmjs.org # TODO: Uncomment when ready to publish to npmjs.org
# publish-npmjs: # publish-npmjs:
# image: *node_image # image: *node_image
@@ -224,17 +134,8 @@ steps:
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json - echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
- | - |
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}" DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
if [ "$CI_COMMIT_BRANCH" = "next" ]; then if [ "$CI_COMMIT_BRANCH" = "main" ]; then
if [ -n "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: next gateway publish must be sha-only; refusing tag '$CI_COMMIT_TAG'" >&2
exit 1
fi
echo "[publish] next gateway publish is sha-only"
elif [ "$CI_COMMIT_BRANCH" = "main" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest" DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
elif [ -z "$CI_COMMIT_TAG" ]; then
echo "[publish] FATAL: gateway image publish may only run for main, next, or tag events" >&2
exit 1
fi fi
if [ -n "$CI_COMMIT_TAG" ]; then if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG" DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
@@ -245,7 +146,7 @@ steps:
build-appservice: build-appservice:
image: gcr.io/kaniko-project/executor:debug image: gcr.io/kaniko-project/executor:debug
when: *main_image_build_when when: *image_build_when
environment: environment:
REGISTRY_USER: REGISTRY_USER:
from_secret: gitea_username from_secret: gitea_username
@@ -271,7 +172,7 @@ steps:
build-web: build-web:
image: gcr.io/kaniko-project/executor:debug image: gcr.io/kaniko-project/executor:debug
when: *main_image_build_when when: *image_build_when
environment: environment:
REGISTRY_USER: REGISTRY_USER:
from_secret: gitea_username from_secret: gitea_username

View File

@@ -30,16 +30,6 @@ This installs both components:
| **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` | | **Framework** | Bash launcher, guides, runtime configs, tools, skills | `~/.config/mosaic/` |
| **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` | | **@mosaicstack/mosaic** | Unified `mosaic` CLI — TUI, gateway client, wizard, auto-updater | `~/.npm-global/bin/` |
### Install lanes
| Lane | Command | Use when | Source |
| ------------------------ | ------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------- |
| Stable | `bash tools/install.sh` | You want the released Mosaic CLI/framework | npm registry `@mosaicstack/mosaic@latest` + framework archive at `main` |
| Prerelease integration | `bash tools/install.sh --next` | You want the current `next` integration branch | Build-from-source at `next` |
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are testing a branch before release; `--ref` wins | Build-from-source at the requested ref |
`--next` is shorthand for the prerelease integration lane: it enables source-build mode and uses `next` unless an explicit `--ref` or `MOSAIC_REF` is provided.
After install, the wizard runs automatically or you can invoke it manually: After install, the wizard runs automatically or you can invoke it manually:
```bash ```bash
@@ -346,9 +336,7 @@ The CLI also performs a background update check on every invocation (cached for
bash tools/install.sh --check # Version check only bash tools/install.sh --check # Version check only
bash tools/install.sh --framework # Framework only (skip npm CLI) bash tools/install.sh --framework # Framework only (skip npm CLI)
bash tools/install.sh --cli # npm CLI only (skip framework) bash tools/install.sh --cli # npm CLI only (skip framework)
bash tools/install.sh --next # Prerelease lane: source build from next bash tools/install.sh --ref v1.0 # Install from a specific git ref
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
bash tools/install.sh --yes # Non-interactive, accept all defaults bash tools/install.sh --yes # Non-interactive, accept all defaults
bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard bash tools/install.sh --no-auto-launch # Skip auto-launch of wizard
``` ```

View File

@@ -1,519 +0,0 @@
/**
* Federation M3 single-gateway integration tests (FED-M3-10).
*
* Covers MILESTONES.md M3 acceptance:
* - #6: malformed certificate OIDs fail with 401; valid cert + revoked grant fails with 403.
* - #7: max_rows_per_query caps list results.
*
* Strategy:
* - Real PostgreSQL via @mosaicstack/db.
* - Mocked TLS context/Fastify request shim for FederationAuthGuard.
* - Direct controller calls using the real POST /api/federation/v1/list/:resource contract.
*
* Run:
* FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- \
* src/__tests__/integration/federation-m3-list.integration.test.ts
*/
import 'reflect-metadata';
import * as crypto from 'node:crypto';
import type { ExecutionContext } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import type { FastifyReply, FastifyRequest } from 'fastify';
import {
and,
createDb,
eq,
federationGrants,
federationPeers,
inArray,
missionTasks,
missions,
projects,
tasks,
teamMembers,
teams,
type Db,
type DbHandle,
users,
} from '@mosaicstack/db';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { DB } from '../../database/database.module.js';
import { GrantsService } from '../../federation/grants.service.js';
import { FederationAuthGuard } from '../../federation/server/federation-auth.guard.js';
import { FederationScopeService } from '../../federation/server/scope.service.js';
import { FederationListQueryService } from '../../federation/server/verbs/list-query.service.js';
import { ListController } from '../../federation/server/verbs/list.controller.js';
import {
makeMosaicIssuedCert,
makeSelfSignedCert,
} from '../../federation/__tests__/helpers/test-cert.js';
const run = process.env['FEDERATED_INTEGRATION'] === '1';
const PG_URL = process.env['DATABASE_URL'] ?? 'postgresql://mosaic:mosaic@localhost:5433/mosaic';
const RUN_ID = `fed-m3-10-${crypto.randomUUID()}`;
const CERT_SERIAL_HEX = crypto.randomUUID().replace(/-/g, '').toUpperCase();
interface TestIds {
readonly subjectUserId: string;
readonly otherUserId: string;
readonly peerId: string;
readonly revokedPeerId: string;
readonly activeGrantId: string;
readonly revokedGrantId: string;
readonly subjectProjectId: string;
readonly subjectMissionId: string;
readonly otherProjectId: string;
readonly teamId: string;
readonly unauthorizedTeamId: string;
readonly teamProjectId: string;
readonly taskIds: readonly string[];
readonly excludedTaskIds: readonly string[];
readonly subjectNoteId: string;
readonly otherUserNoteId: string;
}
function pemToDer(pem: string): Buffer {
return Buffer.from(
pem
.replace(/-----BEGIN CERTIFICATE-----/, '')
.replace(/-----END CERTIFICATE-----/, '')
.replace(/\s+/g, ''),
'base64',
);
}
function makeFederationRequest(certPem: string): FastifyRequest {
return {
raw: {
socket: {
getPeerCertificate: () => ({
raw: pemToDer(certPem),
serialNumber: CERT_SERIAL_HEX,
}),
},
},
} as unknown as FastifyRequest;
}
function makeGuardContext(request: FastifyRequest): {
readonly context: ExecutionContext;
readonly sent: { statusCode?: number; payload?: unknown };
} {
const sent: { statusCode?: number; payload?: unknown } = {};
const reply = {
status: (statusCode: number) => {
sent.statusCode = statusCode;
return {
header: () => ({
send: (payload: unknown) => {
sent.payload = payload;
},
}),
};
},
} as unknown as FastifyReply;
const context = {
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => reply,
}),
} as unknown as ExecutionContext;
return { context, sent };
}
async function insertUser(db: Db, id: string, label: string): Promise<void> {
await db.insert(users).values({
id,
name: `${RUN_ID}-${label}`,
email: `${RUN_ID}-${label}@federation-test.invalid`,
emailVerified: false,
});
}
async function seedFixtures(db: Db): Promise<TestIds> {
const subjectUserId = `${RUN_ID}-subject`;
const otherUserId = `${RUN_ID}-other`;
const peerId = crypto.randomUUID();
const revokedPeerId = crypto.randomUUID();
const activeGrantId = crypto.randomUUID();
const revokedGrantId = crypto.randomUUID();
const subjectProjectId = crypto.randomUUID();
const subjectMissionId = crypto.randomUUID();
const otherProjectId = crypto.randomUUID();
const teamId = crypto.randomUUID();
const unauthorizedTeamId = crypto.randomUUID();
const teamProjectId = crypto.randomUUID();
const taskIds = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()] as const;
const excludedTaskIds = [crypto.randomUUID(), crypto.randomUUID()] as const;
const subjectNoteId = crypto.randomUUID();
const otherUserNoteId = crypto.randomUUID();
await insertUser(db, subjectUserId, 'subject');
await insertUser(db, otherUserId, 'other');
await db.insert(teams).values([
{
id: teamId,
name: `${RUN_ID} allowed team`,
slug: `${RUN_ID}-allowed-team`,
ownerId: subjectUserId,
managerId: subjectUserId,
},
{
id: unauthorizedTeamId,
name: `${RUN_ID} unauthorized team`,
slug: `${RUN_ID}-unauthorized-team`,
ownerId: otherUserId,
managerId: otherUserId,
},
]);
await db.insert(teamMembers).values([
{ teamId, userId: subjectUserId, role: 'member' },
{ teamId: unauthorizedTeamId, userId: subjectUserId, role: 'member' },
]);
await db.insert(projects).values([
{
id: subjectProjectId,
name: `${RUN_ID} subject personal project`,
ownerType: 'user',
ownerId: subjectUserId,
},
{
id: otherProjectId,
name: `${RUN_ID} other personal project`,
ownerType: 'user',
ownerId: otherUserId,
},
{
id: teamProjectId,
name: `${RUN_ID} unauthorized team project`,
ownerType: 'team',
teamId: unauthorizedTeamId,
},
]);
await db.insert(missions).values({
id: subjectMissionId,
name: `${RUN_ID} subject mission`,
projectId: subjectProjectId,
userId: subjectUserId,
});
await db.insert(tasks).values([
{
id: taskIds[0],
title: `${RUN_ID} visible task 1`,
missionId: subjectMissionId,
createdAt: new Date('2026-06-25T03:00:00.000Z'),
updatedAt: new Date('2026-06-25T03:00:00.000Z'),
},
{
id: taskIds[1],
title: `${RUN_ID} visible task 2`,
projectId: subjectProjectId,
createdAt: new Date('2026-06-25T02:00:00.000Z'),
updatedAt: new Date('2026-06-25T02:00:00.000Z'),
},
{
id: taskIds[2],
title: `${RUN_ID} visible task 3`,
projectId: subjectProjectId,
createdAt: new Date('2026-06-25T01:00:00.000Z'),
updatedAt: new Date('2026-06-25T01:00:00.000Z'),
},
{
id: excludedTaskIds[0],
title: `${RUN_ID} other user task`,
projectId: otherProjectId,
createdAt: new Date('2026-06-25T04:00:00.000Z'),
updatedAt: new Date('2026-06-25T04:00:00.000Z'),
},
{
id: excludedTaskIds[1],
title: `${RUN_ID} unauthorized team task`,
projectId: teamProjectId,
createdAt: new Date('2026-06-25T05:00:00.000Z'),
updatedAt: new Date('2026-06-25T05:00:00.000Z'),
},
]);
await db.insert(missionTasks).values([
{
id: subjectNoteId,
missionId: subjectMissionId,
userId: subjectUserId,
notes: `${RUN_ID} subject visible note`,
createdAt: new Date('2026-06-25T03:30:00.000Z'),
updatedAt: new Date('2026-06-25T03:30:00.000Z'),
},
{
id: otherUserNoteId,
missionId: subjectMissionId,
userId: otherUserId,
notes: `${RUN_ID} other user note on subject mission`,
createdAt: new Date('2026-06-25T04:30:00.000Z'),
updatedAt: new Date('2026-06-25T04:30:00.000Z'),
},
]);
await db.insert(federationPeers).values([
{
id: peerId,
commonName: `${RUN_ID}-active-peer`,
displayName: `${RUN_ID} Active Peer`,
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
certSerial: CERT_SERIAL_HEX,
certNotAfter: new Date(Date.now() + 86_400_000),
state: 'active',
},
{
id: revokedPeerId,
commonName: `${RUN_ID}-revoked-peer`,
displayName: `${RUN_ID} Revoked Peer`,
certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n',
certSerial: `${CERT_SERIAL_HEX}${RUN_ID.replace(/-/g, '').slice(0, 8).toUpperCase()}`,
certNotAfter: new Date(Date.now() + 86_400_000),
state: 'active',
},
]);
await db.insert(federationGrants).values([
{
id: activeGrantId,
peerId,
subjectUserId,
status: 'active',
scope: {
resources: ['tasks', 'notes'],
excluded_resources: [],
filters: {
tasks: { include_personal: true, include_teams: [] },
notes: { include_personal: true, include_teams: [] },
},
max_rows_per_query: 2,
},
},
{
id: revokedGrantId,
peerId,
subjectUserId,
status: 'revoked',
revokedAt: new Date(),
revokedReason: `${RUN_ID} revoked grant fixture`,
scope: {
resources: ['tasks'],
excluded_resources: [],
max_rows_per_query: 2,
},
},
]);
return {
subjectUserId,
otherUserId,
peerId,
revokedPeerId,
activeGrantId,
revokedGrantId,
subjectProjectId,
subjectMissionId,
otherProjectId,
teamId,
unauthorizedTeamId,
teamProjectId,
taskIds,
excludedTaskIds,
subjectNoteId,
otherUserNoteId,
};
}
async function cleanupFixtures(db: Db, ids: TestIds | undefined): Promise<void> {
if (!ids) {
return;
}
await db
.delete(missionTasks)
.where(inArray(missionTasks.id, [ids.subjectNoteId, ids.otherUserNoteId]))
.catch(() => {});
await db
.delete(tasks)
.where(inArray(tasks.id, [...ids.taskIds, ...ids.excludedTaskIds]))
.catch(() => {});
await db
.delete(missions)
.where(eq(missions.id, ids.subjectMissionId))
.catch(() => {});
await db
.delete(projects)
.where(inArray(projects.id, [ids.subjectProjectId, ids.otherProjectId, ids.teamProjectId]))
.catch(() => {});
await db
.delete(teamMembers)
.where(
and(
eq(teamMembers.userId, ids.subjectUserId),
inArray(teamMembers.teamId, [ids.teamId, ids.unauthorizedTeamId]),
),
)
.catch(() => {});
await db
.delete(teams)
.where(inArray(teams.id, [ids.teamId, ids.unauthorizedTeamId]))
.catch(() => {});
await db
.delete(federationGrants)
.where(inArray(federationGrants.id, [ids.activeGrantId, ids.revokedGrantId]))
.catch(() => {});
await db
.delete(federationPeers)
.where(inArray(federationPeers.id, [ids.peerId, ids.revokedPeerId]))
.catch(() => {});
await db
.delete(users)
.where(inArray(users.id, [ids.subjectUserId, ids.otherUserId]))
.catch(() => {});
}
describe.skipIf(!run)('federation M3 list verb — single-gateway integration', () => {
let handle: DbHandle;
let db: Db;
let moduleRef: TestingModule;
let guard: FederationAuthGuard;
let listController: ListController;
let ids: TestIds | undefined;
beforeAll(async () => {
handle = createDb(PG_URL);
db = handle.db;
ids = await seedFixtures(db);
moduleRef = await Test.createTestingModule({
controllers: [ListController],
providers: [
{ provide: DB, useValue: db },
GrantsService,
FederationAuthGuard,
FederationScopeService,
FederationListQueryService,
],
}).compile();
guard = moduleRef.get(FederationAuthGuard);
listController = moduleRef.get(ListController);
}, 30_000);
afterAll(async () => {
await moduleRef?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
await cleanupFixtures(db, ids).catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
await handle?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e));
});
it('#6 — rejects a client cert with malformed/missing Mosaic OIDs with 401', async () => {
const malformedOidCert = await makeSelfSignedCert();
const request = makeFederationRequest(malformedOidCert);
const { context, sent } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(false);
expect(sent.statusCode).toBe(401);
expect(sent.payload).toMatchObject({
error: {
code: 'unauthorized',
message: expect.stringContaining('missing required OID'),
},
});
expect(request.federationContext).toBeUndefined();
});
it('#6 — rejects a valid client cert when its grant is revoked with 403', async () => {
expect(ids).toBeDefined();
const revokedCert = await makeMosaicIssuedCert({
grantId: ids!.revokedGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(revokedCert);
const { context, sent } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(false);
expect(sent.statusCode).toBe(403);
expect(sent.payload).toMatchObject({
error: {
code: 'forbidden',
message: 'Federation access denied',
},
});
expect(request.federationContext).toBeUndefined();
});
it('#7 — enforces max_rows_per_query on POST /api/federation/v1/list/:resource', async () => {
expect(ids).toBeDefined();
const activeCert = await makeMosaicIssuedCert({
grantId: ids!.activeGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(activeCert);
const { context } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(true);
const response = await listController.list('tasks', request, { limit: 100 });
const returnedIds = response.items.map((item) => item['id']);
expect(response.items).toHaveLength(2);
expect(response._truncated).toBe(true);
expect(response.nextCursor).toEqual(expect.any(String));
expect(returnedIds).toEqual([ids!.taskIds[0], ids!.taskIds[1]]);
expect(returnedIds).not.toContain(ids!.taskIds[2]);
for (const excludedId of ids!.excludedTaskIds) {
expect(returnedIds).not.toContain(excludedId);
}
expect(response.items.every((item) => item._source === 'local')).toBe(true);
});
it('excludes another user mission task notes on the same authorized mission', async () => {
expect(ids).toBeDefined();
const activeCert = await makeMosaicIssuedCert({
grantId: ids!.activeGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(activeCert);
const { context } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(true);
const response = await listController.list('notes', request, { limit: 10 });
const returnedIds = response.items.map((item) => item['id']);
expect(returnedIds).toEqual([ids!.subjectNoteId]);
expect(returnedIds).not.toContain(ids!.otherUserNoteId);
expect(response.items.every((item) => item._source === 'local')).toBe(true);
});
it('fails closed for unsupported list resources', async () => {
expect(ids).toBeDefined();
const activeCert = await makeMosaicIssuedCert({
grantId: ids!.activeGrantId,
subjectUserId: ids!.subjectUserId,
});
const request = makeFederationRequest(activeCert);
const { context } = makeGuardContext(request);
await expect(guard.canActivate(context)).resolves.toBe(true);
await expect(listController.list('widgets', request, {})).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Requested federation resource is not supported',
},
},
status: 403,
});
});
});

View File

@@ -1,11 +1,9 @@
import { Controller, Get, Inject, Optional, UseGuards } from '@nestjs/common'; import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { sql, type Db } from '@mosaicstack/db'; import { sql, type Db } from '@mosaicstack/db';
import { createQueue } from '@mosaicstack/queue'; import { createQueue } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { DB } from '../database/database.module.js'; import { DB } from '../database/database.module.js';
import { AgentService } from '../agent/agent.service.js'; import { AgentService } from '../agent/agent.service.js';
import { ProviderService } from '../agent/provider.service.js'; import { ProviderService } from '../agent/provider.service.js';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import { AdminGuard } from './admin.guard.js'; import { AdminGuard } from './admin.guard.js';
import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js'; import type { HealthStatusDto, ServiceStatusDto } from './admin.dto.js';
@@ -16,9 +14,6 @@ export class AdminHealthController {
@Inject(DB) private readonly db: Db, @Inject(DB) private readonly db: Db,
@Inject(AgentService) private readonly agentService: AgentService, @Inject(AgentService) private readonly agentService: AgentService,
@Inject(ProviderService) private readonly providerService: ProviderService, @Inject(ProviderService) private readonly providerService: ProviderService,
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) {} ) {}
@Get() @Get()
@@ -60,14 +55,6 @@ export class AdminHealthController {
} }
private async checkCache(): Promise<ServiceStatusDto> { private async checkCache(): Promise<ServiceStatusDto> {
// On Local tier there is no Redis. The cache is intentionally absent, which
// is a healthy state for this tier — report 'ok' rather than opening a new
// ioredis connection on every admin health check (which would spam
// ECONNREFUSED and create/destroy a connection per request). latencyMs 0
// signals "no cache backend to measure" for this tier.
if (this.mosaicConfig?.queue?.type === 'local') {
return { status: 'ok', latencyMs: 0 };
}
const start = Date.now(); const start = Date.now();
const handle = createQueue(); const handle = createQueue();
try { try {

View File

@@ -21,10 +21,7 @@ export class CommandExecutorService {
@Inject(AgentService) private readonly agentService: AgentService, @Inject(AgentService) private readonly agentService: AgentService,
@Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService, @Inject(SystemOverrideService) private readonly systemOverride: SystemOverrideService,
@Inject(SessionGCService) private readonly sessionGC: SessionGCService, @Inject(SessionGCService) private readonly sessionGC: SessionGCService,
// On Local tier COMMANDS_REDIS is null — provider login caching is skipped. @Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
@Optional()
@Inject(COMMANDS_REDIS)
private readonly redis: QueueHandle['redis'] | null,
@Inject(BRAIN) private readonly brain: Brain, @Inject(BRAIN) private readonly brain: Brain,
@Optional() @Optional()
@Inject(forwardRef(() => ReloadService)) @Inject(forwardRef(() => ReloadService))
@@ -406,16 +403,14 @@ export class CommandExecutorService {
}; };
} }
const pollToken = crypto.randomUUID(); const pollToken = crypto.randomUUID();
const pollKey = `mosaic:auth:poll:${pollToken}`; const key = `mosaic:auth:poll:${pollToken}`;
if (this.redis) {
// Store pending state in Valkey (TTL 5 minutes) // Store pending state in Valkey (TTL 5 minutes)
await this.redis.set( await this.redis.set(
pollKey, key,
JSON.stringify({ status: 'pending', provider: providerName, userId }), JSON.stringify({ status: 'pending', provider: providerName, userId }),
'EX', 'EX',
300, 300,
); );
}
// In production this would construct an OAuth URL // In production this would construct an OAuth URL
const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`; const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`;
return { return {

View File

@@ -1,7 +1,5 @@
import { forwardRef, Inject, Module, Optional, type OnApplicationShutdown } from '@nestjs/common'; import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue'; import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import { ChatModule } from '../chat/chat.module.js'; import { ChatModule } from '../chat/chat.module.js';
import { GCModule } from '../gc/gc.module.js'; import { GCModule } from '../gc/gc.module.js';
import { ReloadModule } from '../reload/reload.module.js'; import { ReloadModule } from '../reload/reload.module.js';
@@ -16,17 +14,13 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
providers: [ providers: [
{ {
provide: COMMANDS_QUEUE_HANDLE, provide: COMMANDS_QUEUE_HANDLE,
useFactory: (config: MosaicConfig | null): QueueHandle | null => { useFactory: (): QueueHandle => {
// On Local tier there is no Redis — skip the ioredis connection.
// CommandExecutorService falls back to no-cache for /provider login on local.
if (config?.queue?.type === 'local') return null;
return createQueue(); return createQueue();
}, },
inject: [MOSAIC_CONFIG],
}, },
{ {
provide: COMMANDS_REDIS, provide: COMMANDS_REDIS,
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null, useFactory: (handle: QueueHandle) => handle.redis,
inject: [COMMANDS_QUEUE_HANDLE], inject: [COMMANDS_QUEUE_HANDLE],
}, },
CommandRegistryService, CommandRegistryService,
@@ -35,13 +29,9 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
exports: [CommandRegistryService, CommandExecutorService], exports: [CommandRegistryService, CommandExecutorService],
}) })
export class CommandsModule implements OnApplicationShutdown { export class CommandsModule implements OnApplicationShutdown {
constructor( constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
@Optional()
@Inject(COMMANDS_QUEUE_HANDLE)
private readonly handle: QueueHandle | null,
) {}
async onApplicationShutdown(): Promise<void> { async onApplicationShutdown(): Promise<void> {
await this.handle?.close().catch(() => {}); await this.handle.close().catch(() => {});
} }
} }

View File

@@ -5,8 +5,6 @@ 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 { CapabilitiesController } from './server/verbs/capabilities.controller.js';
import { GetController } from './server/verbs/get.controller.js';
import { FederationGetQueryService } from './server/verbs/get-query.service.js';
import { GrantsService } from './grants.service.js'; import { GrantsService } from './grants.service.js';
import { FederationClientService, QuerySourceService } from './client/index.js'; import { FederationClientService, QuerySourceService } from './client/index.js';
import { FederationAuthGuard, FederationScopeService } from './server/index.js'; import { FederationAuthGuard, FederationScopeService } from './server/index.js';
@@ -14,13 +12,7 @@ import { ListController } from './server/verbs/list.controller.js';
import { FederationListQueryService } from './server/verbs/list-query.service.js'; import { FederationListQueryService } from './server/verbs/list-query.service.js';
@Module({ @Module({
controllers: [ controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
EnrollmentController,
FederationController,
CapabilitiesController,
ListController,
GetController,
],
providers: [ providers: [
AdminGuard, AdminGuard,
CaService, CaService,
@@ -31,7 +23,6 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
FederationAuthGuard, FederationAuthGuard,
FederationScopeService, FederationScopeService,
FederationListQueryService, FederationListQueryService,
FederationGetQueryService,
], ],
exports: [ exports: [
CaService, CaService,
@@ -42,7 +33,6 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
FederationAuthGuard, FederationAuthGuard,
FederationScopeService, FederationScopeService,
FederationListQueryService, FederationListQueryService,
FederationGetQueryService,
], ],
}) })
export class FederationModule {} export class FederationModule {}

View File

@@ -1,348 +0,0 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import {
createPgliteDb,
missionTasks,
missions,
projects,
runPgliteMigrations,
teams,
users,
type Db,
type DbHandle,
} from '@mosaicstack/db';
import type { FederationScopeQueryFilter } from '../../scope.service.js';
import { FederationGetQueryService } from '../get-query.service.js';
const CREDENTIAL_FILTER: FederationScopeQueryFilter = {
resource: 'credentials',
subjectUserId: 'user-1',
includePersonal: true,
teamIds: [],
limit: 1,
maxRowsPerQuery: 25,
};
const SUBJECT_USER_ID = 'fed-m3-06-subject';
const OTHER_USER_ID = 'fed-m3-06-other';
const TEAM_ID = '06000000-0000-4000-8000-000000000001';
const UNAUTHORIZED_TEAM_ID = '06000000-0000-4000-8000-000000000002';
const PERSONAL_PROJECT_ID = '06000000-0000-4000-8000-000000000101';
const TEAM_PROJECT_ID = '06000000-0000-4000-8000-000000000102';
const UNAUTHORIZED_PROJECT_ID = '06000000-0000-4000-8000-000000000103';
const PERSONAL_MISSION_ID = '06000000-0000-4000-8000-000000000201';
const TEAM_MISSION_ID = '06000000-0000-4000-8000-000000000202';
const UNAUTHORIZED_MISSION_ID = '06000000-0000-4000-8000-000000000203';
const SUBJECT_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000301';
const OTHER_TEAM_NOTE_ID = '06000000-0000-4000-8000-000000000302';
const SUBJECT_PERSONAL_NOTE_ID = '06000000-0000-4000-8000-000000000303';
const SUBJECT_UNAUTHORIZED_NOTE_ID = '06000000-0000-4000-8000-000000000304';
let dbHandle: DbHandle | undefined;
function makeService() {
return new FederationGetQueryService({} as Db);
}
function makeDbService() {
if (!dbHandle) {
throw new Error('test DB not initialized');
}
return new FederationGetQueryService(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-06 Team',
slug: 'fed-m3-06-team',
ownerId: SUBJECT_USER_ID,
managerId: SUBJECT_USER_ID,
},
{
id: UNAUTHORIZED_TEAM_ID,
name: 'FED-M3-06 Unauthorized Team',
slug: 'fed-m3-06-unauthorized-team',
ownerId: OTHER_USER_ID,
managerId: OTHER_USER_ID,
},
]);
await dbHandle.db.insert(projects).values([
{
id: PERSONAL_PROJECT_ID,
name: 'FED-M3-06 Personal Project',
ownerId: SUBJECT_USER_ID,
ownerType: 'user',
},
{
id: TEAM_PROJECT_ID,
name: 'FED-M3-06 Team Project',
teamId: TEAM_ID,
ownerType: 'team',
},
{
id: UNAUTHORIZED_PROJECT_ID,
name: 'FED-M3-06 Unauthorized Project',
teamId: UNAUTHORIZED_TEAM_ID,
ownerType: 'team',
},
]);
await dbHandle.db.insert(missions).values([
{
id: PERSONAL_MISSION_ID,
name: 'FED-M3-06 Personal Mission',
projectId: PERSONAL_PROJECT_ID,
userId: SUBJECT_USER_ID,
},
{
id: TEAM_MISSION_ID,
name: 'FED-M3-06 Team Mission',
projectId: TEAM_PROJECT_ID,
userId: SUBJECT_USER_ID,
},
{
id: UNAUTHORIZED_MISSION_ID,
name: 'FED-M3-06 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'),
},
]);
}
describe('FederationGetQueryService', () => {
beforeAll(async () => {
dbHandle = createPgliteDb(`memory://fed-m3-06-get-${Date.now()}`);
await runPgliteMigrations(dbHandle);
await seedNotesFixture();
});
afterAll(async () => {
await dbHandle?.close();
dbHandle = undefined;
});
it('denies sensitive resources in native RBAC for M3 get 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 get 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('uses subject team membership as the native RBAC upper bound for task and note reads', async () => {
const service = makeService();
const listSubjectTeamIds = vi.fn().mockResolvedValue(['team-1', 'team-2']);
(
service as unknown as {
listSubjectTeamIds: (subjectUserId: string) => Promise<string[]>;
}
).listSubjectTeamIds = listSubjectTeamIds;
await expect(
service.evaluateReadAccess({
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
resource: 'tasks',
}),
).resolves.toEqual({
allowed: true,
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
});
expect(listSubjectTeamIds).toHaveBeenCalledWith('user-1');
});
it('does not query storage for sensitive get resources even if scope allowed them', async () => {
const service = makeService();
await expect(service.get({ filter: CREDENTIAL_FILTER, id: 'cred-1' })).resolves.toEqual({
status: 'denied',
reason: 'credentials federation get is not implemented',
});
});
it('fails closed for unsupported resources instead of returning undefined', async () => {
const service = makeService();
await expect(
service.get({
filter: {
...CREDENTIAL_FILTER,
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
},
id: 'row-1',
}),
).resolves.toEqual({
status: 'denied',
reason: 'Unsupported federation get resource: unknown-resource',
});
});
it('does not leak another user mission task note through team-scoped get reads', async () => {
const service = makeDbService();
await expect(
service.get({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: false,
teamIds: [TEAM_ID],
limit: 1,
maxRowsPerQuery: 10,
},
id: OTHER_TEAM_NOTE_ID,
}),
).resolves.toEqual({
status: 'denied',
reason: 'Note is outside the federated scope',
});
});
it('does not return subject notes from missions outside the grant-visible project set', async () => {
const service = makeDbService();
await expect(
service.get({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: true,
teamIds: [TEAM_ID],
limit: 1,
maxRowsPerQuery: 10,
},
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
}),
).resolves.toEqual({
status: 'denied',
reason: 'Note is outside the federated scope',
});
});
it('returns a subject note only when subject ownership and authorized mission intersect', async () => {
const service = makeDbService();
await expect(
service.get({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: false,
teamIds: [TEAM_ID],
limit: 1,
maxRowsPerQuery: 10,
},
id: SUBJECT_TEAM_NOTE_ID,
}),
).resolves.toMatchObject({
status: 'found',
item: {
id: SUBJECT_TEAM_NOTE_ID,
missionId: TEAM_MISSION_ID,
content: 'subject note on team mission',
},
});
});
it('does not return subject personal notes when includePersonal is false', async () => {
const service = makeDbService();
await expect(
service.get({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: false,
teamIds: [TEAM_ID],
limit: 1,
maxRowsPerQuery: 10,
},
id: SUBJECT_PERSONAL_NOTE_ID,
}),
).resolves.toEqual({
status: 'denied',
reason: 'Note is outside the federated scope',
});
});
});

View File

@@ -1,207 +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 { GetController } from '../get.controller.js';
import type { FederationGetQueryResult } from '../get-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: 1,
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?: FederationGetQueryResult;
}) {
const scope = {
evaluateAccess: vi.fn().mockResolvedValue(opts?.scopeResult ?? allowedScope()),
};
const query = {
evaluateReadAccess: vi.fn(),
get: vi.fn().mockResolvedValue(
opts?.queryResult ?? {
status: 'found',
item: {
id: 'task-1',
title: 'Federated task',
createdAt: new Date('2026-06-24T00:00:00.000Z'),
},
},
),
};
return {
controller: new GetController(scope as never, query as never),
scope,
query,
};
}
describe('GetController', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('declares POST /api/federation/v1/get/:resource/:id protected only by FederationAuthGuard', () => {
expect(Reflect.getMetadata('path', GetController)).toBe('api/federation/v1/get');
expect(Reflect.getMetadata('path', GetController.prototype.get)).toBe(':resource/:id');
expect(Reflect.getMetadata('method', GetController.prototype.get)).toBe(RequestMethod.POST);
expect(Reflect.getMetadata('__guards__', GetController)).toEqual([FederationAuthGuard]);
});
it('runs AuthGuard context through ScopeService and returns one local-source tagged row', async () => {
const { controller, scope, query } = makeController();
const response = await controller.get('tasks', 'task-1', makeRequest());
expect(scope.evaluateAccess).toHaveBeenCalledWith({
context: FEDERATION_CONTEXT,
resource: 'tasks',
requestedLimit: 1,
nativeRbac: query,
});
expect(query.get).toHaveBeenCalledWith({ filter: TASK_FILTER, id: 'task-1' });
expect(response).toEqual({
item: {
id: 'task-1',
title: 'Federated task',
createdAt: new Date('2026-06-24T00:00:00.000Z'),
_source: 'local',
},
});
});
it('returns a federation error envelope when auth guard context is missing', async () => {
const { controller, scope, query } = makeController();
await expect(
controller.get('tasks', 'task-1', {} as unknown as FastifyRequest),
).rejects.toMatchObject({
response: {
error: {
code: 'unauthorized',
message: 'Federation context missing',
},
},
status: 401,
});
expect(scope.evaluateAccess).not.toHaveBeenCalled();
expect(query.get).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.get('credentials', 'cred-1', makeRequest())).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Requested federation resource is explicitly excluded by grant scope',
},
},
status: 403,
});
expect(query.get).not.toHaveBeenCalled();
});
it('returns 404 when the scoped query layer cannot find the resource id', async () => {
const { controller } = makeController({ queryResult: { status: 'not_found' } });
await expect(controller.get('tasks', 'missing-task', makeRequest())).rejects.toMatchObject({
response: { error: { code: 'not_found' } },
status: 404,
});
});
it('returns 403 when the resource exists outside the RBAC/scope intersection', async () => {
const { controller } = makeController({
queryResult: { status: 'denied', reason: 'Task is outside the federated scope' },
});
await expect(controller.get('tasks', 'task-2', makeRequest())).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Task is outside the federated scope',
},
},
status: 403,
});
});
it('fails closed when the query layer denies an unsupported resource', async () => {
const unsupportedFilter: FederationScopeQueryFilter = {
...TASK_FILTER,
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
};
const { controller } = makeController({
scopeResult: allowedScope(unsupportedFilter),
queryResult: {
status: 'denied',
reason: 'Unsupported federation get resource: unknown-resource',
},
});
await expect(controller.get('unknown-resource', 'row-1', makeRequest())).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Unsupported federation get resource: unknown-resource',
},
},
status: 403,
});
});
it('rejects empty ids before evaluating scope', async () => {
const { controller, scope, query } = makeController();
await expect(controller.get('tasks', ' ', makeRequest())).rejects.toMatchObject({
response: { error: { code: 'invalid_request' } },
status: 400,
});
expect(scope.evaluateAccess).not.toHaveBeenCalled();
expect(query.get).not.toHaveBeenCalled();
});
});

View File

@@ -1,311 +0,0 @@
/**
* Federation get query layer (FED-M3-06).
*
* Read-only DB adapter used by GetController 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,
eq,
inArray,
insights,
or,
missionTasks,
missions,
preferences,
projects,
tasks,
teamMembers,
type Db,
} from '@mosaicstack/db';
import { DB } from '../../../database/database.module.js';
import type {
FederationNativeRbacEvaluator,
FederationNativeRbacRequest,
FederationNativeRbacResult,
FederationScopeQueryFilter,
} from '../scope.service.js';
export interface FederationGetQueryRequest {
readonly filter: FederationScopeQueryFilter;
readonly id: string;
}
export interface FederationGetQueryFoundResult<T extends object = Record<string, unknown>> {
readonly status: 'found';
readonly item: T;
}
export interface FederationGetQueryNotFoundResult {
readonly status: 'not_found';
}
export interface FederationGetQueryDeniedResult {
readonly status: 'denied';
readonly reason: string;
}
export type FederationGetQueryResult<T extends object = Record<string, unknown>> =
| FederationGetQueryFoundResult<T>
| FederationGetQueryNotFoundResult
| FederationGetQueryDeniedResult;
type RowObject = Record<string, unknown>;
function firstRow<T>(rows: T[]): T | undefined {
return rows[0];
}
function rowBelongsToAccessibleProjectOrMission(
row: { projectId?: string | null; missionId?: string | null },
projectIds: readonly string[],
missionIds: readonly string[],
): boolean {
return (
(typeof row.projectId === 'string' && projectIds.includes(row.projectId)) ||
(typeof row.missionId === 'string' && missionIds.includes(row.missionId))
);
}
@Injectable()
export class FederationGetQueryService 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 get 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 get<T extends RowObject = RowObject>(
request: FederationGetQueryRequest,
): Promise<FederationGetQueryResult<T>> {
return this.getByResource(request.filter, request.id) as Promise<FederationGetQueryResult<T>>;
}
private async getByResource(
filter: FederationScopeQueryFilter,
id: string,
): Promise<FederationGetQueryResult> {
switch (filter.resource) {
case 'tasks':
return this.getTask(filter, id);
case 'notes':
return this.getNote(filter, id);
case 'memory':
return this.getMemory(filter, id);
case 'credentials':
case 'api_keys':
return { status: 'denied', reason: `${filter.resource} federation get is not implemented` };
default:
return {
status: 'denied',
reason: `Unsupported federation get 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) {
// Project team ownership follows TeamsService.canAccessProject: team-owned
// rows are authorized through projects.teamId, while ownerId remains the
// user who created/bootstrapped the project.
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 getTask(
filter: FederationScopeQueryFilter,
id: string,
): Promise<FederationGetQueryResult> {
const row = firstRow(
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(eq(tasks.id, id))
.limit(1),
);
if (!row) {
return { status: 'not_found' };
}
const projectIds = await this.listAccessibleProjectIds(filter);
const missionIds = await this.listMissionIds(projectIds);
if (!rowBelongsToAccessibleProjectOrMission(row, projectIds, missionIds)) {
return { status: 'denied', reason: 'Task is outside the federated scope' };
}
return { status: 'found', item: row as RowObject };
}
private async getNote(
filter: FederationScopeQueryFilter,
id: string,
): Promise<FederationGetQueryResult> {
const row = firstRow(
await this.db
.select({
id: missionTasks.id,
missionId: missionTasks.missionId,
taskId: missionTasks.taskId,
userId: missionTasks.userId,
status: missionTasks.status,
content: missionTasks.notes,
createdAt: missionTasks.createdAt,
updatedAt: missionTasks.updatedAt,
})
.from(missionTasks)
.where(eq(missionTasks.id, id))
.limit(1),
);
if (!row || row.content === null || row.content === '') {
return { status: 'not_found' };
}
const projectIds = await this.listAccessibleProjectIds(filter);
const missionIds = await this.listMissionIds(projectIds);
// mission_tasks rows are user-scoped even when the mission belongs to a team.
// Scope-visible missions must intersect with subject ownership; team scope
// narrows mission IDs but never widens note reads to another user's rows.
if (row.userId !== filter.subjectUserId || !missionIds.includes(row.missionId)) {
return { status: 'denied', reason: 'Note is outside the federated scope' };
}
const item = { ...row } as RowObject;
delete item['userId'];
return { status: 'found', item };
}
private async getMemory(
filter: FederationScopeQueryFilter,
id: string,
): Promise<FederationGetQueryResult> {
const [insightRow, preferenceRow] = await Promise.all([
this.db
.select({
id: insights.id,
userId: insights.userId,
kind: insights.source,
content: insights.content,
category: insights.category,
relevanceScore: insights.relevanceScore,
metadata: insights.metadata,
createdAt: insights.createdAt,
updatedAt: insights.updatedAt,
})
.from(insights)
.where(eq(insights.id, id))
.limit(1)
.then(firstRow),
this.db
.select({
id: preferences.id,
userId: preferences.userId,
kind: preferences.category,
key: preferences.key,
value: preferences.value,
source: preferences.source,
mutable: preferences.mutable,
createdAt: preferences.createdAt,
updatedAt: preferences.updatedAt,
})
.from(preferences)
.where(eq(preferences.id, id))
.limit(1)
.then(firstRow),
]);
const candidates = [insightRow, preferenceRow].filter(
(row): row is NonNullable<typeof row> => row !== undefined,
);
if (candidates.length === 0) {
return { status: 'not_found' };
}
if (!filter.includePersonal) {
return { status: 'denied', reason: 'Memory personal rows are outside the federated scope' };
}
const accessible = candidates.find((row) => row.userId === filter.subjectUserId);
if (!accessible) {
return { status: 'denied', reason: 'Memory row belongs to another subject user' };
}
const item = { ...accessible } as RowObject;
delete item['userId'];
return { status: 'found', item };
}
}

View File

@@ -1,100 +0,0 @@
/**
* Federation get verb (FED-M3-06).
*
* POST /api/federation/v1/get/:resource/:id
*
* Pipeline: FederationAuthGuard attaches the active grant context, then
* FederationScopeService enforces grant scope + native RBAC intersection, then
* the read-only query layer fetches one local row and tags it with `_source`.
* Read audit-log writes are deferred to M4; this controller does not persist
* request or response bodies.
*/
import { Controller, HttpException, Inject, Param, Post, Req, UseGuards } from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import {
FederationInvalidRequestError,
FederationNotFoundError,
FederationScopeViolationError,
FederationUnauthorizedError,
SOURCE_LOCAL,
type FederationGetResponse,
type SourceTag,
} from '@mosaicstack/types';
import { FederationAuthGuard } from '../federation-auth.guard.js';
import '../federation-context.js';
import { FederationScopeService } from '../scope.service.js';
import { FederationGetQueryService } from './get-query.service.js';
type FederatedRow = Record<string, unknown> & SourceTag;
function scopeDenyToHttpException(deny: {
readonly statusCode: 400 | 403;
readonly message: string;
}): HttpException {
const ErrorClass =
deny.statusCode === 400 ? FederationInvalidRequestError : FederationScopeViolationError;
return new HttpException(new ErrorClass(deny.message, deny).toEnvelope(), deny.statusCode);
}
@Controller('api/federation/v1/get')
@UseGuards(FederationAuthGuard)
export class GetController {
constructor(
@Inject(FederationScopeService) private readonly scope: FederationScopeService,
@Inject(FederationGetQueryService) private readonly query: FederationGetQueryService,
) {}
@Post(':resource/:id')
async get(
@Param('resource') resource: string,
@Param('id') id: string,
@Req() request: FastifyRequest,
): Promise<FederationGetResponse<FederatedRow>> {
if (!request.federationContext) {
throw new HttpException(
new FederationUnauthorizedError('Federation context missing').toEnvelope(),
401,
);
}
if (id.trim().length === 0) {
throw new HttpException(
new FederationInvalidRequestError('Federation get id must not be empty').toEnvelope(),
400,
);
}
const scopeResult = await this.scope.evaluateAccess({
context: request.federationContext,
resource,
requestedLimit: 1,
nativeRbac: this.query,
});
if (!scopeResult.allowed) {
throw scopeDenyToHttpException(scopeResult.deny);
}
const result = await this.query.get({ filter: scopeResult.filter, id });
if (result.status === 'not_found') {
throw new HttpException(
new FederationNotFoundError('Requested federation resource was not found').toEnvelope(),
404,
);
}
if (result.status === 'denied') {
throw new HttpException(
new FederationScopeViolationError(result.reason, {
resource,
id,
grantId: request.federationContext.grantId,
peerId: request.federationContext.peerId,
subjectUserId: request.federationContext.subjectUserId,
}).toEnvelope(),
403,
);
}
return { item: { ...result.item, _source: SOURCE_LOCAL } };
}
}

View File

@@ -1,7 +1,5 @@
import { Module, type OnApplicationShutdown, Inject, Optional } from '@nestjs/common'; import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue'; import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import { SessionGCService } from './session-gc.service.js'; import { SessionGCService } from './session-gc.service.js';
import { REDIS } from './gc.tokens.js'; import { REDIS } from './gc.tokens.js';
@@ -11,17 +9,13 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
providers: [ providers: [
{ {
provide: GC_QUEUE_HANDLE, provide: GC_QUEUE_HANDLE,
useFactory: (config: MosaicConfig | null): QueueHandle | null => { useFactory: (): QueueHandle => {
// On Local tier there is no Redis — skip the ioredis connection entirely.
// The Valkey GC sweep is a no-op on Local (no session keys stored there).
if (config?.queue?.type === 'local') return null;
return createQueue(); return createQueue();
}, },
inject: [MOSAIC_CONFIG],
}, },
{ {
provide: REDIS, provide: REDIS,
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null, useFactory: (handle: QueueHandle) => handle.redis,
inject: [GC_QUEUE_HANDLE], inject: [GC_QUEUE_HANDLE],
}, },
SessionGCService, SessionGCService,
@@ -29,13 +23,9 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
exports: [SessionGCService], exports: [SessionGCService],
}) })
export class GCModule implements OnApplicationShutdown { export class GCModule implements OnApplicationShutdown {
constructor( constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
@Optional()
@Inject(GC_QUEUE_HANDLE)
private readonly handle: QueueHandle | null,
) {}
async onApplicationShutdown(): Promise<void> { async onApplicationShutdown(): Promise<void> {
await this.handle?.close().catch(() => {}); await this.handle.close().catch(() => {});
} }
} }

View File

@@ -1,4 +1,4 @@
import { Inject, Injectable, Logger, Optional, type OnModuleInit } from '@nestjs/common'; import { Inject, Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import type { QueueHandle } from '@mosaicstack/queue'; import type { QueueHandle } from '@mosaicstack/queue';
import type { LogService } from '@mosaicstack/log'; import type { LogService } from '@mosaicstack/log';
import { LOG_SERVICE } from '../log/log.tokens.js'; import { LOG_SERVICE } from '../log/log.tokens.js';
@@ -32,21 +32,11 @@ export class SessionGCService implements OnModuleInit {
private readonly logger = new Logger(SessionGCService.name); private readonly logger = new Logger(SessionGCService.name);
constructor( constructor(
// On Local tier there is no Redis — the GC module provides null for this token. @Inject(REDIS) private readonly redis: QueueHandle['redis'],
// NOTE: if a future feature stores Redis-backed state on Local tier, this guard
// would silently skip GC for those keys. Revisit when that happens.
@Optional()
@Inject(REDIS)
private readonly redis: QueueHandle['redis'] | null,
@Inject(LOG_SERVICE) private readonly logService: LogService, @Inject(LOG_SERVICE) private readonly logService: LogService,
) {} ) {}
onModuleInit(): void { onModuleInit(): void {
if (!this.redis) {
// Local tier: no Valkey — skip cold-start GC entirely (correct no-op).
this.logger.log('SessionGCService: Valkey GC skipped on local tier (no Redis configured)');
return;
}
// Fire-and-forget: run full GC asynchronously so it does not block the // Fire-and-forget: run full GC asynchronously so it does not block the
// NestJS bootstrap chain. Cold-start GC typically takes 100500 ms // NestJS bootstrap chain. Cold-start GC typically takes 100500 ms
// depending on Valkey key count; deferring it removes that latency from // depending on Valkey key count; deferring it removes that latency from
@@ -70,10 +60,8 @@ export class SessionGCService implements OnModuleInit {
* Scan Valkey for all keys matching a pattern using SCAN (non-blocking). * Scan Valkey for all keys matching a pattern using SCAN (non-blocking).
* KEYS is avoided because it blocks the Valkey event loop for the full scan * KEYS is avoided because it blocks the Valkey event loop for the full scan
* duration, which can cause latency spikes under production key volumes. * duration, which can cause latency spikes under production key volumes.
* Returns empty array when Redis is not available (Local tier).
*/ */
private async scanKeys(pattern: string): Promise<string[]> { private async scanKeys(pattern: string): Promise<string[]> {
if (!this.redis) return [];
const collected: string[] = []; const collected: string[] = [];
let cursor = '0'; let cursor = '0';
do { do {
@@ -90,15 +78,13 @@ export class SessionGCService implements OnModuleInit {
async collect(sessionId: string): Promise<GCResult> { async collect(sessionId: string): Promise<GCResult> {
const result: GCResult = { sessionId, cleaned: {} }; const result: GCResult = { sessionId, cleaned: {} };
// 1. Valkey: delete all session-scoped keys (skipped on Local tier) // 1. Valkey: delete all session-scoped keys
if (this.redis) {
const pattern = `mosaic:session:${sessionId}:*`; const pattern = `mosaic:session:${sessionId}:*`;
const valkeyKeys = await this.scanKeys(pattern); const valkeyKeys = await this.scanKeys(pattern);
if (valkeyKeys.length > 0) { if (valkeyKeys.length > 0) {
await this.redis.del(...valkeyKeys); await this.redis.del(...valkeyKeys);
result.cleaned.valkeyKeys = valkeyKeys.length; result.cleaned.valkeyKeys = valkeyKeys.length;
} }
}
// 2. PG: demote hot-tier agent_logs for this session to warm // 2. PG: demote hot-tier agent_logs for this session to warm
const cutoff = new Date(); // demote all hot logs for this session const cutoff = new Date(); // demote all hot logs for this session
@@ -120,7 +106,6 @@ export class SessionGCService implements OnModuleInit {
const cleaned: GCResult[] = []; const cleaned: GCResult[] = [];
// 1. Find all session-scoped Valkey keys (non-blocking SCAN) // 1. Find all session-scoped Valkey keys (non-blocking SCAN)
// Returns empty on Local tier — no Valkey session keys exist there.
const allSessionKeys = await this.scanKeys('mosaic:session:*'); const allSessionKeys = await this.scanKeys('mosaic:session:*');
// Extract unique session IDs from keys // Extract unique session IDs from keys
@@ -151,16 +136,12 @@ export class SessionGCService implements OnModuleInit {
*/ */
async fullCollect(): Promise<FullGCResult> { async fullCollect(): Promise<FullGCResult> {
const start = Date.now(); const start = Date.now();
let valkeyKeysCount = 0;
if (this.redis) {
// 1. Valkey: delete ALL session-scoped keys (non-blocking SCAN) // 1. Valkey: delete ALL session-scoped keys (non-blocking SCAN)
const sessionKeys = await this.scanKeys('mosaic:session:*'); const sessionKeys = await this.scanKeys('mosaic:session:*');
if (sessionKeys.length > 0) { if (sessionKeys.length > 0) {
await this.redis.del(...sessionKeys); await this.redis.del(...sessionKeys);
} }
valkeyKeysCount = sessionKeys.length;
}
// 2. NOTE: channel keys are NOT collected on cold start // 2. NOTE: channel keys are NOT collected on cold start
// (discord/telegram plugins may reconnect and resume) // (discord/telegram plugins may reconnect and resume)
@@ -173,7 +154,7 @@ export class SessionGCService implements OnModuleInit {
const jobsPurged = 0; const jobsPurged = 0;
return { return {
valkeyKeys: valkeyKeysCount, valkeyKeys: sessionKeys.length,
logsDemoted, logsDemoted,
jobsPurged, jobsPurged,
tempFilesRemoved: 0, tempFilesRemoved: 0,

View File

@@ -19,7 +19,7 @@ import type { MosaicJobData } from '../queue/queue.service.js';
@Injectable() @Injectable()
export class CronService implements OnModuleInit, OnModuleDestroy { export class CronService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(CronService.name); private readonly logger = new Logger(CronService.name);
private readonly registeredWorkers: Array<Worker<MosaicJobData>> = []; private readonly registeredWorkers: Worker<MosaicJobData>[] = [];
constructor( constructor(
@Inject(SummarizationService) private readonly summarization: SummarizationService, @Inject(SummarizationService) private readonly summarization: SummarizationService,
@@ -28,16 +28,6 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
) {} ) {}
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
// On Local tier BullMQ is disabled — skip all job scheduling.
// NOTE: this means summarization, tier management, and Valkey GC jobs do not
// run on Local installs. For a single-user local install this is acceptable.
// If periodic background work is needed on Local in the future, add a
// setInterval-based scheduler here.
if (!this.queueService.isEnabled()) {
this.logger.log('CronService: BullMQ disabled on local tier — no jobs will be scheduled');
return;
}
const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours const summarizationSchedule = process.env['SUMMARIZATION_CRON'] ?? '0 */6 * * *'; // every 6 hours
const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am const tierManagementSchedule = process.env['TIER_MANAGEMENT_CRON'] ?? '0 3 * * *'; // daily at 3am
const gcSchedule = process.env['SESSION_GC_CRON'] ?? '0 4 * * *'; // daily at 4am const gcSchedule = process.env['SESSION_GC_CRON'] ?? '0 4 * * *'; // daily at 4am
@@ -52,7 +42,7 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => { const summarizationWorker = this.queueService.registerWorker(QUEUE_SUMMARIZATION, async () => {
await this.summarization.runSummarization(); await this.summarization.runSummarization();
}); });
if (summarizationWorker) this.registeredWorkers.push(summarizationWorker); this.registeredWorkers.push(summarizationWorker);
// M6-005: Tier management repeatable job // M6-005: Tier management repeatable job
await this.queueService.addRepeatableJob( await this.queueService.addRepeatableJob(
@@ -64,14 +54,14 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => { const tierWorker = this.queueService.registerWorker(QUEUE_TIER_MANAGEMENT, async () => {
await this.summarization.runTierManagement(); await this.summarization.runTierManagement();
}); });
if (tierWorker) this.registeredWorkers.push(tierWorker); this.registeredWorkers.push(tierWorker);
// M6-004: GC repeatable job // M6-004: GC repeatable job
await this.queueService.addRepeatableJob(QUEUE_GC, 'session-gc', {}, gcSchedule); await this.queueService.addRepeatableJob(QUEUE_GC, 'session-gc', {}, gcSchedule);
const gcWorker = this.queueService.registerWorker(QUEUE_GC, async () => { const gcWorker = this.queueService.registerWorker(QUEUE_GC, async () => {
await this.sessionGC.sweepOrphans(); await this.sessionGC.sweepOrphans();
}); });
if (gcWorker) this.registeredWorkers.push(gcWorker); this.registeredWorkers.push(gcWorker);
this.logger.log( this.logger.log(
`BullMQ jobs scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}", gc="${gcSchedule}"`, `BullMQ jobs scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}", gc="${gcSchedule}"`,

View File

@@ -1,7 +1,5 @@
import { Inject, Injectable, Logger, Optional, type OnApplicationShutdown } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue'; import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { MosaicConfig } from '@mosaicstack/config';
import { MOSAIC_CONFIG } from '../config/config.module.js';
const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`; const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`;
const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string) => const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string) =>
@@ -13,54 +11,16 @@ interface OverrideFragment {
addedAt: number; addedAt: number;
} }
interface LocalOverrideEntry {
condensed: string;
fragments: OverrideFragment[];
}
@Injectable() @Injectable()
export class SystemOverrideService implements OnApplicationShutdown { export class SystemOverrideService {
private readonly logger = new Logger(SystemOverrideService.name); private readonly logger = new Logger(SystemOverrideService.name);
private readonly handle: QueueHandle | null; private readonly handle: QueueHandle;
/**
* In-memory fallback used on Local tier (no Redis).
* NOTE: state is ephemeral — lost on restart. For Local single-user installs
* this is acceptable; system overrides are re-applied at the next session.
* This is a deliberate behavior change from the Redis-backed 7-day TTL.
*/
private readonly localStore = new Map<string, LocalOverrideEntry>();
constructor( constructor() {
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) {
if (this.mosaicConfig?.queue?.type === 'local') {
this.handle = null;
} else {
this.handle = createQueue(); this.handle = createQueue();
} }
}
async onApplicationShutdown(): Promise<void> {
// On non-local tiers the constructor opens an ioredis connection; close it
// on graceful shutdown to avoid leaking the handle (local tier is null).
await this.handle?.close().catch(() => {});
}
async set(sessionId: string, override: string): Promise<void> { async set(sessionId: string, override: string): Promise<void> {
if (!this.handle) {
// Local tier: in-memory path
const entry = this.localStore.get(sessionId) ?? { condensed: '', fragments: [] };
entry.fragments.push({ text: override, addedAt: Date.now() });
entry.condensed = await this.condenseOverrides(entry.fragments.map((f) => f.text));
this.localStore.set(sessionId, entry);
this.logger.debug(
`Set system override for session ${sessionId} (local, ${entry.fragments.length} fragment(s))`,
);
return;
}
// Load existing fragments // Load existing fragments
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId)); const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId));
const fragments: OverrideFragment[] = existing const fragments: OverrideFragment[] = existing
@@ -90,17 +50,10 @@ export class SystemOverrideService implements OnApplicationShutdown {
} }
async get(sessionId: string): Promise<string | null> { async get(sessionId: string): Promise<string | null> {
if (!this.handle) {
return this.localStore.get(sessionId)?.condensed ?? null;
}
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId)); return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId));
} }
async renew(sessionId: string): Promise<void> { async renew(sessionId: string): Promise<void> {
if (!this.handle) {
// Local tier: no TTL to renew; entry persists until restart
return;
}
const pipeline = this.handle.redis.pipeline(); const pipeline = this.handle.redis.pipeline();
pipeline.expire(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS); pipeline.expire(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS); pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
@@ -108,11 +61,6 @@ export class SystemOverrideService implements OnApplicationShutdown {
} }
async clear(sessionId: string): Promise<void> { async clear(sessionId: string): Promise<void> {
if (!this.handle) {
this.localStore.delete(sessionId);
this.logger.debug(`Cleared system override for session ${sessionId} (local)`);
return;
}
await this.handle.redis.del( await this.handle.redis.del(
SESSION_SYSTEM_KEY(sessionId), SESSION_SYSTEM_KEY(sessionId),
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),

View File

@@ -1,35 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import type { MosaicConfig } from '@mosaicstack/config';
import { QueueService } from './queue.service.js';
const localConfig = {
queue: { type: 'local' },
} as MosaicConfig;
describe('QueueService local tier', () => {
it('disables BullMQ and treats queue operations as local no-ops', async () => {
const service = new QueueService(null, localConfig);
expect(service.isEnabled()).toBe(false);
expect(service.getQueue('mosaic-test')).toBeNull();
expect(service.registerWorker('mosaic-test', vi.fn())).toBeNull();
await expect(
service.addRepeatableJob('mosaic-test', 'local-noop', {}, '* * * * *'),
).resolves.toBeUndefined();
await expect(service.getHealthStatus()).resolves.toEqual({ queues: {}, healthy: true });
await expect(service.listJobs()).resolves.toEqual([]);
await expect(service.retryJob('mosaic-test__1')).resolves.toEqual({
ok: false,
message: 'BullMQ is disabled on local tier.',
});
await expect(service.pauseQueue('mosaic-test')).resolves.toEqual({
ok: false,
message: 'BullMQ is disabled on local tier.',
});
await expect(service.resumeQueue('mosaic-test')).resolves.toEqual({
ok: false,
message: 'BullMQ is disabled on local tier.',
});
});
});

View File

@@ -8,9 +8,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq'; import { Queue, Worker, type Job, type ConnectionOptions } from 'bullmq';
import type { LogService } from '@mosaicstack/log'; import type { LogService } from '@mosaicstack/log';
import type { MosaicConfig } from '@mosaicstack/config';
import { LOG_SERVICE } from '../log/log.tokens.js'; import { LOG_SERVICE } from '../log/log.tokens.js';
import { MOSAIC_CONFIG } from '../config/config.module.js';
import type { JobDto, JobStatus } from './queue-admin.dto.js'; import type { JobDto, JobStatus } from './queue-admin.dto.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -110,43 +108,22 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
private readonly connection: ConnectionOptions; private readonly connection: ConnectionOptions;
private readonly queues = new Map<string, Queue<MosaicJobData>>(); private readonly queues = new Map<string, Queue<MosaicJobData>>();
private readonly workers = new Map<string, Worker<MosaicJobData>>(); private readonly workers = new Map<string, Worker<MosaicJobData>>();
/** False on Local tier — BullMQ/Redis operations become no-ops. */
private readonly enabled: boolean;
constructor( constructor(
@Optional() @Optional()
@Inject(LOG_SERVICE) @Inject(LOG_SERVICE)
private readonly logService: LogService | null, private readonly logService: LogService | null,
@Optional()
@Inject(MOSAIC_CONFIG)
private readonly mosaicConfig: MosaicConfig | null,
) { ) {
this.enabled = this.mosaicConfig?.queue?.type !== 'local'; this.connection = getConnection();
this.connection = this.enabled
? getConnection()
: ({ host: '127.0.0.1', port: 6380 } as ConnectionOptions);
}
/** Returns true when BullMQ/Redis is active (Standalone and Federated tiers). */
isEnabled(): boolean {
return this.enabled;
} }
onModuleInit(): void { onModuleInit(): void {
if (this.enabled) {
this.logger.log('QueueService initialised (BullMQ)'); this.logger.log('QueueService initialised (BullMQ)');
} else {
this.logger.log(
'QueueService: BullMQ disabled for local tier — no Redis connections will be opened',
);
}
} }
async onModuleDestroy(): Promise<void> { async onModuleDestroy(): Promise<void> {
if (this.enabled) {
await this.closeAll(); await this.closeAll();
} }
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Queue helpers // Queue helpers
@@ -154,10 +131,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/** /**
* Get or create a BullMQ Queue for the given queue name. * Get or create a BullMQ Queue for the given queue name.
* Returns null on Local tier where BullMQ is disabled.
*/ */
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> | null { getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> {
if (!this.enabled) return null;
let queue = this.queues.get(name) as Queue<T> | undefined; let queue = this.queues.get(name) as Queue<T> | undefined;
if (!queue) { if (!queue) {
queue = new Queue<T>(name, { connection: this.connection }); queue = new Queue<T>(name, { connection: this.connection });
@@ -169,7 +144,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/** /**
* Add a BullMQ repeatable job (cron-style). * Add a BullMQ repeatable job (cron-style).
* Uses `jobId` as a deterministic key so duplicate registrations are idempotent. * Uses `jobId` as a deterministic key so duplicate registrations are idempotent.
* No-op on Local tier.
*/ */
async addRepeatableJob<T extends MosaicJobData>( async addRepeatableJob<T extends MosaicJobData>(
queueName: string, queueName: string,
@@ -177,13 +151,7 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
data: T, data: T,
cronExpression: string, cronExpression: string,
): Promise<void> { ): Promise<void> {
if (!this.enabled) { const queue = this.getQueue<T>(queueName);
this.logger.debug(
`Skipping repeatable job "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
);
return;
}
const queue = this.getQueue<T>(queueName)!;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
await (queue as Queue<any>).add(jobName, data, { await (queue as Queue<any>).add(jobName, data, {
repeat: { pattern: cronExpression }, repeat: { pattern: cronExpression },
@@ -197,18 +165,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/** /**
* Register a Worker for the given queue name with error handling and * Register a Worker for the given queue name with error handling and
* exponential backoff. * exponential backoff.
* Returns null on Local tier where BullMQ is disabled.
*/ */
registerWorker<T extends MosaicJobData>( registerWorker<T extends MosaicJobData>(queueName: string, handler: JobHandler<T>): Worker<T> {
queueName: string,
handler: JobHandler<T>,
): Worker<T> | null {
if (!this.enabled) {
this.logger.debug(
`Skipping worker registration for "${queueName}" (local tier — BullMQ disabled)`,
);
return null;
}
const worker = new Worker<T>( const worker = new Worker<T>(
queueName, queueName,
async (job) => { async (job) => {
@@ -265,12 +223,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/** /**
* Return queue health statistics for all managed queues. * Return queue health statistics for all managed queues.
* Returns an empty healthy result on Local tier.
*/ */
async getHealthStatus(): Promise<QueueHealthStatus> { async getHealthStatus(): Promise<QueueHealthStatus> {
if (!this.enabled) {
return { queues: {}, healthy: true };
}
const queues: QueueHealthStatus['queues'] = {}; const queues: QueueHealthStatus['queues'] = {};
let healthy = true; let healthy = true;
@@ -301,10 +255,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
/** /**
* List jobs across all managed queues, optionally filtered by status. * List jobs across all managed queues, optionally filtered by status.
* BullMQ jobs are fetched by state type from each queue. * BullMQ jobs are fetched by state type from each queue.
* Returns empty array on Local tier.
*/ */
async listJobs(status?: JobStatus): Promise<JobDto[]> { async listJobs(status?: JobStatus): Promise<JobDto[]> {
if (!this.enabled) return [];
const jobs: JobDto[] = []; const jobs: JobDto[] = [];
const states: JobStatus[] = status const states: JobStatus[] = status
? [status] ? [status]
@@ -331,10 +283,8 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Retry a specific failed job by its BullMQ job ID (format: "queueName:id"). * Retry a specific failed job by its BullMQ job ID (format: "queueName:id").
* The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ * The caller passes "<queueName>__<jobId>" as the composite ID because BullMQ
* job IDs are not globally unique — they are scoped to their queue. * job IDs are not globally unique — they are scoped to their queue.
* Returns an error on Local tier.
*/ */
async retryJob(compositeId: string): Promise<{ ok: boolean; message: string }> { async retryJob(compositeId: string): Promise<{ ok: boolean; message: string }> {
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
const sep = compositeId.lastIndexOf('__'); const sep = compositeId.lastIndexOf('__');
if (sep === -1) { if (sep === -1) {
return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' }; return { ok: false, message: 'Invalid job id format. Expected "<queue>__<jobId>".' };
@@ -366,7 +316,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Pause a queue by name. * Pause a queue by name.
*/ */
async pauseQueue(name: string): Promise<{ ok: boolean; message: string }> { async pauseQueue(name: string): Promise<{ ok: boolean; message: string }> {
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
const queue = this.queues.get(name); const queue = this.queues.get(name);
if (!queue) return { ok: false, message: `Queue "${name}" not found.` }; if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
await queue.pause(); await queue.pause();
@@ -378,7 +327,6 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
* Resume a paused queue by name. * Resume a paused queue by name.
*/ */
async resumeQueue(name: string): Promise<{ ok: boolean; message: string }> { async resumeQueue(name: string): Promise<{ ok: boolean; message: string }> {
if (!this.enabled) return { ok: false, message: 'BullMQ is disabled on local tier.' };
const queue = this.queues.get(name); const queue = this.queues.get(name);
if (!queue) return { ok: false, message: `Queue "${name}" not found.` }; if (!queue) return { ok: false, message: `Queue "${name}" not found.` };
await queue.resume(); await queue.resume();

View File

@@ -1,63 +0,0 @@
# npm `@next` prerelease lane
Status: **IMPLEMENTED**
## Current behavior
`tools/install.sh --next` provides the prerelease integration lane for the permanent `next` branch.
The lane is fast-by-default:
1. Install framework files from the `next` source archive.
2. Resolve the Gitea npm registry `next` dist-tag for the globally installed packages:
```bash
npm view @mosaicstack/gateway@next version
npm view @mosaicstack/mosaic@next version
```
3. Require both resolved versions to share the same `next.<pipeline>` suffix, then install the exact resolved versions.
4. If either `@next` package is missing, unreachable, mismatched, or fails to install, fall back to the source-build path at `next`.
`--next` never hard-fails solely because the prerelease npm dist-tag is unavailable.
## Published packages
The `next` publish pipeline publishes non-private `@mosaicstack/*` packages to the Mosaic Gitea npm registry:
```text
https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
```
Observed `next` dist-tags after enabling the pipeline:
```text
@mosaicstack/mosaic@next -> 0.0.49-next.1633
@mosaicstack/gateway@next -> 0.0.7-next.1633
```
The gateway also publishes a Docker image as `gateway:sha-<short>` on `next` merges. The installer fast path uses the npm gateway package when available; the Docker image is for deployed gateway/runtime harness flows.
## Explicit source lanes
Source builds remain available and are still the authority for explicit ref validation:
- `--dev` always builds from source.
- `--ref <ref>` / `MOSAIC_REF=<ref>` wins over `--next` and uses the source path for that exact ref.
## Pipeline shape
1. Trigger on `next` merges.
2. Compute the next prerelease version from the upcoming stable version plus the Woodpecker pipeline number (`<target-stable>-next.<CI_PIPELINE_NUMBER>`).
3. Build and publish non-private packages in CI.
4. Publish to the Mosaic Gitea npm registry with dist-tag `next`.
5. Keep `latest` untouched; only main/release promotion can update `latest`.
6. Publish gateway Docker images from `next` as `gateway:sha-<short>` only.
## Guardrails
- `@next` is mutable prerelease convenience, not a deployment pin.
- Stable installs continue to use `@latest`.
- Contributor validation remains available through `--dev --ref <branch>`.
- Pipeline output traces every prerelease package back to the source commit on `next`.
- The installer falls back to source rather than hard-failing on prerelease registry issues.

View File

@@ -0,0 +1,61 @@
# 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." D1D12 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 124) 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 D1D12.
## 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 1213 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.

View File

@@ -0,0 +1,58 @@
# PRD — Backlog Provider Sync Adapters · Workstream Q
> **Status:** DRAFT for ratification · **Goals:** Q1Q3 · **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.

View File

@@ -0,0 +1,83 @@
# PRD — Hermes Decommission & Tenant-1 Migration · Workstream X
> **Status:** DRAFT for ratification · **Goals:** X1X3 · **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) | P1P3 |
| 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 13** 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: J1J4 (Jarvis on existing transport interim) → K1/J5 (Matrix room) → P2, W1W3, 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.

View File

@@ -0,0 +1,89 @@
# PRD — HMI Main Agent ("Jarvis") · Workstream J
> **Status:** DRAFT for ratification · **Source of truth once landed:** NORTH_STAR.yaml goals J1J6
> **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 J1J4 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.

View File

@@ -0,0 +1,98 @@
# PRD — Permission Relay · Workstream P
> **Status:** DRAFT for ratification · **Goals:** P1P3
> **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-R5P-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.

View File

@@ -0,0 +1,64 @@
# PRD — webUI Fleet Control · Workstream W (realizes F6)
> **Status:** DRAFT for ratification · **Goals:** W1W3 · **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.

View File

@@ -0,0 +1,62 @@
# Mosaic Platform PRD — Jarvis HMI + Hermes Decommission (DRAFT for ratification)
**Date:** 2026-07-09 · **Author:** proto-Jarvis session with Jason · **Status:** Decisions D1D12 **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:** D1D12 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 (H1H4), 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.

View File

@@ -0,0 +1,279 @@
# 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 AH 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.

View File

@@ -211,17 +211,6 @@ pnpm format:check && pnpm typecheck && pnpm lint
A pre-push hook enforces this mechanically. A pre-push hook enforces this mechanically.
### CI Publish Channels
Woodpecker `.woodpecker/publish.yml` keeps stable and integration-line artifacts separate:
| Source | npm packages | Gateway image |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `main` push/manual or release tag | committed package versions published to Gitea npm without changing the dist-tag workflow | `gateway:sha-<short>` plus `gateway:latest` on `main`, and the release tag on tag events |
| `next` push/manual | CI-computed prereleases, `<target-stable>-next.<CI_PIPELINE_NUMBER>`, published with `npm publish --tag next` | `gateway:sha-<short>` only |
`next` never publishes npm `latest` or Docker `latest`. The next npm publish step verifies that `@mosaicstack/mosaic@next` resolves to the computed prerelease before the pipeline can pass.
--- ---
## Adding New Agent Tools ## Adding New Agent Tools

View File

@@ -175,18 +175,8 @@ Or use the direct URL:
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh) bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
``` ```
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. Flags for
non-interactive use:
Install lanes:
| Lane | Command | Source |
| ------------------------ | ------------------------------------- | -------------------------------------------------------------------------------------------- |
| Stable | `bash tools/install.sh` | npm `@mosaicstack/mosaic@latest` + `main` |
| Prerelease integration | `bash tools/install.sh --next` | Fast npm `@mosaicstack/mosaic@next` + `@mosaicstack/gateway@next`; source fallback at `next` |
| Contributor/source build | `bash tools/install.sh --dev --ref X` | Build-from-source at the requested ref |
`--next` is fast-by-default from the Gitea npm `next` dist-tag and falls back to a source build at the permanent `next` branch if the dist-tag is missing or unreachable. Explicit `--ref` or `MOSAIC_REF` still wins and uses the source path.
Flags for non-interactive use:
```bash ```bash
--yes # Accept all defaults --yes # Accept all defaults

View File

@@ -1,38 +0,0 @@
# Scratchpad — FED-M3-06 get verb
## Objective
Implement `POST /api/federation/v1/get/:resource/:id` for M3 inbound federation reads.
## Scope
- `apps/gateway/src/federation/server/verbs/get.controller.ts`
- `apps/gateway/src/federation/server/verbs/get-query.service.ts`
- Unit coverage for controller pipeline + query service RBAC guardrails
- Register controller/service in `FederationModule`
## Plan
1. Mirror the list verb pipeline: `FederationAuthGuard``FederationScopeService` → read-only query service.
2. Return one `_source: "local"` tagged item on success.
3. Return federation error envelopes:
- `404 not_found` when the resource id does not exist.
- `403 scope_violation` when the row exists but falls outside native RBAC/scope intersection.
- `400 invalid_request` for malformed ids/scope requests.
4. Keep read audit persistence deferred to M4; no body or response persistence in M3.
## Verification Evidence
- Rebased onto `origin/main` at `86e106fcc9a1dfa3a18f7846bb477be128794aad` after M3-05 merged; resolved `FederationModule` by registering both list and get verb controllers/services.
- Review-change coverage added for comment 15971:
- get note access now requires subject ownership AND authorized mission intersection.
- missing federation context returns structured `401 unauthorized` envelope.
- unsupported get resources fail closed with structured denial.
- PGlite regressions cover cross-user note exclusion and subject-note unauthorized-mission exclusion.
- `pnpm --filter @mosaicstack/gateway test -- src/federation/server/verbs/__tests__/get.controller.spec.ts src/federation/server/verbs/__tests__/get-query.service.spec.ts` — pass (2 files / 17 tests; re-run after review changes).
- `pnpm --filter @mosaicstack/gateway build` — pass (re-run after review changes).
- `pnpm build` — pass (23 successful tasks before review changes).
- `pnpm typecheck` — pass (41 successful tasks; re-run after review changes).
- `pnpm lint` — pass (23 successful tasks; re-run after review changes).
- `pnpm format:check` — pass (re-run after review changes).
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings after review changes.

View File

@@ -0,0 +1,33 @@
# 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.

View File

@@ -1,82 +0,0 @@
# B1 / @next Durable Publish Pipeline — Design
## Objective
Make `next` a durable integration line that publishes the artifacts required by downstream federation boot tests without manual builds.
Every merge to `next` publishes:
1. **npm prerelease packages** to the Gitea npm registry with dist-tag `next`.
2. **Gateway container image** tagged only as `gateway:sha-<short>`.
The existing stable release behavior remains isolated to `main` / tags.
## Registry verification
Target registry: `https://git.mosaicstack.dev/api/packages/mosaicstack/npm/`.
Pre-implementation checks:
- `npm view @mosaicstack/mosaic dist-tags --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/ --json` returned a dist-tags object (`latest: 0.0.48`).
- `npm view @mosaicstack/mosaic@latest version --registry https://git.mosaicstack.dev/api/packages/mosaicstack/npm/` resolved `0.0.48`.
- `@next` currently returns 404 because no `next` dist-tag exists yet; this is expected before the first next prerelease publish.
Pipeline design includes a post-publish verification that `npm view @mosaicstack/mosaic@next version` resolves to the exact CI-computed prerelease version. If Gitea fails to honor the `next` dist-tag, the pipeline fails closed.
## Version scheme
The prerelease version is computed at publish time only; no `package.json` version changes are committed.
For each non-private `@mosaicstack/*` package:
```text
<target-stable>-next.<CI_PIPELINE_NUMBER>
```
Where:
- `CI_PIPELINE_NUMBER` is Woodpecker's monotonic pipeline number.
- `target-stable` is the package's current committed stable version with the patch component incremented.
- Example: `@mosaicstack/mosaic` `0.0.48` publishes as `0.0.49-next.1626`.
- Example: `@mosaicstack/gateway` `0.0.6` publishes as `0.0.7-next.1626`.
Rationale:
- npm semver sorts `0.0.49-next.1627` above `0.0.49-next.1626`.
- The prerelease does not overtake the future stable `0.0.49`.
- The monotonic pipeline number avoids conflicts across repeated `next` merges.
## Branch and tag guardrails
| Pipeline path | Branch/event | Publishes | Forbidden |
| --------------------- | ------------------------------ | ------------------------------------------------------- | ---------------------- |
| stable npm publish | `main` push/manual or tag | package versions already committed in package manifests | `@next` dist-tag |
| next npm publish | `next` push/manual only | CI-computed prereleases with `--tag next` | `latest` dist-tag |
| gateway image | `main` push/manual or tag | `sha-<short>` + `latest` on main + tag on tag events | next prerelease npm |
| gateway image | `next` push/manual only | `sha-<short>` only | `latest` |
| appservice/web images | `main` push/manual or tag only | existing stable image behavior | next image publication |
The pipeline has explicit branch checks inside the publish commands as a second fail-closed layer beyond Woodpecker `when` clauses.
## Implementation plan
1. Widen `.woodpecker/publish.yml` top-level `when` to include `next` so the publish pipeline runs on next merges.
2. Keep existing `publish-npm` on `main` / tags only.
3. Add `publish-next-npm` for `next` push/manual only:
- configure Gitea npm auth from existing `gitea_token` secret as `NPM_TOKEN`;
- preflight registry dist-tag metadata;
- compute prerelease versions in CI by temporarily editing package manifests in the workspace;
- run `pnpm publish ... --tag next` against non-private `@mosaicstack/*` packages;
- verify `@mosaicstack/mosaic@next` resolves to the computed version.
4. Split image `when` anchors:
- `image_build_when` includes `next` and is used by `build-gateway`;
- `main_image_build_when` keeps appservice/web on main/tags only.
5. Keep gateway next image destinations to `sha-<short>` only; no `latest` on next.
## Risk controls
- Auth/registry failures are fatal.
- No manual image build/push path is introduced.
- No production `latest` tags are touched from `next`.
- No `@latest` npm dist-tags are touched from `next`.
- All changes live in CI config and docs; no runtime source behavior changes.

View File

@@ -1,34 +0,0 @@
# B2 — Fresh-install skills sync path
## Problem
Greenfield wizard on `next` reported:
```text
Skills sync script not found at ~/.config/mosaic/bin/mosaic-sync-skills
Skills: install failed
```
## Diagnosis
The framework install migration removed the legacy `~/.config/mosaic/bin/` directory and now installs framework helper scripts under:
```text
~/.config/mosaic/tools/_scripts/
```
`packages/mosaic/src/stages/finalize.ts` still resolved wizard helper scripts from `mosaicHome/bin`, so wizard-selected skills failed even though `mosaic-sync-skills` was present in the current framework layout.
## Fix
- Resolve framework helper scripts through `tools/_scripts/<name>` first.
- Keep a legacy `bin/<name>` fallback for pre-migration installs.
- Point missing-script warnings at the current `tools/_scripts` layout.
- Update the finalize skills test fixture to model the fresh framework layout.
- Update framework README examples from legacy `bin/` helper paths to `tools/_scripts/`.
## Verification
- Unit: `pnpm --filter @mosaicstack/mosaic test -- finalize-skills`
- Gates: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build`
- Fresh path: ran `packages/mosaic/framework/install.sh` with a temp `MOSAIC_HOME` and `MOSAIC_SYNC_ONLY=1`; verified `tools/_scripts/mosaic-sync-skills` exists, legacy `bin/mosaic-sync-skills` does not, and the script installs a selected fake `lint` skill into Mosaic + Pi runtime skill directories.

View File

@@ -1,36 +0,0 @@
# B3 — Wizard completion ordering
## Problem
The wizard printed the success summary / `Mosaic is ready.` during `finalizeStage`, before the gateway configuration stage had completed its daemon health check. If the gateway health gate later failed, the user could see a success claim followed by a gateway failure.
## Diagnosis
`finalizeStage` handled both mutation work and terminal success messaging. Wizard paths then ran `gatewayConfigStage` and `gatewayBootstrapStage` afterward:
1. finalize writes config, links runtime assets, syncs skills, runs doctor;
2. finalize prints `Installation Summary` + `Mosaic is ready.`;
3. gateway config starts/waits for daemon health;
4. gateway bootstrap runs.
The summary needed to be deferred until after the gateway readiness gates.
## Fix
- `finalizeStage` now returns a `showSummary()` callback and supports `deferSummary`.
- Wizard/quick-start paths call finalize with `deferSummary: true`.
- `showSummary()` is called only after gateway config reports ready and bootstrap completes, or immediately when the caller explicitly skips gateway setup.
- If gateway health/config reports not ready, the wizard returns/aborts without printing the success summary.
- Folded in adjacent runtime install hint fix for Pi: `curl -fsSL https://pi.dev/install.sh | sh`.
## Verification
- Added unified-wizard coverage for summary-after-health and no-summary-on-health-failure.
- Targeted: `pnpm --filter @mosaicstack/mosaic test -- unified-wizard finalize-skills`
- `pnpm format:check`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm build`
- `pnpm test`
- Codex code review: approve.
- Codex security review: one low finding on the requested Pi `curl | sh` install hint; no security finding in the wizard completion-ordering change.

View File

@@ -1,36 +0,0 @@
# B4 — Wizard step deduplication
## Problem
Greenfield wizard testing showed completed wizard steps could be executed again after the menu marked them `[done]`. In practice this made the Providers/API-key flow and Skills flow appear twice in one wizard run.
There was a second related API-key duplication path: when the Providers step was completed with no key, `gatewayConfigStage` still prompted for `ANTHROPIC_API_KEY` during Finish because it only skipped the gateway API-key prompt when `providerKey` was non-empty.
## Diagnosis
- `runMenuLoop` labeled completed sections with `[done]`, but still dispatched the selected step again if the user selected that row.
- Quick Start ran Providers and Skills but did not mark those sections complete in `completedSections`.
- `runFinishPath`/`quickStartPath` defaulted `providerType` to `none` for gateway config, which made it impossible for `gatewayConfigStage` to distinguish:
- provider step completed and user intentionally skipped the key, vs.
- provider step was never run.
## Fix
- Added a shared menu section key helper and a completed-step guard in `runMenuLoop`.
- Completed menu steps now log a skip message instead of re-running their stage.
- Quick Start marks Providers and Skills complete after running them.
- Finish/Quick Start now pass `state.providerType` as-is to gateway config instead of defaulting to `none`.
- `gatewayConfigStage` treats `providerType: 'none'` as an explicit completed provider setup with no key and skips the second gateway API-key prompt.
## Verification
- Added unified wizard regression coverage asserting repeated Providers/Skills menu selections only execute each stage once.
- Added gateway config coverage asserting `providerType: 'none'` does not prompt for a gateway API key and writes no API key env var.
- Targeted: `pnpm --filter @mosaicstack/mosaic test -- unified-wizard gateway-config`
- `pnpm format:check`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm build`
- `pnpm test`
- Codex code review: approve.
- Codex security review: no findings.

View File

@@ -1,60 +0,0 @@
# FED-M3-10 — Federation M3 Integration Tests
## Objective
Add single-gateway gateway integration tests for M3 acceptance #6 and #7.
## Branch / base
- Branch: `feat/federation-m3-integration`
- Base: `origin/next` (`838701bd` after M3-06/#683 merge)
- PR base when unblocked: `next`
## Scope
- Real PostgreSQL via `@mosaicstack/db`.
- Mocked TLS context / Fastify request shim for `FederationAuthGuard`.
- Direct controller calls using the real M3 route contract: `POST /api/federation/v1/list/:resource` with body `{ limit?, cursor? }`.
- Gated by `FEDERATED_INTEGRATION=1`.
- No federation harness dependency.
## Fixture notes
Aligned with the B2 seed design vocabulary:
- `tasks` visibility uses personal `projects` + `missions` chain.
- `notes` are `mission_tasks.notes`; the integration suite asserts subject-only note visibility on an authorized mission.
- Seed includes a second user and unauthorized team/project tasks to prove exclusion from the max-row-cap list result.
- Grants/peers are direct DB fixtures; cert auth still runs through `FederationAuthGuard` using real X.509 certs generated by existing test helpers.
## Current implementation
Added `apps/gateway/src/__tests__/integration/federation-m3-list.integration.test.ts` covering:
1. M3 #6 — cert missing Mosaic OIDs returns 401 federation `unauthorized` envelope.
2. M3 #6 — valid cert whose grant row is `revoked` returns 403 federation `forbidden` envelope.
3. M3 #7 — active grant with `max_rows_per_query: 2` caps `list tasks`, returns `_truncated` + `nextCursor`, source-tags rows, and excludes other-user / unauthorized-team tasks.
4. Cross-user notes invariant — subject can list their own `mission_tasks.notes` row while another user's note on the same authorized mission is excluded.
5. Unsupported-resource invariant — `list widgets` fails closed with a federation `scope_violation` envelope.
## Verification
- `pnpm --filter @mosaicstack/types build` — PASS.
- `pnpm --filter @mosaicstack/db build` — PASS.
- `pnpm --filter @mosaicstack/storage build` — PASS.
- `pnpm --filter @mosaicstack/brain build` — PASS.
- `pnpm --filter @mosaicstack/queue build` — PASS.
- `pnpm --filter @mosaicstack/config build` — PASS.
- `pnpm --filter @mosaicstack/auth build` — PASS.
- `pnpm --filter @mosaicstack/gateway test -- src/__tests__/integration/federation-m3-list.integration.test.ts` — PASS skipped when `FEDERATED_INTEGRATION` unset (5 skipped).
- `FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- src/__tests__/integration/federation-m3-list.integration.test.ts` — PASS (5 tests) after local `docker compose up -d postgres` + `pnpm --filter @mosaicstack/db db:push`.
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
- `pnpm format:check` — PASS.
- `~/.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.
## Push / PR
- #683 landed in `next`; branch rebased onto `origin/next` before push.
- CI is serialized; run queue guard before push.

View File

@@ -1,40 +0,0 @@
# Installer `--next` fast npm lane — 2026-06-25
## Scope
Flip `tools/install.sh --next` from source-build-first to fast npm `@next` first, with source fallback.
## Registry reality check
Gitea npm registry: `https://git.mosaicstack.dev/api/packages/mosaicstack/npm/`
Verified before implementation:
- `@mosaicstack/mosaic@next` resolves to `0.0.49-next.1633`.
- `@mosaicstack/gateway@next` resolves to `0.0.7-next.1633`.
- `@mosaicstack/gateway` dist-tags include `latest: 0.0.6` and `next: 0.0.7-next.1633`.
- `apps/gateway/package.json` is non-private and has Gitea npm `publishConfig`.
Conclusion: the installer can fast-install both CLI and gateway npm packages for `--next`. The gateway Docker `gateway:sha-<short>` remains the deployment/harness artifact; the npm gateway package is valid for the installer global package path.
## Behavior
- `--next` with no explicit ref:
1. framework archive from `next`;
2. resolve `@mosaicstack/gateway@next` and `@mosaicstack/mosaic@next`;
3. require both resolved versions to share the same `next.<pipeline>` suffix;
4. install the exact resolved package versions;
5. set `MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1` so wizard does not overwrite the prerelease gateway;
6. if either package is missing/unreachable/mismatched/fails, fall back to existing source build at `next`.
- `--dev` remains pure source build.
- explicit `--ref` / `MOSAIC_REF` still wins over `--next` and uses the source path for that exact ref.
## Install detail
The installer writes the scoped npmrc mapping (`@mosaicstack:registry=...`) and then runs npm install without overriding npm's default registry. Passing `--registry=<gitea>` to `npm install` forces public transitive dependencies (for example `@anthropic-ai/sdk`) to resolve from Gitea and breaks the fast path; the scoped npmrc mapping is the correct split-registry behavior.
## Verification notes
- Added `tools/install-next-lane.test.sh` with a fake npm/source harness for exact-version fast install, registry failure source fallback, explicit-ref precedence, and mismatched suffix warning.
- Wired the installer harness into `pnpm test` via `pnpm run test:installer`.
- Real temp-prefix fast install succeeded with `@mosaicstack/gateway@0.0.7-next.1633` and `@mosaicstack/mosaic@0.0.49-next.1633`.

View File

@@ -1,35 +0,0 @@
# Scratchpad — installer `--next` lane
## Objective
Add a prerelease installer lane for the permanent `next` integration branch.
## Scope
- `tools/install.sh`
- README/install documentation
- Follow-up design note for future npm `@next` prerelease publishing
## Plan
1. Add `--next` and `MOSAIC_NEXT=1` as source-build shorthand for `next`.
2. Preserve explicit ref precedence: `MOSAIC_REF` and `--ref` win over `--next`.
3. Update installer source display/help text.
4. Document three lanes:
- stable npm `@latest`
- prerelease `--next`
- contributor `--dev --ref X`
5. Run shell and repo gates locally, then hold before push/PR until runner serialization greenlight.
## Verification
- `bash -n tools/install.sh` — pass.
- `docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable tools/install.sh` — pass.
- `bash tools/install.sh --check --framework --next` — source display shows `ref: next, --next prerelease lane`.
- `bash tools/install.sh --check --cli --next --ref feature-x` — source display shows explicit ref wins.
- `MOSAIC_NEXT=1 MOSAIC_REF=feature-env bash tools/install.sh --check --cli` — source display shows explicit env ref wins.
- `pnpm install --frozen-lockfile --prefer-offline --store-dir /home/jarvis/.local/share/pnpm/store` — pass (local override for repo `.npmrc` CI store path).
- `pnpm typecheck` — pass (41 successful tasks).
- `pnpm lint` — pass (23 successful tasks).
- `pnpm format:check` — pass.
- `bash tools/e2e-install-test.sh` — attempted; current baseline fails during gateway health after stable registry install because Valkey is unavailable in the clean container. The `tools/install.sh --yes --no-auto-launch` stage itself completed before the downstream gateway verification failure.

View File

@@ -15,6 +15,22 @@ 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

View File

@@ -7,8 +7,7 @@
"dev": "turbo run dev", "dev": "turbo run dev",
"lint": "turbo run lint", "lint": "turbo run lint",
"typecheck": "turbo run typecheck", "typecheck": "turbo run typecheck",
"test": "turbo run test && pnpm run test:installer", "test": "turbo run test",
"test:installer": "bash tools/install-next-lane.test.sh",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"", "format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
"prepare": "husky" "prepare": "husky"

View File

@@ -11,37 +11,9 @@ import { join } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js'; import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
import { createConfigService } from '../../src/config/config-service.js'; import { createConfigService } from '../../src/config/config-service.js';
import type { SelectOption } from '../../src/prompter/interface.js';
import type { MenuSection, WizardState } from '../../src/types.js';
const gatewayConfigMock = vi.fn(); const gatewayConfigMock = vi.fn();
const gatewayBootstrapMock = vi.fn(); const gatewayBootstrapMock = vi.fn();
const providerSetupMock = vi.fn();
const skillsSelectMock = vi.fn();
class SequencedMenuPrompter extends HeadlessPrompter {
constructor(
answers: Record<string, string | boolean | string[]>,
private readonly menuChoices: string[],
) {
super(answers);
}
override async select<T>(opts: {
message: string;
options: SelectOption<T>[];
initialValue?: T;
}): Promise<T> {
if (opts.message === 'What would you like to configure?') {
const next = this.menuChoices.shift();
if (!next) throw new Error('No queued menu choice left');
const match = opts.options.find((o) => String(o.value) === next);
if (!match) throw new Error(`Queued menu choice not available: ${next}`);
return match.value;
}
return super.select(opts);
}
}
vi.mock('../../src/stages/gateway-config.js', () => ({ vi.mock('../../src/stages/gateway-config.js', () => ({
gatewayConfigStage: (...args: unknown[]) => gatewayConfigMock(...args), gatewayConfigStage: (...args: unknown[]) => gatewayConfigMock(...args),
@@ -51,14 +23,6 @@ vi.mock('../../src/stages/gateway-bootstrap.js', () => ({
gatewayBootstrapStage: (...args: unknown[]) => gatewayBootstrapMock(...args), gatewayBootstrapStage: (...args: unknown[]) => gatewayBootstrapMock(...args),
})); }));
vi.mock('../../src/stages/provider-setup.js', () => ({
providerSetupStage: (...args: unknown[]) => providerSetupMock(...args),
}));
vi.mock('../../src/stages/skills-select.js', () => ({
skillsSelectStage: (...args: unknown[]) => skillsSelectMock(...args),
}));
// Import AFTER the mocks so runWizard picks up the mocked stage modules. // Import AFTER the mocks so runWizard picks up the mocked stage modules.
import { runWizard } from '../../src/wizard.js'; import { runWizard } from '../../src/wizard.js';
@@ -80,16 +44,6 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
} }
gatewayConfigMock.mockReset(); gatewayConfigMock.mockReset();
gatewayBootstrapMock.mockReset(); gatewayBootstrapMock.mockReset();
providerSetupMock.mockReset();
skillsSelectMock.mockReset();
providerSetupMock.mockImplementation(async (_p: HeadlessPrompter, state: WizardState) => {
state.providerType = 'none';
state.completedSections?.add('providers' satisfies MenuSection);
});
skillsSelectMock.mockImplementation(async (_p: HeadlessPrompter, state: WizardState) => {
state.selectedSkills = [];
state.completedSections?.add('skills' satisfies MenuSection);
});
// Pretend we're on an interactive TTY so the wizard's headless-abort // Pretend we're on an interactive TTY so the wizard's headless-abort
// branch does not call `process.exit(1)` during these tests. // branch does not call `process.exit(1)` during these tests.
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
@@ -144,12 +98,8 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
expect(bootstrapCall[2]).toMatchObject({ host: 'localhost', port: 14242 }); expect(bootstrapCall[2]).toMatchObject({ host: 'localhost', port: 14242 });
}); });
it('prints the success summary only after gateway health succeeds', async () => { it('does not invoke bootstrap when config stage reports not ready', async () => {
gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => { gatewayConfigMock.mockResolvedValue({ ready: false });
p.log('Gateway is healthy.');
return { ready: true, host: 'localhost', port: 14242 };
});
gatewayBootstrapMock.mockResolvedValue({ completed: true });
const prompter = new HeadlessPrompter({ const prompter = new HeadlessPrompter({
'Installation mode': 'quick', 'Installation mode': 'quick',
@@ -168,43 +118,6 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
skipGatewayNpmInstall: true, skipGatewayNpmInstall: true,
}); });
const logs = prompter.getLogs();
const healthIndex = logs.findIndex((line) => line.includes('Gateway is healthy.'));
const summaryIndex = logs.findIndex((line) => line.includes('Installation Summary'));
const readyIndex = logs.findIndex((line) => line.includes('Mosaic is ready.'));
expect(healthIndex).toBeGreaterThanOrEqual(0);
expect(summaryIndex).toBeGreaterThan(healthIndex);
expect(readyIndex).toBeGreaterThan(summaryIndex);
});
it('does not claim success when gateway health reports not ready', async () => {
gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => {
p.warn('Gateway did not become healthy within 30 seconds.');
return { ready: false };
});
const prompter = new HeadlessPrompter({
'Installation mode': 'quick',
'What name should agents use?': 'TestBot',
'Communication style': 'direct',
'Your name': 'Tester',
'Your pronouns': 'They/Them',
'Your timezone': 'UTC',
});
await runWizard({
mosaicHome: tmpDir,
sourceDir: tmpDir,
prompter,
configService: createConfigService(tmpDir, tmpDir),
skipGatewayNpmInstall: true,
});
const logs = prompter.getLogs();
expect(logs.some((line) => line.includes('Gateway did not become healthy'))).toBe(true);
expect(logs.some((line) => line.includes('Installation Summary'))).toBe(false);
expect(logs.some((line) => line.includes('Mosaic is ready.'))).toBe(false);
expect(gatewayConfigMock).toHaveBeenCalledTimes(1); expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
expect(gatewayBootstrapMock).not.toHaveBeenCalled(); expect(gatewayBootstrapMock).not.toHaveBeenCalled();
}); });
@@ -230,34 +143,4 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
expect(gatewayConfigMock).not.toHaveBeenCalled(); expect(gatewayConfigMock).not.toHaveBeenCalled();
expect(gatewayBootstrapMock).not.toHaveBeenCalled(); expect(gatewayBootstrapMock).not.toHaveBeenCalled();
}); });
it('does not re-run completed provider or skills menu steps', async () => {
const prompter = new SequencedMenuPrompter(
{
'What name should agents use?': 'TestBot',
'Communication style': 'direct',
'Your name': 'Tester',
'Your pronouns': 'They/Them',
'Your timezone': 'UTC',
},
['providers', 'providers', 'skills', 'skills', 'finish'],
);
await runWizard({
mosaicHome: tmpDir,
sourceDir: tmpDir,
prompter,
configService: createConfigService(tmpDir, tmpDir),
skipGateway: true,
});
expect(providerSetupMock).toHaveBeenCalledTimes(1);
expect(skillsSelectMock).toHaveBeenCalledTimes(1);
expect(prompter.getLogs()).toEqual(
expect.arrayContaining([
expect.stringContaining('Providers [done] is already complete; skipping.'),
expect.stringContaining('Skills [done] is already complete; skipping.'),
]),
);
});
}); });

View File

@@ -43,16 +43,6 @@ The installer:
- Runs a health audit - Runs a health audit
- Detects existing installs and preserves local files (SOUL.md, USER.md, etc.) - Detects existing installs and preserves local files (SOUL.md, USER.md, etc.)
### Install lanes
| Lane | Command | Use when | Source |
| ------------------------ | ------------------------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------- |
| Stable | `bash tools/install.sh` | You want the released framework and CLI | npm `@mosaicstack/mosaic@latest` + `main` |
| Prerelease integration | `bash tools/install.sh --next` | You want the permanent `next` integration lane | Fast npm `@mosaicstack/mosaic@next` + `@mosaicstack/gateway@next`; source fallback at `next` |
| Contributor/source build | `bash tools/install.sh --dev --ref X` | You are validating a branch before release | Build-from-source at the requested git ref |
`--next` is fast-by-default from the Gitea npm `next` dist-tag and falls back to a source build at the permanent `next` branch if the dist-tag is missing or unreachable. Explicit `--ref` or `MOSAIC_REF` wins and uses the source path.
## First Run ## First Run
After install, open a new terminal (or `source ~/.bashrc`) and run: After install, open a new terminal (or `source ~/.bashrc`) and run:
@@ -118,8 +108,8 @@ You can still launch runtimes directly (`claude`, `codex`, etc.) — thin runtim
├── TOOLS.md ← Machine-level tool reference (generated by mosaic init) ├── TOOLS.md ← Machine-level tool reference (generated by mosaic init)
├── STANDARDS.md ← Machine-wide standards ├── STANDARDS.md ← Machine-wide standards
├── guides/ ← Operational guides (E2E delivery, PRD, docs, etc.) ├── guides/ ← Operational guides (E2E delivery, PRD, docs, etc.)
├── bin/ ← CLI tools (mosaic launcher, mosaic-init, mosaic-doctor, etc.)
├── tools/ ← Tool suites: git, orchestrator, prdy, quality, etc. ├── tools/ ← Tool suites: git, orchestrator, prdy, quality, etc.
│ └── _scripts/ ← Framework helper scripts (sync skills, doctor, runtime links)
├── runtime/ ← Runtime adapters + runtime-specific references ├── runtime/ ← Runtime adapters + runtime-specific references
│ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks │ ├── claude/ ← CLAUDE.md, RUNTIME.md, settings.json, hooks
│ ├── codex/ ← instructions.md, RUNTIME.md │ ├── codex/ ← instructions.md, RUNTIME.md
@@ -184,9 +174,7 @@ The installer preserves local `SOUL.md`, `USER.md`, `TOOLS.md`, and `memory/` by
bash tools/install.sh --check # Version check only bash tools/install.sh --check # Version check only
bash tools/install.sh --framework # Framework only (skip npm CLI) bash tools/install.sh --framework # Framework only (skip npm CLI)
bash tools/install.sh --cli # npm CLI only (skip framework) bash tools/install.sh --cli # npm CLI only (skip framework)
bash tools/install.sh --next # Prerelease lane: npm @next, source fallback bash tools/install.sh --ref v1.0 # Install from a specific git ref
bash tools/install.sh --dev # Contributor lane: source build at --ref/main
bash tools/install.sh --ref v1.0 # Install from a specific git ref (--ref wins over --next)
``` ```
## Universal Skills ## Universal Skills
@@ -195,14 +183,14 @@ The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/ski
```bash ```bash
mosaic sync # Full sync (clone + link) mosaic sync # Full sync (clone + link)
~/.config/mosaic/tools/_scripts/mosaic-sync-skills --link-only # Re-link only ~/.config/mosaic/bin/mosaic-sync-skills --link-only # Re-link only
``` ```
## Health Audit ## Health Audit
```bash ```bash
mosaic doctor # Standard audit mosaic doctor # Standard audit
~/.config/mosaic/tools/_scripts/mosaic-doctor --fail-on-warn # Strict mode ~/.config/mosaic/bin/mosaic-doctor --fail-on-warn # Strict mode
``` ```
## MCP Registration ## MCP Registration
@@ -213,8 +201,8 @@ sequential-thinking MCP is required for Mosaic Stack. The installer registers it
To verify or re-register manually: To verify or re-register manually:
```bash ```bash
~/.config/mosaic/tools/_scripts/mosaic-ensure-sequential-thinking ~/.config/mosaic/bin/mosaic-ensure-sequential-thinking
~/.config/mosaic/tools/_scripts/mosaic-ensure-sequential-thinking --check ~/.config/mosaic/bin/mosaic-ensure-sequential-thinking --check
``` ```
### Claude Code MCP Registration ### Claude Code MCP Registration

View File

@@ -15,6 +15,22 @@ 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

View File

@@ -87,6 +87,10 @@ message crosses the wire as base64 (`-b`) to avoid all shell-quoting hazards.
- `agent-send.sh` — inter-agent wrapper (preamble + local/remote dispatch). - `agent-send.sh` — inter-agent wrapper (preamble + local/remote dispatch).
- `send-message.sh` — low-level reliable single-pane submitter (`-b` base64 input). - `send-message.sh` — low-level reliable single-pane submitter (`-b` base64 input).
- `auto-submit-drafts.sh` — watchdog that flushes stable unsubmitted prompt
drafts on a coordinator pane (default target `mos-claude`); run it as a
long-lived process alongside the coordinator session.
- `agent-send.test.sh` — regression + grammar lock for `agent-send.sh`.
- `test-send-message-socket.sh` — smoke test for named-socket isolation. - `test-send-message-socket.sh` — smoke test for named-socket isolation.
## Distribution ## Distribution

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# auto-submit-drafts.sh — watchdog for Claude Code panes that receive channel
# messages but leave them as unsubmitted prompt drafts. Intended for Mos only.
set -uo pipefail
TARGET="${1:-mos-claude}"
INTERVAL="${INTERVAL:-2}"
STABLE_SECONDS="${STABLE_SECONDS:-4}"
LOG_PREFIX="[auto-submit-drafts:$TARGET]"
last_prompt=""
first_seen=0
prompt_text() {
tmux capture-pane -t "$TARGET" -p 2>/dev/null | python3 -c '
import sys, re
lines = sys.stdin.read().splitlines()
idx = None
for i in range(len(lines)-1, -1, -1):
if "" in lines[i]:
idx = i
break
if idx is None:
raise SystemExit
parts = []
after = lines[idx].split("", 1)[1]
parts.append(after)
for line in lines[idx+1:]:
# Stop at Claude Code separator/border lines.
if "─" in line or "╰" in line or "╭" in line:
break
s = line.replace("\u00a0", " ")
s = re.sub(r"[\x00-\x1f\x7f]", "", s).strip()
if s:
parts.append(s)
text = " ".join(parts).replace("\u00a0", " ")
text = re.sub(r"[\x00-\x1f\x7f]", "", text).strip()
print(text)
'
}
while true; do
if ! tmux has-session -t "$TARGET" 2>/dev/null; then
echo "$LOG_PREFIX target missing; waiting" >&2
sleep "$INTERVAL"
last_prompt=""
first_seen=0
continue
fi
current="$(prompt_text || true)"
now="$(date +%s)"
if [[ -z "$current" ]]; then
last_prompt=""
first_seen=0
sleep "$INTERVAL"
continue
fi
if [[ "$current" != "$last_prompt" ]]; then
last_prompt="$current"
first_seen="$now"
sleep "$INTERVAL"
continue
fi
age=$(( now - first_seen ))
if (( age >= STABLE_SECONDS )); then
echo "$LOG_PREFIX submitting stable draft after ${age}s: ${current:0:120}" >&2
tmux send-keys -t "$TARGET" C-j
sleep 0.8
tmux send-keys -t "$TARGET" C-m
sleep 2
last_prompt=""
first_seen=0
else
sleep "$INTERVAL"
fi
done

View File

@@ -77,10 +77,20 @@ snippet=$(printf '%s' "$MSG" | tr '\n' ' ' | tr -s ' ' | sed 's/[^[:print:]]//g'
# 1) Paste the body as a bracketed paste so multi-line content does not submit # 1) Paste the body as a bracketed paste so multi-line content does not submit
# line-by-line. load-buffer/paste-buffer is far safer than `send-keys -l`. # line-by-line. load-buffer/paste-buffer is far safer than `send-keys -l`.
printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b __mosaic_send - # Buffer name MUST be unique per invocation: concurrent senders on the shared
# tmux server race a fixed name (load overwrites load, -d deletes underneath),
# cross-delivering or dropping messages — bit the fleet on the 2026-07-09
# simultaneous restart (briefs swapped between sessions).
BUF="__mosaic_send_$$_$(date +%s%N)"
printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -
# -p = bracketed paste when the client supports it; fall back if not. # -p = bracketed paste when the client supports it; fall back if not.
"${tmux_cmd[@]}" paste-buffer -d -p -b __mosaic_send -t "$EFFECTIVE_TARGET" 2>/dev/null \ "${tmux_cmd[@]}" paste-buffer -d -p -b "$BUF" -t "$EFFECTIVE_TARGET" 2>/dev/null \
|| "${tmux_cmd[@]}" paste-buffer -d -b __mosaic_send -t "$EFFECTIVE_TARGET" || "${tmux_cmd[@]}" paste-buffer -d -b "$BUF" -t "$EFFECTIVE_TARGET" \
|| "${tmux_cmd[@]}" delete-buffer -b "$BUF" 2>/dev/null
# ^ -d deletes the buffer only on a SUCCESSFUL paste; if both attempts fail
# (e.g. the target vanished since the liveness check), delete explicitly —
# named buffers are exempt from tmux's buffer-limit eviction, so orphans
# would otherwise accumulate forever.
sleep 0.5 sleep 0.5
# 2) Submit, then verify; flush with another Enter if it is still a draft. # 2) Submit, then verify; flush with another Enter if it is still a draft.

View File

@@ -47,4 +47,32 @@ if capture_default | grep -qF "agent socket hello"; then
fail "agent-send.sh leaked named-socket message to default tmux server" fail "agent-send.sh leaked named-socket message to default tmux server"
fi fi
# Concurrency: parallel senders on one server must not cross-deliver or drop.
# Locks the unique-per-invocation paste buffer (a fixed buffer name raced:
# load overwrote load, -d deleted underneath — messages swapped between panes).
CONC_N=5
for i in $(seq 1 "$CONC_N"); do
tmux -L "$SOCKET" new-session -d -s "conc-$i" -c "$TMPDIR" 'bash --noprofile --norc -i'
done
pids=()
for i in $(seq 1 "$CONC_N"); do
"$SEND_MESSAGE" -L "$SOCKET" -t "=conc-$i" -m "CONCPAYLOAD-${i}-END" >/dev/null &
pids+=($!)
done
for pid in "${pids[@]}"; do
wait "$pid" || fail "concurrent send-message.sh invocation exited non-zero"
done
sleep 0.2
for i in $(seq 1 "$CONC_N"); do
pane=$(tmux -L "$SOCKET" capture-pane -t "=conc-$i:0.0" -p)
printf '%s' "$pane" | grep -qF "CONCPAYLOAD-${i}-END" \
|| fail "concurrent send dropped payload for pane conc-$i"
for j in $(seq 1 "$CONC_N"); do
[ "$j" = "$i" ] && continue
if printf '%s' "$pane" | grep -qF "CONCPAYLOAD-${j}-END"; then
fail "concurrent send cross-delivered payload $j to pane conc-$i"
fi
done
done
echo "ok - named tmux socket send tools" echo "ok - named tmux socket send tools"

View File

@@ -37,7 +37,7 @@ const RUNTIME_DEFS: Record<
label: 'Pi', label: 'Pi',
command: 'pi', command: 'pi',
versionFlag: '--version', versionFlag: '--version',
installHint: 'curl -fsSL https://pi.dev/install.sh | sh', installHint: 'npm install -g @mariozechner/pi-coding-agent',
}, },
}; };

View File

@@ -85,16 +85,16 @@ function makeConfigService(): ConfigService {
describe('finalizeStage — skill installer', () => { describe('finalizeStage — skill installer', () => {
let tmp: string; let tmp: string;
let scriptsDir: string; let binDir: string;
let syncScript: string; let syncScript: string;
beforeEach(() => { beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), 'mosaic-finalize-')); tmp = mkdtempSync(join(tmpdir(), 'mosaic-finalize-'));
scriptsDir = join(tmp, 'tools', '_scripts'); binDir = join(tmp, 'bin');
mkdirSync(scriptsDir, { recursive: true }); mkdirSync(binDir, { recursive: true });
syncScript = join(scriptsDir, 'mosaic-sync-skills'); syncScript = join(binDir, 'mosaic-sync-skills');
// Default: current framework layout has tools/_scripts and succeeds. // Default: script exists and succeeds
writeFileSync(syncScript, '#!/usr/bin/env bash\necho ok\n', { mode: 0o755 }); writeFileSync(syncScript, '#!/usr/bin/env bash\necho ok\n', { mode: 0o755 });
spawnSyncMock.mockReturnValue({ status: 0, stdout: 'ok', stderr: '' }); spawnSyncMock.mockReturnValue({ status: 0, stdout: 'ok', stderr: '' });
}); });
@@ -122,29 +122,10 @@ describe('finalizeStage — skill installer', () => {
const call = findSkillsSyncCall(); const call = findSkillsSyncCall();
expect(call).toBeDefined(); expect(call).toBeDefined();
expect(call![1]).toEqual([join(tmp, 'tools', '_scripts', 'mosaic-sync-skills')]);
const opts = call![2] as { env?: Record<string, string> }; const opts = call![2] as { env?: Record<string, string> };
expect(opts.env?.['MOSAIC_INSTALL_SKILLS']).toBe('brainstorming:lint:systematic-debugging'); expect(opts.env?.['MOSAIC_INSTALL_SKILLS']).toBe('brainstorming:lint:systematic-debugging');
}); });
it('falls back to legacy bin path for pre-migration installs', async () => {
rmSync(syncScript);
const legacyBinDir = join(tmp, 'bin');
mkdirSync(legacyBinDir, { recursive: true });
const legacySyncScript = join(legacyBinDir, 'mosaic-sync-skills');
writeFileSync(legacySyncScript, '#!/usr/bin/env bash\necho ok\n', { mode: 0o755 });
const state = makeState(tmp, ['brainstorming']);
const p = buildPrompter();
const config = makeConfigService();
await finalizeStage(p, state, config);
const call = findSkillsSyncCall();
expect(call).toBeDefined();
expect(call![1]).toEqual([legacySyncScript]);
});
it('skips the sync script entirely when no skills are selected', async () => { it('skips the sync script entirely when no skills are selected', async () => {
const state = makeState(tmp, []); const state = makeState(tmp, []);
const p = buildPrompter(); const p = buildPrompter();
@@ -184,9 +165,7 @@ describe('finalizeStage — skill installer', () => {
// spawnSync should NOT have been called for the skills script // spawnSync should NOT have been called for the skills script
expect(findSkillsSyncCall()).toBeUndefined(); expect(findSkillsSyncCall()).toBeUndefined();
expect(p.warn).toHaveBeenCalledWith( expect(p.warn).toHaveBeenCalledWith(expect.stringContaining('not found'));
expect.stringContaining('tools/_scripts/mosaic-sync-skills'),
);
}); });
it('includes skills count in the summary when install succeeds', async () => { it('includes skills count in the summary when install succeeds', async () => {

View File

@@ -7,21 +7,8 @@ import type { ConfigService } from '../config/config-service.js';
import type { WizardState } from '../types.js'; import type { WizardState } from '../types.js';
import { getShellProfilePath } from '../platform/detect.js'; import { getShellProfilePath } from '../platform/detect.js';
function frameworkScriptPath(mosaicHome: string, name: string): string {
const currentPath = join(mosaicHome, 'tools', '_scripts', name);
if (existsSync(currentPath)) return currentPath;
// Backward-compatible fallback for pre-migration installs that still have bin/.
const legacyPath = join(mosaicHome, 'bin', name);
if (existsSync(legacyPath)) return legacyPath;
// Return the current expected path so user-facing errors point at the layout
// installed by packages/mosaic/framework/install.sh.
return currentPath;
}
function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): void { function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): void {
const script = frameworkScriptPath(mosaicHome, 'mosaic-link-runtime-assets'); const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets');
if (existsSync(script)) { if (existsSync(script)) {
try { try {
spawnSync('bash', [script], { spawnSync('bash', [script], {
@@ -61,7 +48,7 @@ function syncSkills(mosaicHome: string, selectedSkills: string[]): SyncSkillsRes
return { success: true, installedCount: 0 }; return { success: true, installedCount: 0 };
} }
const script = frameworkScriptPath(mosaicHome, 'mosaic-sync-skills'); const script = join(mosaicHome, 'bin', 'mosaic-sync-skills');
if (!existsSync(script)) { if (!existsSync(script)) {
return { return {
success: false, success: false,
@@ -109,7 +96,7 @@ interface DoctorResult {
} }
function runDoctor(mosaicHome: string): DoctorResult { function runDoctor(mosaicHome: string): DoctorResult {
const script = frameworkScriptPath(mosaicHome, 'mosaic-doctor'); const script = join(mosaicHome, 'bin', 'mosaic-doctor');
if (!existsSync(script)) { if (!existsSync(script)) {
return { warnings: 0, output: 'mosaic-doctor not found' }; return { warnings: 0, output: 'mosaic-doctor not found' };
} }
@@ -162,24 +149,11 @@ function setupPath(mosaicHome: string, _p: WizardPrompter): PathAction {
} }
} }
export interface FinalizeStageOptions {
/**
* Defer the success summary/outro so callers can run downstream readiness
* gates (gateway health/bootstrap) before claiming Mosaic is ready.
*/
deferSummary?: boolean;
}
export interface FinalizeStageResult {
showSummary: () => void;
}
export async function finalizeStage( export async function finalizeStage(
p: WizardPrompter, p: WizardPrompter,
state: WizardState, state: WizardState,
config: ConfigService, config: ConfigService,
options: FinalizeStageOptions = {}, ): Promise<void> {
): Promise<FinalizeStageResult> {
p.separator(); p.separator();
const spin = p.spinner(); const spin = p.spinner();
@@ -226,11 +200,6 @@ export async function finalizeStage(
// 6. PATH setup // 6. PATH setup
const pathAction = setupPath(state.mosaicHome, p); const pathAction = setupPath(state.mosaicHome, p);
let summaryShown = false;
const showSummary = () => {
if (summaryShown) return;
summaryShown = true;
// 7. Summary // 7. Summary
const skillsSummary = skillsResult.success const skillsSummary = skillsResult.success
? skillsResult.installedCount > 0 ? skillsResult.installedCount > 0
@@ -271,11 +240,4 @@ export async function finalizeStage(
p.note(nextSteps.map((s, i) => `${(i + 1).toString()}. ${s}`).join('\n'), 'Next Steps'); p.note(nextSteps.map((s, i) => `${(i + 1).toString()}. ${s}`).join('\n'), 'Next Steps');
p.outro('Mosaic is ready.'); p.outro('Mosaic is ready.');
};
if (!options.deferSummary) {
showSummary();
}
return { showSummary };
} }

View File

@@ -136,7 +136,6 @@ describe('gatewayConfigStage', () => {
delete process.env['MOSAIC_STORAGE_TIER']; delete process.env['MOSAIC_STORAGE_TIER'];
delete process.env['MOSAIC_DATABASE_URL']; delete process.env['MOSAIC_DATABASE_URL'];
delete process.env['MOSAIC_VALKEY_URL']; delete process.env['MOSAIC_VALKEY_URL'];
delete process.env['MOSAIC_GATEWAY_SKIP_NPM_INSTALL'];
}); });
afterEach(() => { afterEach(() => {
@@ -168,75 +167,6 @@ describe('gatewayConfigStage', () => {
expect(state.gateway?.regeneratedConfig).toBe(true); expect(state.gateway?.regeneratedConfig).toBe(true);
}); });
it('installs the gateway package on fresh install when skipInstall is not set', async () => {
const p = buildPrompter();
const state = makeState('/home/user/.config/mosaic');
const result = await gatewayConfigStage(p, state, {
host: 'localhost',
defaultPort: 14242,
skipInstall: false,
});
expect(result.ready).toBe(true);
expect(daemonState.installPkgCalled).toBe(1);
});
it('honors MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1 and skips the registry install (dev/offline installs)', async () => {
process.env['MOSAIC_GATEWAY_SKIP_NPM_INSTALL'] = '1';
const p = buildPrompter();
const state = makeState('/home/user/.config/mosaic');
const result = await gatewayConfigStage(p, state, {
host: 'localhost',
defaultPort: 14242,
skipInstall: false,
});
// The source-built global gateway must NOT be overwritten by @latest.
expect(result.ready).toBe(true);
expect(daemonState.installPkgCalled).toBe(0);
});
it('does not ask for a gateway API key when provider setup was completed with no key', async () => {
delete process.env['MOSAIC_ASSUME_YES'];
const originalIsTTY = process.stdin.isTTY;
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
try {
const textFn = vi.fn(async (opts: { message: string; initialValue?: string }) => {
if (opts.message === 'Gateway port') return opts.initialValue ?? '14242';
if (opts.message === 'Web UI hostname (for browser access)') return 'localhost';
if (opts.message.includes('API_KEY')) {
throw new Error('gateway API key prompt should be skipped');
}
return '';
});
const p = buildPrompter({ text: textFn, select: vi.fn().mockResolvedValue('local') });
const state = makeState('/home/user/.config/mosaic');
const result = await gatewayConfigStage(p, state, {
host: 'localhost',
defaultPort: 14242,
skipInstall: true,
providerType: 'none',
});
expect(result.ready).toBe(true);
expect(textFn).not.toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining('API_KEY') }),
);
const envContents = readFileSync(daemonState.envFile, 'utf-8');
expect(envContents).not.toContain('ANTHROPIC_API_KEY=');
expect(envContents).not.toContain('OPENAI_API_KEY=');
} finally {
Object.defineProperty(process.stdin, 'isTTY', {
value: originalIsTTY,
configurable: true,
});
}
});
it('short-circuits when gateway is already fully installed and user declines rerun', async () => { it('short-circuits when gateway is already fully installed and user declines rerun', async () => {
// Pre-populate both files + running daemon + meta with token // Pre-populate both files + running daemon + meta with token
const fs = require('node:fs'); const fs = require('node:fs');

View File

@@ -294,12 +294,7 @@ export async function gatewayConfigStage(
} }
// Install the gateway npm package on first install or after failure. // Install the gateway npm package on first install or after failure.
// MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1 forces a skip even without opts.skipInstall: if (!opts.skipInstall && !daemonRunning) {
// used by dev/offline installs where @mosaicstack/gateway is already present
// globally (e.g. a build-from-source `install.sh --dev`) and must not be
// overwritten by the registry @latest build.
const skipNpmInstall = opts.skipInstall || process.env['MOSAIC_GATEWAY_SKIP_NPM_INSTALL'] === '1';
if (!skipNpmInstall && !daemonRunning) {
installGatewayPackage(); installGatewayPackage();
} }
@@ -511,9 +506,6 @@ async function collectAndWriteConfig(
if (opts.providerKey) { if (opts.providerKey) {
anthropicKey = opts.providerKey; anthropicKey = opts.providerKey;
p.log(`Using API key from provider setup (${opts.providerType ?? 'unknown'}).`); p.log(`Using API key from provider setup (${opts.providerType ?? 'unknown'}).`);
} else if (opts.providerType === 'none') {
anthropicKey = '';
p.log('No API key provided during provider setup; skipping gateway API key prompt.');
} else { } else {
anthropicKey = await p.text({ anthropicKey = await p.text({
message: 'ANTHROPIC_API_KEY (optional, press Enter to skip)', message: 'ANTHROPIC_API_KEY (optional, press Enter to skip)',

View File

@@ -37,7 +37,6 @@ export async function quickStartPath(
// 1. Provider setup (first question) // 1. Provider setup (first question)
await providerSetupStage(prompter, state); await providerSetupStage(prompter, state);
state.completedSections?.add('providers');
// Apply sensible defaults for everything else // Apply sensible defaults for everything else
state.soul.agentName ??= 'Mosaic'; state.soul.agentName ??= 'Mosaic';
@@ -58,13 +57,9 @@ export async function quickStartPath(
// Skills (recommended set, no user input in quick mode) // Skills (recommended set, no user input in quick mode)
await skillsSelectStage(prompter, state); await skillsSelectStage(prompter, state);
state.completedSections?.add('skills');
// Finalize writes configs/assets/skills, but defer the success summary until // Finalize (writes configs, links runtime assets, syncs skills)
// after the gateway health/bootstrap gates complete. await finalizeStage(prompter, state, configService);
const finalizeResult = await finalizeStage(prompter, state, configService, {
deferSummary: true,
});
// Gateway config + bootstrap // Gateway config + bootstrap
if (!options.skipGateway) { if (!options.skipGateway) {
@@ -77,7 +72,7 @@ export async function quickStartPath(
portOverride: options.gatewayPortOverride, portOverride: options.gatewayPortOverride,
skipInstall: options.skipGatewayNpmInstall, skipInstall: options.skipGatewayNpmInstall,
providerKey: state.providerKey, providerKey: state.providerKey,
providerType: state.providerType, providerType: state.providerType ?? 'none',
}); });
if (!configResult.ready || !configResult.host || !configResult.port) { if (!configResult.ready || !configResult.host || !configResult.port) {
@@ -85,9 +80,7 @@ export async function quickStartPath(
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.'); prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
process.exit(1); process.exit(1);
} }
return; } else {
}
const bootstrapResult = await gatewayBootstrapStage(prompter, state, { const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
host: configResult.host, host: configResult.host,
port: configResult.port, port: configResult.port,
@@ -95,14 +88,11 @@ export async function quickStartPath(
if (!bootstrapResult.completed) { if (!bootstrapResult.completed) {
prompter.warn('Admin bootstrap failed — aborting wizard.'); prompter.warn('Admin bootstrap failed — aborting wizard.');
process.exit(1); process.exit(1);
return;
} }
finalizeResult.showSummary(); }
} catch (err) { } catch (err) {
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`); prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
throw err; throw err;
} }
} else {
finalizeResult.showSummary();
} }
} }

View File

@@ -126,11 +126,6 @@ type MenuChoice =
| 'advanced' | 'advanced'
| 'finish'; | 'finish';
function menuSectionKey(section: MenuChoice): MenuSection | null {
if (section === 'quick-start' || section === 'finish') return null;
return section === 'gateway-config' ? 'gateway' : section;
}
function menuLabel(section: MenuChoice, completed: Set<MenuSection>): string { function menuLabel(section: MenuChoice, completed: Set<MenuSection>): string {
const labels: Record<MenuChoice, string> = { const labels: Record<MenuChoice, string> = {
'quick-start': 'Quick Start', 'quick-start': 'Quick Start',
@@ -142,24 +137,14 @@ function menuLabel(section: MenuChoice, completed: Set<MenuSection>): string {
finish: 'Finish & Apply', finish: 'Finish & Apply',
}; };
const base = labels[section]; const base = labels[section];
const sectionKey = menuSectionKey(section); const sectionKey: MenuSection =
if (sectionKey && completed.has(sectionKey)) { section === 'gateway-config' ? 'gateway' : (section as MenuSection);
if (completed.has(sectionKey)) {
return `${base} [done]`; return `${base} [done]`;
} }
return base; return base;
} }
function skipCompletedMenuChoice(
prompter: WizardPrompter,
completed: Set<MenuSection>,
choice: MenuChoice,
): boolean {
const sectionKey = menuSectionKey(choice);
if (!sectionKey || !completed.has(sectionKey)) return false;
prompter.log(`${menuLabel(choice, completed)} is already complete; skipping.`);
return true;
}
async function runMenuLoop( async function runMenuLoop(
prompter: WizardPrompter, prompter: WizardPrompter,
state: WizardState, state: WizardState,
@@ -216,25 +201,21 @@ async function runMenuLoop(
return; // Quick start is a complete flow — exit menu return; // Quick start is a complete flow — exit menu
case 'providers': case 'providers':
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
await providerSetupStage(prompter, state); await providerSetupStage(prompter, state);
completed.add('providers'); completed.add('providers');
break; break;
case 'identity': case 'identity':
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
await agentIntentStage(prompter, state); await agentIntentStage(prompter, state);
completed.add('identity'); completed.add('identity');
break; break;
case 'skills': case 'skills':
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
await skillsSelectStage(prompter, state); await skillsSelectStage(prompter, state);
completed.add('skills'); completed.add('skills');
break; break;
case 'gateway-config': case 'gateway-config':
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
// Gateway config is handled during Finish — mark as "configured" // Gateway config is handled during Finish — mark as "configured"
// after user reviews settings. // after user reviews settings.
await runGatewaySubMenu(prompter, state, options); await runGatewaySubMenu(prompter, state, options);
@@ -242,7 +223,6 @@ async function runMenuLoop(
break; break;
case 'advanced': case 'advanced':
if (skipCompletedMenuChoice(prompter, completed, choice)) break;
await runAdvancedSubMenu(prompter, state); await runAdvancedSubMenu(prompter, state);
completed.add('advanced'); completed.add('advanced');
break; break;
@@ -330,11 +310,8 @@ async function runFinishPath(
await skillsSelectStage(prompter, state); await skillsSelectStage(prompter, state);
} }
// Finalize writes configs/assets/skills, but defer the success summary until // Finalize (writes configs, links runtime assets, syncs skills)
// after the gateway health/bootstrap gates complete. await finalizeStage(prompter, state, configService);
const finalizeResult = await finalizeStage(prompter, state, configService, {
deferSummary: true,
});
// Gateway stages // Gateway stages
if (!options.skipGateway) { if (!options.skipGateway) {
@@ -345,7 +322,7 @@ async function runFinishPath(
portOverride: options.gatewayPortOverride, portOverride: options.gatewayPortOverride,
skipInstall: options.skipGatewayNpmInstall, skipInstall: options.skipGatewayNpmInstall,
providerKey: state.providerKey, providerKey: state.providerKey,
providerType: state.providerType, providerType: state.providerType ?? 'none',
}); });
if (configResult.ready && configResult.host && configResult.port) { if (configResult.ready && configResult.host && configResult.port) {
@@ -356,16 +333,12 @@ async function runFinishPath(
if (!bootstrapResult.completed) { if (!bootstrapResult.completed) {
prompter.warn('Admin bootstrap failed — aborting wizard.'); prompter.warn('Admin bootstrap failed — aborting wizard.');
process.exit(1); process.exit(1);
return;
} }
finalizeResult.showSummary();
} }
} catch (err) { } catch (err) {
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`); prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
throw err; throw err;
} }
} else {
finalizeResult.showSummary();
} }
} }
@@ -401,11 +374,8 @@ async function runHeadlessPath(
// Skills // Skills
await skillsSelectStage(prompter, state); await skillsSelectStage(prompter, state);
// Finalize writes configs/assets/skills, but defer the success summary until // Finalize
// after the gateway health/bootstrap gates complete. await finalizeStage(prompter, state, configService);
const finalizeResult = await finalizeStage(prompter, state, configService, {
deferSummary: true,
});
// Gateway stages // Gateway stages
if (!options.skipGateway) { if (!options.skipGateway) {
@@ -416,15 +386,13 @@ async function runHeadlessPath(
portOverride: options.gatewayPortOverride, portOverride: options.gatewayPortOverride,
skipInstall: options.skipGatewayNpmInstall, skipInstall: options.skipGatewayNpmInstall,
providerKey: state.providerKey, providerKey: state.providerKey,
providerType: state.providerType, providerType: state.providerType ?? 'none',
}); });
if (!configResult.ready || !configResult.host || !configResult.port) { if (!configResult.ready || !configResult.host || !configResult.port) {
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.'); prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
process.exit(1); process.exit(1);
return; } else {
}
const bootstrapResult = await gatewayBootstrapStage(prompter, state, { const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
host: configResult.host, host: configResult.host,
port: configResult.port, port: configResult.port,
@@ -432,15 +400,12 @@ async function runHeadlessPath(
if (!bootstrapResult.completed) { if (!bootstrapResult.completed) {
prompter.warn('Admin bootstrap failed — aborting wizard.'); prompter.warn('Admin bootstrap failed — aborting wizard.');
process.exit(1); process.exit(1);
return;
} }
finalizeResult.showSummary(); }
} catch (err) { } catch (err) {
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`); prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
throw err; throw err;
} }
} else {
finalizeResult.showSummary();
} }
} }
@@ -461,11 +426,8 @@ async function runKeepPath(
// Skills // Skills
await skillsSelectStage(prompter, state); await skillsSelectStage(prompter, state);
// Finalize writes configs/assets/skills, but defer the success summary until // Finalize
// after the gateway health/bootstrap gates complete. await finalizeStage(prompter, state, configService);
const finalizeResult = await finalizeStage(prompter, state, configService, {
deferSummary: true,
});
// Gateway stages // Gateway stages
if (!options.skipGateway) { if (!options.skipGateway) {
@@ -485,15 +447,11 @@ async function runKeepPath(
if (!bootstrapResult.completed) { if (!bootstrapResult.completed) {
prompter.warn('Admin bootstrap failed — aborting wizard.'); prompter.warn('Admin bootstrap failed — aborting wizard.');
process.exit(1); process.exit(1);
return;
} }
finalizeResult.showSummary();
} }
} catch (err) { } catch (err) {
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`); prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
throw err; throw err;
} }
} else {
finalizeResult.showSummary();
} }
} }

View File

@@ -1,222 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-next-install-test-XXXXXX")"
trap 'rm -rf "$TMP"' EXIT
FAKE_BIN="$TMP/bin"
HOME_DIR="$TMP/home"
PREFIX="$TMP/prefix"
MOSAIC_HOME="$TMP/mosaic"
STATE="$TMP/state"
LOG="$TMP/npm.log"
mkdir -p "$FAKE_BIN" "$HOME_DIR" "$STATE"
cat > "$FAKE_BIN/npm" <<'FAKE_NPM'
#!/usr/bin/env bash
set -euo pipefail
LOG="${MOSAIC_TEST_NPM_LOG:?}"
STATE="${MOSAIC_TEST_STATE:?}"
echo "$*" >> "$LOG"
if [[ "$1" == "view" ]]; then
case "$2 $3" in
"@mosaicstack/mosaic@next version") echo "0.0.49-next.999" ;;
"@mosaicstack/gateway@next version") echo "${MOSAIC_TEST_GATEWAY_NEXT_VERSION:-0.0.7-next.999}" ;;
"@mosaicstack/mosaic version") echo "0.0.48" ;;
*) echo "unexpected npm view: $*" >&2; exit 1 ;;
esac
exit 0
fi
if [[ "$1" == "install" ]]; then
case "$*" in
*"@mosaicstack/mosaic@0.0.49-next.999"*)
echo "0.0.49-next.999" > "$STATE/mosaic"
;;
*"@mosaicstack/gateway@0.0.7-next.999"*)
if [[ "${MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL:-0}" == "1" ]]; then
echo "forced gateway install failure" >&2
exit 1
fi
echo "0.0.7-next.999" > "$STATE/gateway"
;;
*"mosaicstack-mosaic-0.0.0-source.tgz"*)
echo "0.0.0-source" > "$STATE/mosaic"
;;
*"mosaicstack-gateway-0.0.0-source.tgz"*)
echo "0.0.0-source" > "$STATE/gateway"
;;
*) echo "unexpected npm install: $*" >&2; exit 1 ;;
esac
exit 0
fi
if [[ "$1" == "ls" ]]; then
cli="$(cat "$STATE/mosaic" 2>/dev/null || true)"
gateway="$(cat "$STATE/gateway" 2>/dev/null || true)"
node -e '
const cli = process.argv[1];
const gateway = process.argv[2];
const dependencies = {};
if (cli) dependencies["@mosaicstack/mosaic"] = { version: cli };
if (gateway) dependencies["@mosaicstack/gateway"] = { version: gateway };
process.stdout.write(JSON.stringify({ dependencies }));
' "$cli" "$gateway"
exit 0
fi
echo "unexpected npm command: $*" >&2
exit 1
FAKE_NPM
chmod +x "$FAKE_BIN/npm"
cat > "$FAKE_BIN/curl" <<'FAKE_CURL'
#!/usr/bin/env bash
set -euo pipefail
# The fake tar creates the source tree; curl only needs to keep the pipe alive.
exit 0
FAKE_CURL
chmod +x "$FAKE_BIN/curl"
cat > "$FAKE_BIN/tar" <<'FAKE_TAR'
#!/usr/bin/env bash
set -euo pipefail
dest=""
while [[ $# -gt 0 ]]; do
case "$1" in
-C) dest="$2"; shift 2 ;;
*) shift ;;
esac
done
if [[ -z "$dest" ]]; then
echo "fake tar missing -C destination" >&2
exit 1
fi
mkdir -p "$dest/stack/packages/mosaic" "$dest/stack/apps/gateway"
FAKE_TAR
chmod +x "$FAKE_BIN/tar"
cat > "$FAKE_BIN/pnpm" <<'FAKE_PNPM'
#!/usr/bin/env bash
set -euo pipefail
LOG="${MOSAIC_TEST_NPM_LOG:?}"
echo "pnpm $*" >> "$LOG"
if [[ "$1" == "pack" ]]; then
out=""
while [[ $# -gt 0 ]]; do
case "$1" in
--pack-destination) out="$2"; shift 2 ;;
*) shift ;;
esac
done
if [[ -z "$out" ]]; then
echo "fake pnpm pack missing destination" >&2
exit 1
fi
mkdir -p "$out"
case "$PWD" in
*/apps/gateway) touch "$out/mosaicstack-gateway-0.0.0-source.tgz" ;;
*/packages/mosaic) touch "$out/mosaicstack-mosaic-0.0.0-source.tgz" ;;
*) echo "unexpected pnpm pack cwd: $PWD" >&2; exit 1 ;;
esac
exit 0
fi
# install/build commands are no-ops in this harness.
exit 0
FAKE_PNPM
chmod +x "$FAKE_BIN/pnpm"
reset_state() {
: > "$LOG"
rm -f "$STATE"/*
}
reset_state
echo "[test] --next fast path pins resolved package versions"
OUTPUT="$(
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
PATH="$FAKE_BIN:$PATH" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
)"
grep -qF 'Installed @next packages: CLI 0.0.49-next.999, gateway 0.0.7-next.999' <<<"$OUTPUT"
grep -qF 'install -g @mosaicstack/gateway@0.0.7-next.999' "$LOG"
grep -qF 'install -g @mosaicstack/mosaic@0.0.49-next.999' "$LOG"
if grep -qE '^install -g .+@next( |$)' "$LOG"; then
echo "expected exact-version installs, found mutable @next install" >&2
exit 1
fi
if grep -qF 'Downloading source from next' <<<"$OUTPUT"; then
echo "fast path unexpectedly fell back to source" >&2
exit 1
fi
reset_state
echo "[test] fast path failure falls back to source build"
OUTPUT="$(
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
MOSAIC_TEST_FAIL_NEXT_GATEWAY_INSTALL=1 \
PATH="$FAKE_BIN:$PATH" \
bash "$ROOT/tools/install.sh" --cli --next --yes --no-auto-launch
)"
grep -qF 'Fast gateway @next install failed.' <<<"$OUTPUT"
grep -qF 'Falling back to source build at ref next; --next will not hard-fail on registry issues.' <<<"$OUTPUT"
grep -qF 'Downloading source from next' <<<"$OUTPUT"
grep -qF 'Installed from source: CLI 0.0.0-source' <<<"$OUTPUT"
grep -qF 'install -g @mosaicstack/mosaic@0.0.49-next.999' "$LOG"
grep -qE 'install -g .*/mosaicstack-gateway-0\.0\.0-source\.tgz' "$LOG"
grep -qE 'install -g .*/mosaicstack-mosaic-0\.0\.0-source\.tgz' "$LOG"
[[ "$(cat "$STATE/mosaic")" == "0.0.0-source" ]]
[[ "$(cat "$STATE/gateway")" == "0.0.0-source" ]]
reset_state
echo "[test] explicit --ref keeps source lane and avoids @next lookup"
OUTPUT="$(
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
PATH="$FAKE_BIN:$PATH" \
bash "$ROOT/tools/install.sh" --check --cli --next --ref feature-x
)"
grep -qF 'explicit ref wins, build-from-source' <<<"$OUTPUT"
if grep -qF '@next version' "$LOG"; then
echo "explicit ref should not query @next dist-tags" >&2
exit 1
fi
reset_state
echo "[test] --check --next warns on mismatched prerelease pipeline suffixes"
OUTPUT="$(
HOME="$HOME_DIR" \
MOSAIC_HOME="$MOSAIC_HOME" \
MOSAIC_PREFIX="$PREFIX" \
MOSAIC_NO_COLOR=1 \
MOSAIC_TEST_NPM_LOG="$LOG" \
MOSAIC_TEST_STATE="$STATE" \
MOSAIC_TEST_GATEWAY_NEXT_VERSION="0.0.7-next.1000" \
PATH="$FAKE_BIN:$PATH" \
bash "$ROOT/tools/install.sh" --check --cli --next
)"
grep -qF '@next registry lane incomplete, mismatched, or unreachable; --next would fall back to source.' <<<"$OUTPUT"
echo "[test] installer next lane tests passed"

View File

@@ -16,10 +16,6 @@
# --framework Install/upgrade framework only (skip npm CLI) # --framework Install/upgrade framework only (skip npm CLI)
# --cli Install/upgrade npm CLI only (skip framework) # --cli Install/upgrade npm CLI only (skip framework)
# --ref <branch> Git ref for framework archive (default: main) # --ref <branch> Git ref for framework archive (default: main)
# --next Prerelease lane: try fast npm @next install for CLI +
# gateway from the Gitea registry, then fall back to a
# source build at next if unavailable. Explicit
# --ref/MOSAIC_REF wins and uses the source path.
# --dev Build CLI + gateway FROM SOURCE at --ref instead of the # --dev Build CLI + gateway FROM SOURCE at --ref instead of the
# registry @latest. Zero registry writes — packs local # registry @latest. Zero registry writes — packs local
# tarballs and installs them globally. Use to test a branch # tarballs and installs them globally. Use to test a branch
@@ -35,7 +31,6 @@
# MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global) # MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global)
# MOSAIC_NO_COLOR — disable colour (set to 1) # MOSAIC_NO_COLOR — disable colour (set to 1)
# MOSAIC_REF — git ref for framework (default: main) # MOSAIC_REF — git ref for framework (default: main)
# MOSAIC_NEXT — equivalent to --next (set to 1)
# MOSAIC_DEV — equivalent to --dev (set to 1) # MOSAIC_DEV — equivalent to --dev (set to 1)
# MOSAIC_ASSUME_YES — equivalent to --yes (set to 1) # MOSAIC_ASSUME_YES — equivalent to --yes (set to 1)
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
@@ -54,12 +49,7 @@ FLAG_NO_AUTO_LAUNCH=false
FLAG_YES=false FLAG_YES=false
FLAG_UNINSTALL=false FLAG_UNINSTALL=false
FLAG_DEV=false FLAG_DEV=false
FLAG_NEXT=false
GIT_REF="${MOSAIC_REF:-main}" GIT_REF="${MOSAIC_REF:-main}"
GIT_REF_EXPLICIT=false
if [[ -n "${MOSAIC_REF:-}" ]]; then
GIT_REF_EXPLICIT=true
fi
# MOSAIC_ASSUME_YES env var acts the same as --yes # MOSAIC_ASSUME_YES env var acts the same as --yes
if [[ "${MOSAIC_ASSUME_YES:-0}" == "1" ]]; then if [[ "${MOSAIC_ASSUME_YES:-0}" == "1" ]]; then
@@ -71,24 +61,13 @@ if [[ "${MOSAIC_DEV:-0}" == "1" ]]; then
FLAG_DEV=true FLAG_DEV=true
fi fi
# MOSAIC_NEXT env var acts the same as --next: fast npm @next install with
# source fallback from the permanent next integration branch unless
# MOSAIC_REF/--ref explicitly wins.
if [[ "${MOSAIC_NEXT:-0}" == "1" ]]; then
FLAG_NEXT=true
if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then
GIT_REF="next"
fi
fi
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--check) FLAG_CHECK=true; shift ;; --check) FLAG_CHECK=true; shift ;;
--framework) FLAG_CLI=false; shift ;; --framework) FLAG_CLI=false; shift ;;
--cli) FLAG_FRAMEWORK=false; shift ;; --cli) FLAG_FRAMEWORK=false; shift ;;
--ref) GIT_REF="${2:-main}"; GIT_REF_EXPLICIT=true; shift 2 ;; --ref) GIT_REF="${2:-main}"; shift 2 ;;
--dev) FLAG_DEV=true; shift ;; --dev) FLAG_DEV=true; shift ;;
--next) FLAG_NEXT=true; if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then GIT_REF="next"; fi; shift ;;
--yes|-y) FLAG_YES=true; shift ;; --yes|-y) FLAG_YES=true; shift ;;
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;; --no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
--uninstall) FLAG_UNINSTALL=true; shift ;; --uninstall) FLAG_UNINSTALL=true; shift ;;
@@ -96,24 +75,12 @@ while [[ $# -gt 0 ]]; do
esac esac
done done
# Explicit refs represent a request for that exact source tree. Keep --next as
# a lane selector, but do not install the registry @next package for a different
# ref than the permanent next branch.
if [[ "$FLAG_NEXT" == "true" && "$GIT_REF_EXPLICIT" == "true" ]]; then
FLAG_DEV=true
fi
if [[ "$FLAG_YES" == "true" ]]; then
export MOSAIC_ASSUME_YES=1
fi
# ─── constants ──────────────────────────────────────────────────────────────── # ─── constants ────────────────────────────────────────────────────────────────
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}" MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaicstack/npm/}" REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaicstack/npm/}"
SCOPE="${MOSAIC_SCOPE:-@mosaicstack}" SCOPE="${MOSAIC_SCOPE:-@mosaicstack}"
PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}" PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}"
CLI_PKG="${SCOPE}/mosaic" CLI_PKG="${SCOPE}/mosaic"
GATEWAY_PKG="${SCOPE}/gateway"
REPO_BASE="https://git.mosaicstack.dev/mosaicstack/stack" REPO_BASE="https://git.mosaicstack.dev/mosaicstack/stack"
ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz" ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz"
@@ -128,20 +95,6 @@ fi
WORK_DIR="" WORK_DIR=""
EXTRACTED_DIR="" EXTRACTED_DIR=""
newest_matching_file() {
local dir="$1"
local pattern="$2"
local matches=()
[[ -d "$dir" ]] || return 0
shopt -s nullglob
# shellcheck disable=SC2206 # Intentional glob expansion for caller-provided file pattern.
matches=("$dir"/$pattern)
shopt -u nullglob
[[ "${#matches[@]}" -gt 0 ]] || return 0
# shellcheck disable=SC2012 # Need portable mtime sorting across Linux/macOS.
ls -1t "${matches[@]}" 2>/dev/null | head -1
}
# ─── uninstall path ─────────────────────────────────────────────────────────── # ─── uninstall path ───────────────────────────────────────────────────────────
# Shell-level uninstall for when the CLI is broken or not available. # Shell-level uninstall for when the CLI is broken or not available.
# Handles: framework directory, npm CLI package, npmrc scope line. # Handles: framework directory, npm CLI package, npmrc scope line.
@@ -205,7 +158,7 @@ if [[ "$FLAG_UNINSTALL" == "true" ]]; then
# Find most recent backup # Find most recent backup
backup="" backup=""
if [[ -d "$dir" ]]; then if [[ -d "$dir" ]]; then
backup="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")" backup="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)"
fi fi
if [[ -n "$backup" ]] && [[ -f "$backup" ]]; then if [[ -n "$backup" ]] && [[ -f "$backup" ]]; then
cp "$backup" "$dest" cp "$backup" "$dest"
@@ -261,22 +214,6 @@ fail() { echo "${R}✖${RESET} $*" >&2; }
dim() { echo "${DIM}$*${RESET}"; } dim() { echo "${DIM}$*${RESET}"; }
step() { echo ""; echo "${BOLD}$*${RESET}"; } step() { echo ""; echo "${BOLD}$*${RESET}"; }
is_next_registry_lane() {
[[ "$FLAG_NEXT" == "true" && "$FLAG_DEV" == "false" && "$GIT_REF" == "next" && "$GIT_REF_EXPLICIT" == "false" ]]
}
source_ref_details() {
if is_next_registry_lane; then
echo "ref: next, --next prerelease lane"
elif [[ "$FLAG_NEXT" == "true" && "$GIT_REF" == "next" ]]; then
echo "ref: next, --next prerelease lane (build-from-source)"
elif [[ "$FLAG_NEXT" == "true" ]]; then
echo "ref: ${GIT_REF}, --next requested, explicit ref wins"
else
echo "ref: ${GIT_REF}"
fi
}
# ─── helpers ────────────────────────────────────────────────────────────────── # ─── helpers ──────────────────────────────────────────────────────────────────
require_cmd() { require_cmd() {
@@ -299,43 +236,10 @@ installed_cli_version() {
fi fi
} }
installed_gateway_version() {
local json
json="$(npm ls -g --depth=0 --json --prefix="$PREFIX" 2>/dev/null)" || true
if [[ -n "$json" ]]; then
node -e "
const d = JSON.parse(process.argv[1]);
const v = d?.dependencies?.['${GATEWAY_PKG}']?.version ?? '';
process.stdout.write(v);
" "$json" 2>/dev/null || true
fi
}
latest_cli_version() { latest_cli_version() {
npm view "${CLI_PKG}" version --registry="$REGISTRY" 2>/dev/null || true npm view "${CLI_PKG}" version --registry="$REGISTRY" 2>/dev/null || true
} }
next_cli_version() {
npm view "${CLI_PKG}@next" version --registry="$REGISTRY" 2>/dev/null || true
}
next_gateway_version() {
npm view "${GATEWAY_PKG}@next" version --registry="$REGISTRY" 2>/dev/null || true
}
next_pipeline_suffix() {
printf '%s' "$1" | sed -n 's/.*-next\.\([0-9][0-9]*\)$/\1/p'
}
next_versions_share_pipeline() {
local cli_next="$1"
local gateway_next="$2"
local cli_pipeline gateway_pipeline
cli_pipeline="$(next_pipeline_suffix "$cli_next")"
gateway_pipeline="$(next_pipeline_suffix "$gateway_next")"
[[ -n "$cli_pipeline" && -n "$gateway_pipeline" && "$cli_pipeline" == "$gateway_pipeline" ]]
}
version_lt() { version_lt() {
node -e " node -e "
const a=process.argv[1], b=process.argv[2]; const a=process.argv[1], b=process.argv[2];
@@ -428,8 +332,8 @@ install_cli_from_source() {
( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /' ( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /'
local cli_tgz gw_tgz local cli_tgz gw_tgz
cli_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-mosaic-*.tgz')" cli_tgz="$(ls -1t "$out_dir"/mosaicstack-mosaic-*.tgz 2>/dev/null | head -1)"
gw_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-gateway-*.tgz')" gw_tgz="$(ls -1t "$out_dir"/mosaicstack-gateway-*.tgz 2>/dev/null | head -1)"
if [[ ! -f "$cli_tgz" ]]; then if [[ ! -f "$cli_tgz" ]]; then
fail "CLI tarball was not produced by pnpm pack." fail "CLI tarball was not produced by pnpm pack."
@@ -451,49 +355,6 @@ install_cli_from_source() {
ok "Installed from source: CLI $(installed_cli_version)" ok "Installed from source: CLI $(installed_cli_version)"
} }
install_next_cli_from_registry() {
local cli_next gateway_next
cli_next="$(next_cli_version)"
gateway_next="$(next_gateway_version)"
if [[ -z "$cli_next" ]]; then
warn "${CLI_PKG}@next is unavailable from $REGISTRY."
return 1
fi
if [[ -z "$gateway_next" ]]; then
warn "${GATEWAY_PKG}@next is unavailable from $REGISTRY."
return 1
fi
if ! next_versions_share_pipeline "$cli_next" "$gateway_next"; then
warn "@next CLI/gateway versions do not share a pipeline suffix (${cli_next}, ${gateway_next})."
return 1
fi
info "Installing ${CLI_PKG}@${cli_next} from registry…"
if ! npm install -g "${CLI_PKG}@${cli_next}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then
warn "Fast CLI @next install failed."
return 1
fi
info "Installing ${GATEWAY_PKG}@${gateway_next} from registry…"
if ! npm install -g "${GATEWAY_PKG}@${gateway_next}" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'; then
warn "Fast gateway @next install failed."
return 1
fi
local installed_cli installed_gateway
installed_cli="$(installed_cli_version)"
installed_gateway="$(installed_gateway_version)"
if [[ "$installed_cli" != "$cli_next" || "$installed_gateway" != "$gateway_next" ]]; then
warn "Installed @next versions did not match resolved versions (CLI: ${installed_cli:-missing}, gateway: ${installed_gateway:-missing})."
return 1
fi
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
ok "Installed @next packages: CLI ${installed_cli}, gateway ${installed_gateway}"
}
# ─── preflight ──────────────────────────────────────────────────────────────── # ─── preflight ────────────────────────────────────────────────────────────────
require_cmd node require_cmd node
@@ -527,7 +388,7 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then
else else
dim " Installed: (none)" dim " Installed: (none)"
fi fi
dim " Source: ${REPO_BASE} ($(source_ref_details))" dim " Source: ${REPO_BASE} (ref: ${GIT_REF})"
echo "" echo ""
if [[ "$FLAG_CHECK" == "true" ]]; then if [[ "$FLAG_CHECK" == "true" ]]; then
@@ -594,12 +455,8 @@ if [[ "$FLAG_CLI" == "true" ]]; then
fi fi
CURRENT="$(installed_cli_version)" CURRENT="$(installed_cli_version)"
NEXT_GATEWAY=""
if [[ "$FLAG_DEV" == "true" ]]; then if [[ "$FLAG_DEV" == "true" ]]; then
LATEST="" LATEST=""
elif is_next_registry_lane; then
LATEST="$(next_cli_version)"
NEXT_GATEWAY="$(next_gateway_version)"
else else
LATEST="$(latest_cli_version)" LATEST="$(latest_cli_version)"
fi fi
@@ -611,19 +468,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then
fi fi
if [[ "$FLAG_DEV" == "true" ]]; then if [[ "$FLAG_DEV" == "true" ]]; then
dim " Source: ${REPO_BASE} ($(source_ref_details), build-from-source)" dim " Source: ${REPO_BASE} (ref: ${GIT_REF}, build-from-source)"
elif is_next_registry_lane; then
if [[ -n "$LATEST" ]]; then
dim " Next CLI: ${CLI_PKG}@${LATEST}"
else
dim " Next CLI: (registry @next unreachable)"
fi
if [[ -n "$NEXT_GATEWAY" ]]; then
dim " Next GW: ${GATEWAY_PKG}@${NEXT_GATEWAY}"
else
dim " Next GW: (registry @next unreachable)"
fi
dim " Fallback: ${REPO_BASE} (ref: next, build-from-source)"
elif [[ -n "$LATEST" ]]; then elif [[ -n "$LATEST" ]]; then
dim " Latest: ${CLI_PKG}@${LATEST}" dim " Latest: ${CLI_PKG}@${LATEST}"
else else
@@ -634,12 +479,6 @@ if [[ "$FLAG_CLI" == "true" ]]; then
if [[ "$FLAG_CHECK" == "true" ]]; then if [[ "$FLAG_CHECK" == "true" ]]; then
if [[ "$FLAG_DEV" == "true" ]]; then if [[ "$FLAG_DEV" == "true" ]]; then
info "Dev mode: installed version is ${CURRENT:-(none)} (no registry comparison)." info "Dev mode: installed version is ${CURRENT:-(none)} (no registry comparison)."
elif is_next_registry_lane; then
if [[ -n "$LATEST" && -n "$NEXT_GATEWAY" ]] && next_versions_share_pipeline "$LATEST" "$NEXT_GATEWAY"; then
ok "@next registry lane available: ${CLI_PKG}@${LATEST}, ${GATEWAY_PKG}@${NEXT_GATEWAY}."
else
warn "@next registry lane incomplete, mismatched, or unreachable; --next would fall back to source."
fi
elif [[ -z "$LATEST" ]]; then elif [[ -z "$LATEST" ]]; then
warn "Could not reach registry." warn "Could not reach registry."
elif [[ -z "$CURRENT" ]]; then elif [[ -z "$CURRENT" ]]; then
@@ -656,23 +495,6 @@ if [[ "$FLAG_CLI" == "true" ]]; then
ensure_monorepo ensure_monorepo
install_cli_from_source install_cli_from_source
# PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH"
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
fi
elif is_next_registry_lane; then
info "Next mode — trying fast npm @next install from ${REGISTRY}"
if install_next_cli_from_registry; then
:
else
warn "Falling back to source build at ref ${GIT_REF}; --next will not hard-fail on registry issues."
unset MOSAIC_GATEWAY_SKIP_NPM_INSTALL
ensure_monorepo
install_cli_from_source
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
fi
# PATH check for npm prefix # PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH" warn "$PREFIX/bin is not on your PATH"
@@ -781,7 +603,7 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
local base dir backup_path backup_val local base dir backup_path backup_val
base="$(basename "$dest")" base="$(basename "$dest")"
dir="$(dirname "$dest")" dir="$(dirname "$dest")"
backup_path="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")" backup_path="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)"
if [[ -n "$backup_path" ]]; then if [[ -n "$backup_path" ]]; then
backup_val="\"$backup_path\"" backup_val="\"$backup_path\""
else else
@@ -806,7 +628,7 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
NPMRC_LINES_JSON="[\"$MANIFEST_SCOPE_LINE\"]" NPMRC_LINES_JSON="[\"$MANIFEST_SCOPE_LINE\"]"
fi fi
if node -e " node -e "
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const p = process.argv[1]; const p = process.argv[1];
@@ -831,11 +653,9 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
"$MANIFEST_CLI_VERSION" \ "$MANIFEST_CLI_VERSION" \
"$MANIFEST_FW_VERSION" \ "$MANIFEST_FW_VERSION" \
"$NPMRC_LINES_JSON" \ "$NPMRC_LINES_JSON" \
"$RUNTIME_COPIES" 2>/dev/null; then "$RUNTIME_COPIES" 2>/dev/null \
ok "Install manifest written: $MANIFEST_PATH" && ok "Install manifest written: $MANIFEST_PATH" \
else || warn "Could not write install manifest (non-fatal)"
warn "Could not write install manifest (non-fatal)"
fi
echo "" echo ""
ok "Done." ok "Done."