Compare commits
10 Commits
5ad29926d9
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
| 193331544d | |||
| 495f73bfdb | |||
| b96cc7982a | |||
| 0883fb91ec | |||
| 56787fabf1 | |||
| 940ae3cc41 | |||
| c25a551c28 | |||
| 94d6538061 | |||
| a3c1ab923c | |||
| 838701bde2 |
@@ -5,5 +5,3 @@ pnpm-lock.yaml
|
|||||||
**/drizzle
|
**/drizzle
|
||||||
**/.next
|
**/.next
|
||||||
.claude/
|
.claude/
|
||||||
docs/tess/TASKS.md
|
|
||||||
docs/scratchpads/
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Build, publish npm packages, and push Docker images
|
# Build, publish npm packages, and push Docker images
|
||||||
# Runs only on main branch push/tag
|
# Runs on main for stable publishes and on next for integration-line prereleases/images
|
||||||
|
|
||||||
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,9 +23,21 @@ 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]
|
- branch: [main, next]
|
||||||
event: [push, manual, tag]
|
event: [push, manual, tag]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -103,6 +115,84 @@ 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
|
||||||
@@ -134,8 +224,17 @@ 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" = "main" ]; then
|
if [ "$CI_COMMIT_BRANCH" = "next" ]; 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"
|
||||||
@@ -146,7 +245,7 @@ steps:
|
|||||||
|
|
||||||
build-appservice:
|
build-appservice:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *image_build_when
|
when: *main_image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
@@ -172,7 +271,7 @@ steps:
|
|||||||
|
|
||||||
build-web:
|
build-web:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *image_build_when
|
when: *main_image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -30,6 +30,16 @@ 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
|
||||||
@@ -336,7 +346,9 @@ 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 --ref v1.0 # Install from a specific git ref
|
bash tools/install.sh --next # Prerelease lane: source build from next
|
||||||
|
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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,519 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Inject, Optional, 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';
|
||||||
|
|
||||||
@@ -14,6 +16,9 @@ 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()
|
||||||
@@ -55,6 +60,14 @@ 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 {
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ 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,
|
||||||
@Inject(COMMANDS_REDIS) private readonly redis: QueueHandle['redis'],
|
// On Local tier COMMANDS_REDIS is null — provider login caching is skipped.
|
||||||
|
@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))
|
||||||
@@ -83,15 +86,16 @@ export class CommandExecutorService {
|
|||||||
success: true,
|
success: true,
|
||||||
message: 'Retry last message requested.',
|
message: 'Retry last message requested.',
|
||||||
};
|
};
|
||||||
case 'gc':
|
case 'gc': {
|
||||||
// Global retention requires a separate, authorized and audited job.
|
// Admin-only: system-wide GC sweep across all sessions
|
||||||
// Session cleanup is performed only through the session lifecycle.
|
const result = await this.sessionGC.sweepOrphans();
|
||||||
return {
|
return {
|
||||||
command: 'gc',
|
command: 'gc',
|
||||||
success: false,
|
success: true,
|
||||||
message: 'Global GC is disabled pending an authorized retention job.',
|
message: `GC sweep complete: ${result.orphanedSessions} orphaned sessions cleaned in ${result.duration}ms.`,
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
case 'agent':
|
case 'agent':
|
||||||
return await this.handleAgent(args ?? null, conversationId, userId);
|
return await this.handleAgent(args ?? null, conversationId, userId);
|
||||||
case 'provider':
|
case 'provider':
|
||||||
@@ -402,14 +406,16 @@ export class CommandExecutorService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const pollToken = crypto.randomUUID();
|
const pollToken = crypto.randomUUID();
|
||||||
const key = `mosaic:auth:poll:${pollToken}`;
|
const pollKey = `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(
|
||||||
key,
|
pollKey,
|
||||||
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 {
|
||||||
|
|||||||
@@ -176,12 +176,14 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
expect(result.command).toBe('nonexistent');
|
expect(result.command).toBe('nonexistent');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('/gc refuses an unaudited global sweep', async () => {
|
// /gc handler calls SessionGCService.sweepOrphans (admin-only, no userId arg)
|
||||||
|
it('/gc calls SessionGCService.sweepOrphans without arguments', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'gc', conversationId };
|
const payload: SlashCommandPayload = { command: 'gc', conversationId };
|
||||||
const result = await executor.execute(payload, userId);
|
const result = await executor.execute(payload, userId);
|
||||||
expect(mockSessionGC.sweepOrphans).not.toHaveBeenCalled();
|
expect(mockSessionGC.sweepOrphans).toHaveBeenCalledWith();
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('disabled pending an authorized retention job');
|
expect(result.message).toContain('GC sweep complete');
|
||||||
|
expect(result.message).toContain('3 orphaned sessions');
|
||||||
});
|
});
|
||||||
|
|
||||||
// /system with args calls SystemOverrideService.set
|
// /system with args calls SystemOverrideService.set
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { forwardRef, Inject, Module, type OnApplicationShutdown } from '@nestjs/common';
|
import { forwardRef, Inject, Module, Optional, 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';
|
||||||
@@ -14,13 +16,17 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
|||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: COMMANDS_QUEUE_HANDLE,
|
provide: COMMANDS_QUEUE_HANDLE,
|
||||||
useFactory: (): QueueHandle => {
|
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
|
||||||
|
// 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) => handle.redis,
|
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
|
||||||
inject: [COMMANDS_QUEUE_HANDLE],
|
inject: [COMMANDS_QUEUE_HANDLE],
|
||||||
},
|
},
|
||||||
CommandRegistryService,
|
CommandRegistryService,
|
||||||
@@ -29,9 +35,13 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
|||||||
exports: [CommandRegistryService, CommandExecutorService],
|
exports: [CommandRegistryService, CommandExecutorService],
|
||||||
})
|
})
|
||||||
export class CommandsModule implements OnApplicationShutdown {
|
export class CommandsModule implements OnApplicationShutdown {
|
||||||
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
constructor(
|
||||||
|
@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(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ 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';
|
||||||
@@ -12,7 +14,13 @@ 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: [EnrollmentController, FederationController, CapabilitiesController, ListController],
|
controllers: [
|
||||||
|
EnrollmentController,
|
||||||
|
FederationController,
|
||||||
|
CapabilitiesController,
|
||||||
|
ListController,
|
||||||
|
GetController,
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AdminGuard,
|
AdminGuard,
|
||||||
CaService,
|
CaService,
|
||||||
@@ -23,6 +31,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
|||||||
FederationAuthGuard,
|
FederationAuthGuard,
|
||||||
FederationScopeService,
|
FederationScopeService,
|
||||||
FederationListQueryService,
|
FederationListQueryService,
|
||||||
|
FederationGetQueryService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
CaService,
|
CaService,
|
||||||
@@ -33,6 +42,7 @@ import { FederationListQueryService } from './server/verbs/list-query.service.js
|
|||||||
FederationAuthGuard,
|
FederationAuthGuard,
|
||||||
FederationScopeService,
|
FederationScopeService,
|
||||||
FederationListQueryService,
|
FederationListQueryService,
|
||||||
|
FederationGetQueryService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class FederationModule {}
|
export class FederationModule {}
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
311
apps/gateway/src/federation/server/verbs/get-query.service.ts
Normal file
311
apps/gateway/src/federation/server/verbs/get-query.service.ts
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
/**
|
||||||
|
* 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
100
apps/gateway/src/federation/server/verbs/get.controller.ts
Normal file
100
apps/gateway/src/federation/server/verbs/get.controller.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* 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 } };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Module, type OnApplicationShutdown, Inject } from '@nestjs/common';
|
import { Module, type OnApplicationShutdown, Inject, Optional } 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';
|
||||||
|
|
||||||
@@ -9,13 +11,17 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
|||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
provide: GC_QUEUE_HANDLE,
|
provide: GC_QUEUE_HANDLE,
|
||||||
useFactory: (): QueueHandle => {
|
useFactory: (config: MosaicConfig | null): QueueHandle | null => {
|
||||||
|
// 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) => handle.redis,
|
useFactory: (handle: QueueHandle | null) => handle?.redis ?? null,
|
||||||
inject: [GC_QUEUE_HANDLE],
|
inject: [GC_QUEUE_HANDLE],
|
||||||
},
|
},
|
||||||
SessionGCService,
|
SessionGCService,
|
||||||
@@ -23,9 +29,13 @@ const GC_QUEUE_HANDLE = 'GC_QUEUE_HANDLE';
|
|||||||
exports: [SessionGCService],
|
exports: [SessionGCService],
|
||||||
})
|
})
|
||||||
export class GCModule implements OnApplicationShutdown {
|
export class GCModule implements OnApplicationShutdown {
|
||||||
constructor(@Inject(GC_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
constructor(
|
||||||
|
@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(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,7 @@ type MockRedis = {
|
|||||||
describe('SessionGCService', () => {
|
describe('SessionGCService', () => {
|
||||||
let service: SessionGCService;
|
let service: SessionGCService;
|
||||||
let mockRedis: MockRedis;
|
let mockRedis: MockRedis;
|
||||||
let mockLogService: {
|
let mockLogService: { logs: { promoteToWarm: ReturnType<typeof vi.fn> } };
|
||||||
logs: {
|
|
||||||
promoteSessionToWarm: ReturnType<typeof vi.fn>;
|
|
||||||
promoteToWarm: ReturnType<typeof vi.fn>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper: build a scan mock that returns all provided keys in a single
|
* Helper: build a scan mock that returns all provided keys in a single
|
||||||
@@ -35,7 +30,6 @@ describe('SessionGCService', () => {
|
|||||||
|
|
||||||
mockLogService = {
|
mockLogService = {
|
||||||
logs: {
|
logs: {
|
||||||
promoteSessionToWarm: vi.fn().mockResolvedValue(0),
|
|
||||||
promoteToWarm: vi.fn().mockResolvedValue(0),
|
promoteToWarm: vi.fn().mockResolvedValue(0),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -70,18 +64,49 @@ describe('SessionGCService', () => {
|
|||||||
expect(result.sessionId).toBe('test-session-id');
|
expect(result.sessionId).toBe('test-session-id');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('collect() demotes logs only for the requested session', async () => {
|
it('fullCollect() deletes all session keys', async () => {
|
||||||
await service.collect('owned-session');
|
mockRedis.scan = makeScanMock(['mosaic:session:abc:system', 'mosaic:session:xyz:foo']);
|
||||||
|
const result = await service.fullCollect();
|
||||||
expect(mockLogService.logs.promoteSessionToWarm).toHaveBeenCalledWith(
|
expect(mockRedis.del).toHaveBeenCalled();
|
||||||
'owned-session',
|
expect(result.valkeyKeys).toBe(2);
|
||||||
expect.any(Date),
|
|
||||||
);
|
|
||||||
expect(mockLogService.logs.promoteToWarm).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not expose automatic global GC entry points', () => {
|
it('fullCollect() with no keys returns 0 valkeyKeys', async () => {
|
||||||
expect('fullCollect' in service).toBe(false);
|
mockRedis.scan = makeScanMock([]);
|
||||||
expect('sweepOrphans' in service).toBe(false);
|
const result = await service.fullCollect();
|
||||||
|
expect(result.valkeyKeys).toBe(0);
|
||||||
|
expect(mockRedis.del).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fullCollect() returns duration', async () => {
|
||||||
|
const result = await service.fullCollect();
|
||||||
|
expect(result.duration).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sweepOrphans() extracts unique session IDs and collects them', async () => {
|
||||||
|
// First scan call returns the global session list; subsequent calls return
|
||||||
|
// per-session keys during collect().
|
||||||
|
mockRedis.scan = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
'0',
|
||||||
|
['mosaic:session:abc:system', 'mosaic:session:abc:messages', 'mosaic:session:xyz:system'],
|
||||||
|
])
|
||||||
|
// collect('abc') scan
|
||||||
|
.mockResolvedValueOnce(['0', ['mosaic:session:abc:system', 'mosaic:session:abc:messages']])
|
||||||
|
// collect('xyz') scan
|
||||||
|
.mockResolvedValueOnce(['0', ['mosaic:session:xyz:system']]);
|
||||||
|
mockRedis.del.mockResolvedValue(1);
|
||||||
|
|
||||||
|
const result = await service.sweepOrphans();
|
||||||
|
expect(result.orphanedSessions).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(result.duration).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sweepOrphans() returns empty when no session keys', async () => {
|
||||||
|
mockRedis.scan = makeScanMock([]);
|
||||||
|
const result = await service.sweepOrphans();
|
||||||
|
expect(result.orphanedSessions).toBe(0);
|
||||||
|
expect(result.totalCleaned).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable, Logger, Optional, 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';
|
||||||
@@ -13,19 +13,67 @@ export interface GCResult {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GCSweepResult {
|
||||||
|
orphanedSessions: number;
|
||||||
|
totalCleaned: GCResult[];
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FullGCResult {
|
||||||
|
valkeyKeys: number;
|
||||||
|
logsDemoted: number;
|
||||||
|
jobsPurged: number;
|
||||||
|
tempFilesRemoved: number;
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SessionGCService {
|
export class SessionGCService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(SessionGCService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
|
// On Local tier there is no Redis — the GC module provides null for this token.
|
||||||
|
// 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 {
|
||||||
|
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
|
||||||
|
// NestJS bootstrap chain. Cold-start GC typically takes 100–500 ms
|
||||||
|
// depending on Valkey key count; deferring it removes that latency from
|
||||||
|
// the TTFB of the first HTTP request.
|
||||||
|
this.fullCollect()
|
||||||
|
.then((result) => {
|
||||||
|
this.logger.log(
|
||||||
|
`Full GC complete: ${result.valkeyKeys} Valkey keys, ` +
|
||||||
|
`${result.logsDemoted} logs demoted, ` +
|
||||||
|
`${result.jobsPurged} jobs purged, ` +
|
||||||
|
`${result.tempFilesRemoved} temp dirs removed ` +
|
||||||
|
`(${result.duration}ms)`,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
this.logger.error('Cold-start GC failed', err instanceof Error ? err.stack : String(err));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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 {
|
||||||
@@ -42,21 +90,94 @@ export class SessionGCService {
|
|||||||
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
|
// 1. Valkey: delete all session-scoped keys (skipped on Local tier)
|
||||||
|
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 only.
|
// 2. PG: demote hot-tier agent_logs for this session to warm
|
||||||
const cutoff = new Date();
|
const cutoff = new Date(); // demote all hot logs for this session
|
||||||
const logsDemoted = await this.logService.logs.promoteSessionToWarm(sessionId, cutoff);
|
const logsDemoted = await this.logService.logs.promoteToWarm(cutoff);
|
||||||
if (logsDemoted > 0) {
|
if (logsDemoted > 0) {
|
||||||
result.cleaned.logsDemoted = logsDemoted;
|
result.cleaned.logsDemoted = logsDemoted;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sweep GC — find orphaned artifacts from dead sessions.
|
||||||
|
* System-wide operation: only call from admin-authorized paths or internal
|
||||||
|
* scheduled jobs. Individual session cleanup is handled by collect().
|
||||||
|
*/
|
||||||
|
async sweepOrphans(): Promise<GCSweepResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
const cleaned: GCResult[] = [];
|
||||||
|
|
||||||
|
// 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:*');
|
||||||
|
|
||||||
|
// Extract unique session IDs from keys
|
||||||
|
const sessionIds = new Set<string>();
|
||||||
|
for (const key of allSessionKeys) {
|
||||||
|
const match = key.match(/^mosaic:session:([^:]+):/);
|
||||||
|
if (match) sessionIds.add(match[1]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. For each session ID, collect stale keys
|
||||||
|
for (const sessionId of sessionIds) {
|
||||||
|
const gcResult = await this.collect(sessionId);
|
||||||
|
if (Object.keys(gcResult.cleaned).length > 0) {
|
||||||
|
cleaned.push(gcResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
orphanedSessions: cleaned.length,
|
||||||
|
totalCleaned: cleaned,
|
||||||
|
duration: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full GC — aggressive collection for cold start.
|
||||||
|
* Assumes no sessions survived the restart.
|
||||||
|
*/
|
||||||
|
async fullCollect(): Promise<FullGCResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
let valkeyKeysCount = 0;
|
||||||
|
|
||||||
|
if (this.redis) {
|
||||||
|
// 1. Valkey: delete ALL session-scoped keys (non-blocking SCAN)
|
||||||
|
const sessionKeys = await this.scanKeys('mosaic:session:*');
|
||||||
|
if (sessionKeys.length > 0) {
|
||||||
|
await this.redis.del(...sessionKeys);
|
||||||
|
}
|
||||||
|
valkeyKeysCount = sessionKeys.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. NOTE: channel keys are NOT collected on cold start
|
||||||
|
// (discord/telegram plugins may reconnect and resume)
|
||||||
|
|
||||||
|
// 3. PG: demote stale hot-tier logs older than 24h to warm
|
||||||
|
const hotCutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||||
|
const logsDemoted = await this.logService.logs.promoteToWarm(hotCutoff);
|
||||||
|
|
||||||
|
// 4. No summarization job purge API available yet
|
||||||
|
const jobsPurged = 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
valkeyKeys: valkeyKeysCount,
|
||||||
|
logsDemoted,
|
||||||
|
jobsPurged,
|
||||||
|
tempFilesRemoved: 0,
|
||||||
|
duration: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import {
|
|||||||
type OnModuleDestroy,
|
type OnModuleDestroy,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { SummarizationService } from './summarization.service.js';
|
import { SummarizationService } from './summarization.service.js';
|
||||||
|
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||||
import {
|
import {
|
||||||
QueueService,
|
QueueService,
|
||||||
QUEUE_GC,
|
|
||||||
QUEUE_SUMMARIZATION,
|
QUEUE_SUMMARIZATION,
|
||||||
|
QUEUE_GC,
|
||||||
QUEUE_TIER_MANAGEMENT,
|
QUEUE_TIER_MANAGEMENT,
|
||||||
} from '../queue/queue.service.js';
|
} from '../queue/queue.service.js';
|
||||||
import type { Worker } from 'bullmq';
|
import type { Worker } from 'bullmq';
|
||||||
@@ -18,16 +19,28 @@ 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: Worker<MosaicJobData>[] = [];
|
private readonly registeredWorkers: Array<Worker<MosaicJobData>> = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
||||||
|
@Inject(SessionGCService) private readonly sessionGC: SessionGCService,
|
||||||
@Inject(QueueService) private readonly queueService: QueueService,
|
@Inject(QueueService) private readonly queueService: QueueService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
// M6-003: Summarization repeatable job
|
// M6-003: Summarization repeatable job
|
||||||
await this.queueService.addRepeatableJob(
|
await this.queueService.addRepeatableJob(
|
||||||
@@ -39,7 +52,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();
|
||||||
});
|
});
|
||||||
this.registeredWorkers.push(summarizationWorker);
|
if (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(
|
||||||
@@ -51,14 +64,17 @@ 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();
|
||||||
});
|
});
|
||||||
this.registeredWorkers.push(tierWorker);
|
if (tierWorker) this.registeredWorkers.push(tierWorker);
|
||||||
|
|
||||||
// Retire any repeatable global GC schedule created by older deployments.
|
// M6-004: GC repeatable job
|
||||||
// Session cleanup is now triggered only by an authorized session lifecycle operation.
|
await this.queueService.addRepeatableJob(QUEUE_GC, 'session-gc', {}, gcSchedule);
|
||||||
await this.queueService.removeRepeatableJobs(QUEUE_GC, 'session-gc');
|
const gcWorker = this.queueService.registerWorker(QUEUE_GC, async () => {
|
||||||
|
await this.sessionGC.sweepOrphans();
|
||||||
|
});
|
||||||
|
if (gcWorker) this.registeredWorkers.push(gcWorker);
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`BullMQ jobs scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}"`,
|
`BullMQ jobs scheduled: summarization="${summarizationSchedule}", tier="${tierManagementSchedule}", gc="${gcSchedule}"`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger, Optional, 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';
|
||||||
|
|
||||||
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) =>
|
||||||
@@ -11,16 +13,54 @@ interface OverrideFragment {
|
|||||||
addedAt: number;
|
addedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
interface LocalOverrideEntry {
|
||||||
export class SystemOverrideService {
|
condensed: string;
|
||||||
private readonly logger = new Logger(SystemOverrideService.name);
|
fragments: OverrideFragment[];
|
||||||
private readonly handle: QueueHandle;
|
}
|
||||||
|
|
||||||
constructor() {
|
@Injectable()
|
||||||
|
export class SystemOverrideService implements OnApplicationShutdown {
|
||||||
|
private readonly logger = new Logger(SystemOverrideService.name);
|
||||||
|
private readonly handle: QueueHandle | null;
|
||||||
|
/**
|
||||||
|
* 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(
|
||||||
|
@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
|
||||||
@@ -50,10 +90,17 @@ export class SystemOverrideService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -61,6 +108,11 @@ export class SystemOverrideService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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),
|
||||||
|
|||||||
35
apps/gateway/src/queue/queue.service.spec.ts
Normal file
35
apps/gateway/src/queue/queue.service.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
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.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,9 @@ 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';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -108,22 +110,43 @@ 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.connection = getConnection();
|
this.enabled = this.mosaicConfig?.queue?.type !== 'local';
|
||||||
|
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
|
||||||
@@ -131,8 +154,10 @@ 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> {
|
getQueue<T extends MosaicJobData = MosaicJobData>(name: string): Queue<T> | null {
|
||||||
|
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 });
|
||||||
@@ -144,6 +169,7 @@ 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,
|
||||||
@@ -151,7 +177,13 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
data: T,
|
data: T,
|
||||||
cronExpression: string,
|
cronExpression: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const queue = this.getQueue<T>(queueName);
|
if (!this.enabled) {
|
||||||
|
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 },
|
||||||
@@ -162,28 +194,21 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove every existing repeatable schedule for a job name. This supports
|
|
||||||
* safe retirement of previously registered system-wide jobs.
|
|
||||||
*/
|
|
||||||
async removeRepeatableJobs(queueName: string, jobName: string): Promise<number> {
|
|
||||||
const queue = this.getQueue(queueName);
|
|
||||||
const jobs = await queue.getRepeatableJobs();
|
|
||||||
const matchingJobs = jobs.filter((job) => job.name === jobName);
|
|
||||||
await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key)));
|
|
||||||
if (matchingJobs.length > 0) {
|
|
||||||
this.logger.log(
|
|
||||||
`Removed ${matchingJobs.length} repeatable "${jobName}" job(s) from "${queueName}"`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return matchingJobs.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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>(queueName: string, handler: JobHandler<T>): Worker<T> {
|
registerWorker<T extends MosaicJobData>(
|
||||||
|
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) => {
|
||||||
@@ -240,8 +265,12 @@ 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;
|
||||||
|
|
||||||
@@ -272,8 +301,10 @@ 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]
|
||||||
@@ -300,8 +331,10 @@ 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>".' };
|
||||||
@@ -333,6 +366,7 @@ 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();
|
||||||
@@ -344,6 +378,7 @@ 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();
|
||||||
|
|||||||
@@ -68,10 +68,9 @@ The MVP is complete when ALL declared workstreams are complete AND every cross-c
|
|||||||
## Workstreams
|
## Workstreams
|
||||||
|
|
||||||
| # | ID | Name | Status | Manifest | Notes |
|
| # | ID | Name | Status | Manifest | Notes |
|
||||||
| --- | ---- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- |
|
| --- | --- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- |
|
||||||
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed |
|
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed |
|
||||||
| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready |
|
| W2+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
|
||||||
| W3+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
|
|
||||||
|
|
||||||
### Likely Additional Workstreams (Not Yet Declared)
|
### Likely Additional Workstreams (Not Yet Declared)
|
||||||
|
|
||||||
|
|||||||
96
docs/PRD.md
96
docs/PRD.md
@@ -79,102 +79,6 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tess Interaction Agent Workstream (TESS)
|
|
||||||
|
|
||||||
### Problem and Objective
|
|
||||||
|
|
||||||
Jason needs one durable, operator-facing Mosaic agent outside Hermes that is reachable through a dedicated Discord channel and CLI, can attach to and operate the Mosaic fleet and transitional Hermes agents, and preserves context across restarts and compaction. Mos remains the coding/general fleet orchestrator; Tess is the complementary human interaction, visibility, control, and migration agent.
|
|
||||||
|
|
||||||
The objective is to ship **Tess** (from _tessera_, a piece of a mosaic) as a Pi-native, GPT-5.6 Sol agent with high reasoning. Tess must use Mosaic-owned contracts and plugins so Hermes can be replaced incrementally rather than becoming a permanent architectural dependency.
|
|
||||||
|
|
||||||
### Scope
|
|
||||||
|
|
||||||
#### In Scope
|
|
||||||
|
|
||||||
1. `TESS-ARP-001`: A runtime-neutral `AgentRuntimeProvider` contract supporting `listSessions`, `streamSession`, `sendMessage`, `terminate`, `getSessionTree`, `attach`, health, capability discovery, and normalized events/errors.
|
|
||||||
2. `TESS-PI-001`: A long-running Pi-native Tess agent profile/service pinned to GPT-5.6 Sol with high reasoning, explicit tool policy, lifecycle hooks, durable checkpoints, and restart recovery.
|
|
||||||
3. `TESS-DSC-001`: Dedicated Discord channel binding to Tess through the Mosaic gateway, with allowlists/RBAC, thread/reply policy, streaming, attachments, approvals, and correlation IDs.
|
|
||||||
4. `TESS-CLI-001`: `mosaic tess` CLI commands for chat, status, session listing, attach/detach, send/steer/stop, provider health, and recovery.
|
|
||||||
5. `TESS-FLT-001`: Fleet plugin capabilities for roster/status/heartbeat inspection, message delivery, session hierarchy, safe attach, and controlled restart/recovery.
|
|
||||||
6. `TESS-MOS-001`: Explicit Mos coordination boundary and tools: hand off orchestration requests, observe mission/task state, receive results, and never silently compete for orchestration authority.
|
|
||||||
7. `TESS-HRM-001`: Transitional Hermes adapter for profiles/agents, sessions, streaming/messages, Kanban, skills, memory, tools, cron, and health, using capability negotiation and fail-closed unsupported operations.
|
|
||||||
8. `TESS-MEM-001`: Unified memory/retrieval plugin with scoped search/recent/capture/stats, startup context injection, provenance, redaction, namespace isolation, and flat-file/project truth precedence.
|
|
||||||
9. `TESS-STA-001`: Durable agent state, inbox, handoff, compaction-recovery, and resume reconstruction.
|
|
||||||
10. `TESS-PLG-001`: Plugin/tool catalog covering runtime bootstrap, repository/PR workflow, fleet diagnostics, incident-safe read operations, Discord interaction, and extensible MCP/skill discovery.
|
|
||||||
11. `TESS-TRN-001`: Replaceable transport providers: tmux/fleet now, Matrix/native Mosaic transport later, with no Discord/CLI business logic coupled to transport details.
|
|
||||||
12. `TESS-SEC-001`: RBAC, per-operation authorization, explicit approval for destructive/privileged/customer-visible actions, audit events, secret/PII redaction, tenant isolation, and bounded command execution.
|
|
||||||
13. `TESS-SEC-002`: Command execution SHALL enforce declared scope/role server-side; admin/system and destructive operations SHALL require policy-bound durable approval.
|
|
||||||
14. `TESS-SEC-003`: Every session list/read/attach/send/terminate operation SHALL enforce server-derived owner and tenant scope; guessed or client-supplied IDs SHALL grant no authority.
|
|
||||||
15. `TESS-SEC-004`: MCP tools SHALL derive actor/tenant from authenticated context and SHALL NOT accept caller-controlled identity fields.
|
|
||||||
16. `TESS-SEC-005`: Discord plugin ingress SHALL authenticate service identity, enforce guild/channel/user allowlists, propagate correlation/message IDs, and reject replay.
|
|
||||||
17. `TESS-SEC-006`: Secret/PII classification and redaction SHALL occur before persistence and before channel egress, including tool metadata and authentication flows.
|
|
||||||
18. `TESS-SEC-007`: Approvals SHALL be one-time, expiring, actor/tenant-bound, and cryptographically bound to the exact structured action digest.
|
|
||||||
19. `TESS-SEC-008`: Ingress, provider sends, tool side effects, and responses SHALL use durable inbox/outbox/checkpoints and idempotency records for restart-safe replay.
|
|
||||||
20. `TESS-SEC-009`: Garbage collection and retention SHALL be session/tenant scoped unless executed as a separately authorized and audited system-wide job.
|
|
||||||
21. `TESS-OBS-001`: Structured logs, traces, health/readiness, provider latency/errors, session lifecycle, tool audit, and actionable recovery diagnostics.
|
|
||||||
22. `TESS-MIG-001`: Capability inventory and staged Hermes-to-Mosaic migration matrix with coexistence, cutover, rollback, and deprecation gates.
|
|
||||||
|
|
||||||
#### Out of Scope
|
|
||||||
|
|
||||||
1. Replacing Mos as coding/general fleet orchestrator.
|
|
||||||
2. Making Hermes the Mosaic core or coupling Mosaic domain logic to Hermes schemas.
|
|
||||||
3. Migrating every historical chat verbatim; only policy-compliant indexed summaries and user-selected sessions are migrated.
|
|
||||||
4. Unrestricted shell execution from Discord.
|
|
||||||
5. Full web UI parity in the first Tess operational milestone; gateway contracts must remain web-consumable.
|
|
||||||
6. Replacing tmux before Matrix/native transport reaches operational parity.
|
|
||||||
|
|
||||||
### Stakeholder and User Requirements
|
|
||||||
|
|
||||||
- Jason must be able to converse with the same Tess session from Discord and CLI.
|
|
||||||
- Jason must be able to see what is running, stale, blocked, or unhealthy without attaching manually to every session.
|
|
||||||
- Jason must be able to attach to Tess and authorized fleet sessions through supported CLI controls.
|
|
||||||
- Tess must collaborate with Mos and the fleet while preserving a single clear orchestration authority.
|
|
||||||
- The system must migrate useful Hermes/OpenClaw capabilities intentionally, with evidence, instead of copying implementations wholesale.
|
|
||||||
|
|
||||||
### Non-Functional Requirements
|
|
||||||
|
|
||||||
1. **Security:** default-deny provider/tool capabilities, least privilege, no secrets in logs/prompts/commits, Discord user/channel authorization, and auditable approvals.
|
|
||||||
2. **Reliability:** durable inbox/checkpoints; idempotent message handling; reconnect with bounded backoff; no message loss or duplicate execution across gateway restart.
|
|
||||||
3. **Performance:** first acknowledgement within 2 seconds when connected; streamed agent output begins within 5 seconds excluding model/provider delay; status reads return within 2 seconds under nominal local conditions.
|
|
||||||
4. **Observability:** every ingress message and resulting provider/tool operation carries a correlation ID across Discord, gateway, Tess, provider, and audit events.
|
|
||||||
5. **Maintainability:** channel, runtime, transport, memory, and external-agent integrations remain adapter-based with contract tests.
|
|
||||||
6. **Privacy:** only scoped context enters external runtimes; persisted messages/memories follow retention and redaction policy.
|
|
||||||
7. **Portability:** Tess runs through Pi/Mosaic contracts and does not require Hermes to start or serve native Mosaic operations.
|
|
||||||
|
|
||||||
### Acceptance Criteria
|
|
||||||
|
|
||||||
1. `AC-TESS-01`: A dedicated Discord channel and `mosaic tess chat` connect to one durable Tess session and stream responses bidirectionally.
|
|
||||||
2. `AC-TESS-02`: `mosaic tess status|sessions|tree|attach|send|stop` operate against authorized provider capabilities with stable typed outputs and actionable errors.
|
|
||||||
3. `AC-TESS-03`: Tess runs GPT-5.6 Sol at high reasoning and its effective runtime/model/tool policy is visible through status without exposing credentials.
|
|
||||||
4. `AC-TESS-04`: Tess can inspect and message the Mosaic fleet, hand orchestration work to Mos, and demonstrate that Tess does not independently claim Mos-owned orchestration work.
|
|
||||||
5. `AC-TESS-05`: Hermes adapter demonstrates session listing, streaming/message delivery, hierarchy mapping, and at least one approved capability in each of Kanban, skills, memory, tools, and cron—or reports unsupported capabilities fail-closed.
|
|
||||||
6. `AC-TESS-06`: Restart/compaction test preserves session identity, pending inbox, last durable checkpoint, and a resumable handoff without duplicate side effects.
|
|
||||||
7. `AC-TESS-07`: Unauthorized Discord users/channels, cross-tenant access, unsafe tool calls, forged approvals, and sensitive-output cases are denied and audited.
|
|
||||||
8. `AC-TESS-08`: tmux/fleet and Matrix/native transport implementations pass the same provider contract suite; Matrix may remain non-default until readiness gates pass.
|
|
||||||
9. `AC-TESS-09`: Baseline quality gates, unit/integration/contract tests, Discord+CLI E2E, restart/recovery tests, independent code review, and security review are green.
|
|
||||||
10. `AC-TESS-10`: Migration matrix documents every audited Hermes/OpenClaw capability as native, adapted, deferred, or rejected, with cutover and rollback evidence.
|
|
||||||
11. `AC-TESS-11`: User, admin, developer, API/OpenAPI, operations/recovery, and plugin-authoring documentation is current and linked from the sitemap.
|
|
||||||
|
|
||||||
### Constraints, Dependencies, Risks, and Assumptions
|
|
||||||
|
|
||||||
- Dependency: Mosaic gateway remains the single API surface; Pi is the native runtime; Valkey/PostgreSQL provide canonical durable state where required.
|
|
||||||
- Dependency: Discord bot credentials and dedicated channel ID are deployment secrets provisioned outside source control.
|
|
||||||
- Risk: Tess could drift into a second orchestrator. Mitigation: explicit role policy, Mos handoff contract, authority checks, and E2E boundary tests.
|
|
||||||
- Risk: broad Hermes compatibility can freeze legacy semantics into Mosaic. Mitigation: Mosaic-owned normalized contracts and capability negotiation.
|
|
||||||
- Risk: Discord creates a privileged remote-control surface. Mitigation: pairing/allowlists, RBAC, approvals, rate limits, audit, and safe tool classes.
|
|
||||||
- Risk: transcript ingestion can violate privacy or overload memory. Mitigation: scoped opt-in import, redacted summaries, provenance, retention, and deduplication.
|
|
||||||
- Risk: current root filesystem has limited headroom. Mitigation: isolated worktrees, no duplicated dependency installation unless required, and cleanup only after active-lane verification.
|
|
||||||
- `ASSUMPTION:` The public name is **Tess**, because the user requested a name and the tessera/Mosaic relationship is distinctive; config must permit later display-name changes without renaming APIs or storage keys.
|
|
||||||
- `ASSUMPTION:` The dedicated Discord channel ID and final guild policy will be supplied/provisioned during deployment, so implementation uses explicit configuration and fail-fast startup validation.
|
|
||||||
- `ASSUMPTION:` tmux/fleet is the production transport for the first operational milestone; Matrix/native transport is implemented behind the same contract and promoted only after parity/reliability verification.
|
|
||||||
- `ASSUMPTION:` Project/task truth remains in canonical Mosaic/project stores; semantic memory systems are retrieval/mirror layers, not hidden authorities.
|
|
||||||
|
|
||||||
### Testing and Delivery Intent
|
|
||||||
|
|
||||||
Delivery uses five gated milestones: runtime contracts/security; Pi service/state; Discord/CLI; fleet/Hermes/plugin suite; migration/Matrix/recovery/qualification. Every source-code task requires tests, independent review, a PR to `main`, terminal-green CI, and issue/task closure. Production activation additionally requires a clean-host Pi launch, dedicated Discord channel smoke test, CLI attach test, restart/recovery drill, and rollback procedure.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### High-Level System Diagram
|
### High-Level System Diagram
|
||||||
|
|||||||
@@ -15,9 +15,8 @@
|
|||||||
## Workstream Rollup
|
## Workstream Rollup
|
||||||
|
|
||||||
| id | status | workstream | progress | tasks file | notes |
|
| id | status | workstream | progress | tasks file | notes |
|
||||||
| --- | ----------------- | ---------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- |
|
| --- | ----------------- | ------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- |
|
||||||
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning |
|
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning |
|
||||||
| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready |
|
|
||||||
|
|
||||||
## Cross-Cutting Tracking
|
## Cross-Cutting Tracking
|
||||||
|
|
||||||
|
|||||||
63
docs/design/prerelease-next-dist-tag-pipeline.md
Normal file
63
docs/design/prerelease-next-dist-tag-pipeline.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# 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.
|
||||||
@@ -301,10 +301,6 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
|
|||||||
| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) |
|
| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) |
|
||||||
| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call |
|
| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call |
|
||||||
|
|
||||||
### Session retention and garbage collection
|
|
||||||
|
|
||||||
Session cleanup is scoped to one session identifier and only removes that session's Valkey keys and demotes that session's hot logs. Gateway startup and scheduled jobs do not perform global session cleanup; startup removes legacy repeatable `session-gc` schedules created by older deployments. The `/gc` command is intentionally disabled until a distinct global-retention job supplies explicit authorization and audit evidence. This prevents one tenant or session's cleanup from changing another's retained data.
|
|
||||||
|
|
||||||
### Observability
|
### Observability
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|
|||||||
@@ -211,6 +211,17 @@ 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
|
||||||
|
|||||||
@@ -175,8 +175,18 @@ 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`. Flags for
|
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`.
|
||||||
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
|
||||||
|
|||||||
38
docs/scratchpads/462-fed-m3-06-get-verb.md
Normal file
38
docs/scratchpads/462-fed-m3-06-get-verb.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# 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.
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# Issue #561 — Bare python on agent hosts
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Make the durable bootstrap/provisioning guidance ensure agent hosts provide a bare `python` command that resolves to Python 3.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- Add Debian/Ubuntu `python-is-python3` to agent-host prerequisites in bootstrap docs.
|
|
||||||
- Check for actual OS package provisioning scripts and update only if an existing agent-host package install path exists.
|
|
||||||
- Do not touch live host state.
|
|
||||||
- Do not update `docs/TASKS.md`; repo guidance says workers read it but never modify it.
|
|
||||||
|
|
||||||
## Recon
|
|
||||||
|
|
||||||
- Issue #561 confirms repeated `python: command not found` failures from fleet agents that emit `python foo.py`.
|
|
||||||
- `guides/BOOTSTRAP.md` and `packages/mosaic/framework/guides/BOOTSTRAP.md` are the source and packaged framework copies of the bootstrap guide.
|
|
||||||
- Targeted repo sweep found no agent-host Debian package provisioning script. Existing `apt-get install` hits are CI/test helper paths or unrelated deployment docs.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Add a host prerequisite section to both bootstrap guide copies.
|
|
||||||
2. Include `python-is-python3` in the Debian/Ubuntu package list with an issue comment.
|
|
||||||
3. Note the non-Debian equivalent as a `/usr/bin/python -> python3` symlink.
|
|
||||||
4. Validate markdown/diff, run shell syntax checks where applicable, run required review, commit, queue guard, and push.
|
|
||||||
|
|
||||||
## Validation Log
|
|
||||||
|
|
||||||
- `rg` recon: no existing agent-host Debian package provisioning script; only CI/test helper `apt-get install` paths and unrelated deployment docs.
|
|
||||||
- `git diff --check`: passed.
|
|
||||||
- `bash -n packages/mosaic/framework/install.sh tools/install.sh packages/mosaic/framework/tools/bootstrap/init-project.sh packages/mosaic/framework/tools/_scripts/mosaic-bootstrap-repo`: passed. No touched shell scripts.
|
|
||||||
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted`: approved, 0 findings.
|
|
||||||
- `pnpm format:check`: initially blocked because `node_modules` was absent and `prettier` was unavailable; `pnpm install --frozen-lockfile` initially hit an invalid `/root` pnpm store path. Reran install with `--store-dir /home/hermes/agent-work/.pnpm-store`, then `pnpm format:check` passed.
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
# #703 Git Wrapper Interactive and Auth Resilience
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Restore the deployed Git wrapper contract: issue-create supports interactive invocation and Gitea mutation behavior tolerates a stale Tea authenticated user by validating current identity and using the existing host-scoped API fallback.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- `packages/mosaic/framework/tools/git/issue-create.sh`
|
|
||||||
- `packages/mosaic/framework/tools/git/detect-platform.sh`
|
|
||||||
- Git wrapper regression harnesses
|
|
||||||
- This scratchpad
|
|
||||||
|
|
||||||
## Requirements / acceptance evidence
|
|
||||||
|
|
||||||
1. `issue-create -i` and `--interactive` prompt for missing issue fields without exposing credentials.
|
|
||||||
2. Explicit command-line fields retain precedence and do not trigger prompt input.
|
|
||||||
3. Gitea wrapper resolves the current user dynamically from the target host and does not rely on the saved Tea user identity.
|
|
||||||
4. A Tea `GetUserByName` failure falls back to authenticated API creation.
|
|
||||||
5. Existing body-safety, login-resolution, issue-create, and pr-create paths remain green.
|
|
||||||
6. Source framework is re-seeded to deployed `~/.config/mosaic`, then deployed wrappers are verified end to end.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Add failing shell regression harness for interactive input and stale Tea user fallback.
|
|
||||||
2. Implement minimal helper and parser changes.
|
|
||||||
3. Run wrapper harnesses, syntax checks, and repository baseline checks.
|
|
||||||
4. Re-seed deployed framework and run live wrapper verification.
|
|
||||||
5. Commit, queue guard, push, open PR, and stop for independent review.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- Issue #703 filed before code; issue comment records #536 root cause and stale-login trigger.
|
|
||||||
- Deployed wrapper `issue-create.sh -i` reproduced: `Unknown option: -i` (exit 1).
|
|
||||||
- Live Tea mutation did not reproduce `GetUserByName` on this host because the current mosaicstack Tea login is valid. The test harness models the reported stale authenticated-user condition.
|
|
||||||
- Implemented `-i` / `--interactive` prompt collection and a dynamic Tea `/user` validation. A stale Tea identity now selects the existing host-scoped Gitea API fallback before mutation for both issue and PR creation.
|
|
||||||
- Re-seeded the framework with `MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash packages/mosaic/framework/install.sh`. Installed and source wrapper SHA-256 values matched.
|
|
||||||
- Live deployed verification: interactive issue-create opened then closed #704; installed dynamic identity resolved `jason.woltje`.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- PASS: `packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh`
|
|
||||||
- PASS: `packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh`
|
|
||||||
- PASS: `packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh`
|
|
||||||
- PASS: `packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh`
|
|
||||||
- PASS: `packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh`
|
|
||||||
- PASS: `bash packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh`
|
|
||||||
- PASS: `bash -n packages/mosaic/framework/tools/git/*.sh`
|
|
||||||
- PASS: Prettier check for this scratchpad
|
|
||||||
82
docs/scratchpads/B1-next-durable-publish-design.md
Normal file
82
docs/scratchpads/B1-next-durable-publish-design.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# 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.
|
||||||
34
docs/scratchpads/B2-skills-sync-path.md
Normal file
34
docs/scratchpads/B2-skills-sync-path.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# 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.
|
||||||
36
docs/scratchpads/B3-wizard-gateway-health-order.md
Normal file
36
docs/scratchpads/B3-wizard-gateway-health-order.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# 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.
|
||||||
36
docs/scratchpads/B4-wizard-step-dedup.md
Normal file
36
docs/scratchpads/B4-wizard-step-dedup.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# 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.
|
||||||
60
docs/scratchpads/FED-M3-10-integration-tests.md
Normal file
60
docs/scratchpads/FED-M3-10-integration-tests.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# 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.
|
||||||
40
docs/scratchpads/installer-next-fast-npm-20260625.md
Normal file
40
docs/scratchpads/installer-next-fast-npm-20260625.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# 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`.
|
||||||
35
docs/scratchpads/installer-next-lane-20260624.md
Normal file
35
docs/scratchpads/installer-next-lane-20260624.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# 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.
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# Scratchpad — Tess Interaction Agent
|
|
||||||
|
|
||||||
## 2026-07-12 — Mission intake
|
|
||||||
|
|
||||||
**Objective:** Build a Pi-native GPT-5.6 Sol high-reasoning Mosaic interaction agent, named Tess, as Jason's primary Discord/CLI access point for Mosaic fleet and transitional Hermes capabilities. Tess complements Mos and must not become a competing orchestrator.
|
|
||||||
|
|
||||||
**Issue:** #706
|
|
||||||
|
|
||||||
**Budget:** No explicit cap provided. Original working estimate was 290K implementation/review tokens. That estimate is superseded after six security prerequisite tasks were added; revised arithmetic total is pending because the calculation tool was blocked by runtime consent. Run at most two workers; prefer one implementation lane plus one independent review/discovery lane. Re-estimate after planning approval and each milestone.
|
|
||||||
|
|
||||||
**Evidence gathered:**
|
|
||||||
- Mosaic already provides Pi lifecycle hooks, fleet/tmux sessions, Matrix connector/controller pieces, typed chat events, Discord/Telegram channel plugins, and command/plugin registries.
|
|
||||||
- Current `IProviderAdapter` is an LLM model/completion abstraction, not an external agent/session provider.
|
|
||||||
- Required new seam is `AgentRuntimeProvider`: sessions, stream, message, terminate, hierarchy, attach, health, capabilities.
|
|
||||||
- Recurring cross-runtime needs: unified memory/retrieval, Discord routing/approvals, agent state/inbox/compaction recovery, runtime bootstrap, fleet/incident controls, and GitOps workflow.
|
|
||||||
- Project truth must remain in canonical project/Mosaic stores; semantic memory is retrieval/mirror.
|
|
||||||
|
|
||||||
**Decisions:**
|
|
||||||
1. Name: Tess (tessera). Stable machine key `tess`; display name configurable.
|
|
||||||
2. Mos owns orchestration; Tess delegates Mos-owned work through an explicit coordination contract.
|
|
||||||
3. Gateway owns ingress/auth/routing; Discord and CLI remain thin clients.
|
|
||||||
4. tmux/fleet ships first behind an adapter; Matrix/native Mosaic is the forward transport.
|
|
||||||
5. Hermes integration is transitional and capability-negotiated; unsupported operations fail closed.
|
|
||||||
6. No unrestricted Discord shell. Privileged/destructive/customer-visible actions require authorization and approval.
|
|
||||||
|
|
||||||
**Plan:**
|
|
||||||
1. Land requirements/architecture/task graph.
|
|
||||||
2. Deliver runtime contracts and security model.
|
|
||||||
3. Deliver durable Pi service/state.
|
|
||||||
4. Deliver Discord and CLI.
|
|
||||||
5. Deliver fleet/Mos/Hermes/memory/tool plugins.
|
|
||||||
6. Deliver Matrix/native transport, migration matrix, recovery, docs, and qualification.
|
|
||||||
|
|
||||||
**Progress:** Issue #706 created. PRD/manifest/tasks initialized on clean branch `feat/tess-interaction-agent` from `origin/main`.
|
|
||||||
|
|
||||||
**Risks:** 14 GB root filesystem headroom; active fleet lanes; broad migration scope; Discord privilege boundary; possible duplicate orchestration authority.
|
|
||||||
|
|
||||||
## 2026-07-12 — Independent planning and threat review
|
|
||||||
|
|
||||||
**Verdict received:** BLOCK TESS-PLAN-001. Coding remains stopped.
|
|
||||||
|
|
||||||
**Blocking findings:** formal threat model absent; verification matrix absent; migration inventory implied but absent; non-existent task paths; AC-TESS-03 lacked a crisp test. Security review also identified command scope bypass, cross-tenant session attachment, MCP actor impersonation, unsafe Discord service ingress, pre-persistence/egress secret leakage, non-durable replay, and globally scoped GC.
|
|
||||||
|
|
||||||
**Remediation applied:**
|
|
||||||
- Added `docs/tess/ARCHITECTURE.md`.
|
|
||||||
- Added `docs/tess/THREAT-MODEL.md` with TM-01..12.
|
|
||||||
- Added `docs/tess/VERIFICATION-MATRIX.md` mapping AC-TESS-01..11.
|
|
||||||
- Added `docs/tess/MIGRATION-INVENTORY.md`.
|
|
||||||
- Added hard requirements TESS-SEC-002..009.
|
|
||||||
- Added six prerequisite security tasks before provider/ingress implementation.
|
|
||||||
- Corrected task paths to existing package surfaces.
|
|
||||||
- Added explicit GPT-5.6 Sol/high/tool-policy status verification for AC-TESS-03.
|
|
||||||
|
|
||||||
**Re-review 1:** BLOCK only on composite `repo` values that looked like nonexistent paths. Remediated by declaring comma-separated roots and validating every root.
|
|
||||||
|
|
||||||
**Final focused review:** PASS. Deterministic audit validated all task repository roots with zero missing paths; no planning placeholders remained; security prerequisites still gate Tess exposure; observability traceability is explicit.
|
|
||||||
|
|
||||||
**Current gate:** planning PR must merge to `main` with terminal-green CI before any source-code worker starts.
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# Scratchpad — TESS-M1-SEC-006 Session GC scope
|
|
||||||
|
|
||||||
- **Task / issue:** TESS-M1-SEC-006 / #707
|
|
||||||
- **Branch:** `fix/tess-session-gc-scope` from `origin/main` at `59e49cfd`
|
|
||||||
- **Objective:** Make session cleanup session-scoped and prevent automatic global retention/GC without an authorized, auditable operation.
|
|
||||||
- **Scope:** `apps/gateway`, `packages/log`, admin/developer operations documentation.
|
|
||||||
- **Budget:** Task estimate 18K; no explicit hard cap supplied.
|
|
||||||
- **Assumption:** No authorized global retention service exists today. Existing full/sweep GC must therefore be disabled from startup and cron paths, while single-session cleanup remains available.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Add failing isolation tests proving single-session cleanup only demotes its own logs and automatic startup/scheduled GC cannot globally delete session data.
|
|
||||||
2. Add session-scoped log repository retention and make `collect(sessionId)` use it.
|
|
||||||
3. Remove automatic full/sweep GC invocation; preserve any future global operation behind an explicit authorization/audit seam.
|
|
||||||
4. Document the operational boundary, run gates, review, and commit without push.
|
|
||||||
|
|
||||||
## Verification evidence
|
|
||||||
|
|
||||||
- Isolation TDD: `pnpm --filter @mosaicstack/gateway test -- session-gc.service.spec.ts commands.integration.spec.ts command-executor-p8012.spec.ts` — 61 passed.
|
|
||||||
- `pnpm typecheck` — passed.
|
|
||||||
- `pnpm lint` — passed.
|
|
||||||
- `pnpm format:check` remains red only on the known pre-existing Tess documentation debt; changed files are Prettier-clean.
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
# Tess Architecture
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Tess is the Mosaic operator interaction plane. Mos remains the coding/general fleet orchestration authority. Tess receives authorized operator intent, presents fleet/session state, delegates Mos-owned work to Mos, and exposes native Mosaic plus transitional external-agent capabilities through normalized providers.
|
|
||||||
|
|
||||||
## Component Boundaries
|
|
||||||
|
|
||||||
```text
|
|
||||||
Discord plugin ─┐
|
|
||||||
├─ authenticated ingress envelope ─> Mosaic Gateway
|
|
||||||
mosaic tess CLI ┘ │
|
|
||||||
├─ policy/approval/audit
|
|
||||||
├─ Tess durable session service (Pi GPT-5.6 Sol high)
|
|
||||||
├─ AgentRuntimeProvider registry
|
|
||||||
│ ├─ native Pi provider
|
|
||||||
│ ├─ fleet/tmux provider
|
|
||||||
│ ├─ Hermes adapter
|
|
||||||
│ └─ Matrix/native transport provider
|
|
||||||
├─ memory/state/inbox plugins
|
|
||||||
└─ Mos coordination adapter ─> Mos / fleet queue
|
|
||||||
```
|
|
||||||
|
|
||||||
## Core Contract
|
|
||||||
|
|
||||||
`AgentRuntimeProvider` is separate from the existing model-completion `IProviderAdapter`. It normalizes external and native agent runtimes without leaking provider-specific schemas.
|
|
||||||
|
|
||||||
Required operations:
|
|
||||||
|
|
||||||
- `capabilities()` and `health()`
|
|
||||||
- `listSessions(scope)`
|
|
||||||
- `getSessionTree(scope)`
|
|
||||||
- `streamSession(sessionRef, cursor, scope)`
|
|
||||||
- `sendMessage(sessionRef, message, idempotencyKey, scope)`
|
|
||||||
- `attach(sessionRef, mode, scope)` / `detach()`
|
|
||||||
- `terminate(sessionRef, approvalRef, scope)`
|
|
||||||
|
|
||||||
Every call receives an immutable, server-derived actor/tenant/channel scope and correlation ID. Caller-supplied actor IDs are forbidden. Unsupported capabilities fail closed with typed errors.
|
|
||||||
|
|
||||||
## Authority Model
|
|
||||||
|
|
||||||
| Intent | Owner | Tess behavior |
|
|
||||||
| --------------------------------------------------------------------------- | ----------------------- | ---------------------------------------------------------------------- |
|
|
||||||
| Conversation, status, retrieval, safe diagnostics | Tess | Execute within policy |
|
|
||||||
| Code/project decomposition, worker assignment, reviews, merge orchestration | Mos | Create a correlated handoff and observe result |
|
|
||||||
| Destructive, privileged, external/customer-visible action | Human approval + policy | Propose, wait for durable one-time approval, then execute idempotently |
|
|
||||||
| Provider-specific unsupported action | None | Fail closed; never emulate silently |
|
|
||||||
|
|
||||||
## Session and State Model
|
|
||||||
|
|
||||||
A Tess session has stable `sessionId`, `tenantId`, `ownerId`, provider/runtime identity, ingress bindings, cursor, checkpoint, inbox/outbox, and idempotency records. Discord and CLI bind to the same authorized session. Ownership is verified server-side on every list/read/attach/send/terminate operation.
|
|
||||||
|
|
||||||
Valkey may hold ephemeral coordination state; PostgreSQL is canonical for durable session bindings, approvals, audit, checkpoints, inbox/outbox, and idempotency. Pi session files are replay sources, not cross-agent truth.
|
|
||||||
|
|
||||||
## Transport Strategy
|
|
||||||
|
|
||||||
- **Initial:** fleet/tmux provider, including exact target, socket, identity, heartbeat, and safe attach semantics.
|
|
||||||
- **Forward:** Matrix/native Mosaic provider using authenticated identity, idempotent transaction IDs, replay cursors, and the same contract suite.
|
|
||||||
- Discord/CLI never call tmux or Matrix directly.
|
|
||||||
|
|
||||||
## Plugin Families
|
|
||||||
|
|
||||||
1. Channel: Discord now; other channels later.
|
|
||||||
2. Runtime: Pi, fleet/tmux, Hermes, Matrix/native.
|
|
||||||
3. Operator tools: fleet health, Mos handoff, GitOps wrappers, incident-safe diagnostics.
|
|
||||||
4. Memory/state: search/recent/capture, durable inbox, checkpoint, handoff, compaction recovery.
|
|
||||||
5. Migration: capability inventory, adapters, cutover, rollback, telemetry.
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
Tess runs as a rostered, systemd-supervised Pi agent using GPT-5.6 Sol and high reasoning. Secrets are supplied through approved runtime secret mechanisms. Startup fails when required model, gateway identity, Discord binding, or durable-state dependencies are missing. Health reports effective model/reasoning/tool policy without credential material.
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# Tess Capability Migration Inventory
|
|
||||||
|
|
||||||
Status values: `native` · `adapt` · `defer` · `reject`. This is the initial inventory; M5 requires implementation and evidence fields to be completed before cutover.
|
|
||||||
|
|
||||||
| Capability | Current source | Target | Initial status | Cutover/rollback intent |
|
|
||||||
| ---------------------------------------- | ---------------------------------------- | ------------------------------------------ | -------------- | ----------------------------------------------------------------------- |
|
|
||||||
| Interactive agent chat/session streaming | Hermes/Pi/OpenClaw | Mosaic Tess session service | native | Dual-run per channel; revert binding to legacy gateway |
|
|
||||||
| Discord dedicated-channel routing | Hermes/Claude/OpenClaw plugins | Mosaic Discord plugin + gateway | native | Per-channel binding switch; legacy bot disabled only after soak |
|
|
||||||
| CLI/TUI session interaction and attach | Hermes/Pi/tmux | `mosaic tess` + AgentRuntimeProvider | native | Keep direct tmux attach as break-glass rollback |
|
|
||||||
| Session list/tree/send/terminate | Hermes/fleet | AgentRuntimeProvider | native | Capability-negotiated adapter remains during migration |
|
|
||||||
| Mos/fleet orchestration handoff | tmux messaging/Mosaic fleet | Mosaic coord/fleet provider | native | tmux handoff remains initial transport |
|
|
||||||
| Kanban/projects/tasks | Hermes Kanban | Mosaic queue/coord/project providers | adapt | Read projection first; mutating cutover after parity/audit |
|
|
||||||
| Skills catalog/load/manage | Hermes skills/Pi skills | Mosaic skill registry/provider | adapt | Import metadata/provenance; preserve source skill until validated |
|
|
||||||
| Tools and MCP | Hermes/OpenClaw/MCP | Mosaic tool registry/MCP | adapt | Default deny; migrate allowlisted tools one capability at a time |
|
|
||||||
| Cron/scheduled work | Hermes cron | Mosaic scheduler/queue | adapt | Shadow schedules; prevent duplicate execution; rollback owner field |
|
|
||||||
| Memory search/recent/capture | jarvis-brain/OpenViking/OpenBrain/Hermes | Mosaic memory provider | adapt | Flat/project stores remain truth; semantic systems are mirrors |
|
|
||||||
| User/profile preferences | Hermes memory/user profile | Mosaic user/memory domain | adapt | Provenance + explicit conflict rules; exportable rollback snapshot |
|
|
||||||
| Agent state/inbox/handoff | OpenClaw extensions/session files | Mosaic durable state service | native | Read legacy handoff during coexistence; write Mosaic only after cutover |
|
|
||||||
| Runtime contract/bootstrap | Mosaic framework/Hermes/OpenClaw | Mosaic compose/runtime provider | native | Legacy launchers remain until clean-host parity passes |
|
|
||||||
| Repository/PR workflow | Mosaic wrappers/Hermes tools | Mosaic operator plugin | native | Wrapper-only; no raw-provider fallback |
|
|
||||||
| Incident-safe diagnostics | Hermes skills/tools | Mosaic scoped operator plugin | adapt | Read-only first; privileged recovery requires approval |
|
|
||||||
| Broad unrestricted shell from Discord | Hermes/OpenClaw configurations | None | reject | No cutover; replace with allowlisted typed operations |
|
|
||||||
| Raw full transcript bulk migration | Hermes/Claude/OpenClaw histories | Indexed summaries/selective import | reject | Keep source archives subject to retention; no automatic copy |
|
|
||||||
| Voice/video interaction | Hermes optional tools | Future Mosaic channel plugins | defer | Not required for Tess operational release |
|
|
||||||
| Matrix transport | Mosaic connector | AgentRuntimeProvider Matrix implementation | native | Non-default until contract/reliability parity; tmux rollback |
|
|
||||||
|
|
||||||
## Cutover Gates
|
|
||||||
|
|
||||||
1. Capability contract and security tests pass.
|
|
||||||
2. Data mapping/provenance and retention are documented.
|
|
||||||
3. Shadow or dual-run shows no unauthorized access, loss, or duplicate effects.
|
|
||||||
4. Operator runbook and rollback are exercised.
|
|
||||||
5. Channel/provider binding changes are reversible without schema rollback.
|
|
||||||
6. Legacy capability is disabled only after a defined soak period and evidence review.
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# Mission Manifest — Tess Interaction Agent
|
|
||||||
|
|
||||||
## Mission
|
|
||||||
|
|
||||||
- **ID:** tess-20260712
|
|
||||||
- **Issue:** #706
|
|
||||||
- **Branch:** `feat/tess-interaction-agent`
|
|
||||||
- **Phase:** Execution
|
|
||||||
- **Current Milestone:** TESS-M1 — Runtime contracts and security foundation
|
|
||||||
- **Progress:** 0 / 5 delivery milestones complete
|
|
||||||
- **Status:** active
|
|
||||||
- **Owner:** Mosaic orchestrator; Mos is coordinating fleet authority
|
|
||||||
- **Source PRD:** `docs/PRD.md` — `TESS-*` requirements
|
|
||||||
- **Scratchpad:** `docs/scratchpads/tess-20260712.md`
|
|
||||||
|
|
||||||
## Mission Statement
|
|
||||||
|
|
||||||
Ship Tess as Jason's durable Pi-native GPT-5.6 Sol high-reasoning interaction agent for Discord and CLI, with safe visibility/control of Mosaic fleet and transitional Hermes capabilities, while Mos remains the coding/general orchestration authority.
|
|
||||||
|
|
||||||
## Invariants
|
|
||||||
|
|
||||||
1. Mosaic is the enterprise AI hub; Hermes is a reference migration adapter.
|
|
||||||
2. Gateway is the single API surface.
|
|
||||||
3. Mos owns coding/general fleet orchestration; Tess owns human interaction, visibility, mediation, and migration access.
|
|
||||||
4. Runtime, transport, channel, memory, and external-agent integrations are replaceable adapters.
|
|
||||||
5. No source task completes before merged PR, terminal-green CI, independent review, and linked task/issue closure.
|
|
||||||
|
|
||||||
## Milestones
|
|
||||||
|
|
||||||
| ID | Issue | Name | Status | Exit gate |
|
|
||||||
| ------- | ----- | ------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| TESS-M1 | #707 | Runtime contracts and security foundation | ready | AgentRuntimeProvider, normalized events/capabilities/errors, RBAC/audit contracts and contract tests merged |
|
|
||||||
| TESS-M2 | #708 | Durable Pi Tess service and state | not-started | GPT-5.6 Sol high service starts, resumes, checkpoints, and passes restart/compaction tests |
|
|
||||||
| TESS-M3 | #709 | Discord and CLI interaction surfaces | not-started | One durable session works through dedicated Discord binding and `mosaic tess`, including attach and approvals |
|
|
||||||
| TESS-M4 | #710 | Fleet, Mos, Hermes, memory, state, and tool plugins | not-started | Fleet/Mos boundary and transitional capability matrix demonstrated end-to-end |
|
|
||||||
| TESS-M5 | #711 | Matrix/native migration, recovery, documentation, and qualification | not-started | Transport parity, migration/rollback matrix, security review, docs, greenfield and deployment validation complete |
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
All `AC-TESS-*` criteria in `docs/PRD.md` are mapped to reproducible evidence. The final operational test must prove Discord + CLI session continuity, fleet/Mos coordination, authorized Hermes transition capabilities, denial/audit paths, restart recovery, and rollback.
|
|
||||||
|
|
||||||
## Session History
|
|
||||||
|
|
||||||
| Session | Date | Runtime | Outcome |
|
|
||||||
| ------- | ---------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| S1 | 2026-07-12 | Hermes / GPT-5.6 Sol | User commission captured; Mosaic/OpenViking/session/code archaeology completed; issue #706 created; PRD and task control plane initialized. |
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# Tasks — Tess Interaction Agent
|
|
||||||
|
|
||||||
> Mission: `tess-20260712` · Issue: #706 · PRD requirements: `TESS-*`
|
|
||||||
> Orchestrator is sole writer. Workers must not modify this file.
|
|
||||||
> `repo` contains one or more comma-separated repository-relative roots; every listed root must exist before dispatch.
|
|
||||||
|
|
||||||
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
|
|
||||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
|
||||||
| TESS-PLAN-001 | done | Finalize PRD, architecture, authority boundary, threat model, migration inventory, and verification matrix | #706 | sonnet | docs, packages/types, apps/gateway | feat/tess-interaction-agent | — | 22K | Independent gate PASS after two remediation rounds; completion effective when planning PR merges |
|
|
||||||
| TESS-M1-SEC-001 | not-started | Enforce command scopes/roles and durable exact-action approval for privileged/destructive commands | #707 | codex | apps/gateway | fix/tess-command-authz | TESS-PLAN-001 | 25K | TESS-SEC-002; security TDD |
|
|
||||||
| TESS-M1-SEC-002 | not-started | Enforce owner/tenant binding on session list/read/attach/send/terminate across REST and WS | #707 | codex | apps/gateway | fix/tess-session-ownership | TESS-PLAN-001 | 30K | TESS-SEC-003; security TDD |
|
|
||||||
| TESS-M1-SEC-003 | not-started | Bind MCP actor/tenant to authenticated context and add per-tool scopes | #707 | codex | apps/gateway | fix/tess-mcp-identity | TESS-PLAN-001 | 22K | TESS-SEC-004; security TDD |
|
|
||||||
| TESS-M1-SEC-004 | not-started | Add authenticated Discord service ingress, allowlists, correlation and replay protection | #707 | codex | plugins/discord, apps/gateway | fix/tess-discord-ingress | TESS-PLAN-001 | 28K | TESS-SEC-005; security TDD |
|
|
||||||
| TESS-M1-SEC-005 | not-started | Redact/classify secret and PII before persistence/egress; harden provider login flow | #707 | codex | apps/gateway, packages/log | fix/tess-redaction | TESS-PLAN-001 | 28K | TESS-SEC-006; seeded canary tests |
|
|
||||||
| TESS-M1-SEC-006 | not-started | Scope session GC/retention or separate authorized global retention job | #707 | codex | apps/gateway, packages/log | fix/tess-session-gc-scope | TESS-PLAN-001 | 18K | TESS-SEC-009; isolation TDD |
|
|
||||||
| TESS-M1-001 | not-started | Define AgentRuntimeProvider, capabilities, session tree, normalized stream events/errors, attach semantics | #707 | codex | packages/types, packages/agent | feat/tess-runtime-contract | TESS-PLAN-001 | 25K | TESS-ARP-001, TESS-TRN-001; contract TDD |
|
|
||||||
| TESS-M1-002 | not-started | Implement provider registry/service with immutable actor scope, approval, audit and correlation boundaries | #707 | codex | apps/gateway, packages/agent | feat/tess-provider-registry | TESS-M1-001,TESS-M1-SEC-001,TESS-M1-SEC-002,TESS-M1-SEC-003 | 30K | TESS-SEC-001..004,007; security TDD |
|
|
||||||
| TESS-M1-003 | not-started | Implement tmux/fleet runtime provider and safe attach/message/terminate capability policy | #707 | codex | packages/mosaic, packages/agent | feat/tess-fleet-provider | TESS-M1-002 | 30K | TESS-FLT-001; exact target/identity tests |
|
|
||||||
| TESS-M1-OBS-001 | not-started | Implement correlation propagation, structured runtime/provider/tool audit, health/readiness and safe effective-policy status | #707 | codex | apps/gateway, packages/agent, packages/log | feat/tess-observability | TESS-M1-002 | 24K | TESS-OBS-001; no credential material |
|
|
||||||
| TESS-M1-V | not-started | Independent architecture/security review and complete contract/abuse-suite verification | #707 | sonnet | apps/gateway, packages/agent, packages/log, plugins/discord | review/tess-m1 | TESS-M1-SEC-001,TESS-M1-SEC-002,TESS-M1-SEC-003,TESS-M1-SEC-004,TESS-M1-SEC-005,TESS-M1-SEC-006,TESS-M1-003,TESS-M1-OBS-001 | 20K | Gate M2 |
|
|
||||||
| TESS-M2-001 | not-started | Add Tess roster/profile/service pinned to GPT-5.6 Sol high with fail-fast config and observable effective policy | #708 | codex | packages/mosaic/framework | feat/tess-pi-service | TESS-M1-V | 22K | TESS-PI-001; explicit AC-TESS-03 test |
|
|
||||||
| TESS-M2-002 | not-started | Implement durable session identity, inbox/outbox, approval, checkpoint, handoff, compaction and restart recovery | #708 | codex | apps/gateway, packages/agent, packages/db | feat/tess-durable-state | TESS-M2-001 | 38K | TESS-STA-001, TESS-SEC-007..008; recovery TDD |
|
|
||||||
| TESS-M2-V | not-started | Clean-host Pi launch plus model/policy status and restart/compaction/duplicate-side-effect verification | #708 | sonnet | apps/gateway/src/__tests__/integration, packages/mosaic/src | review/tess-m2 | TESS-M2-002 | 18K | Gate M3; AC-TESS-03/06 |
|
|
||||||
| TESS-M3-001 | not-started | Bind dedicated Tess Discord channel with streaming, threads, attachments, pairing/RBAC and approvals | #709 | codex | plugins/discord, apps/gateway | feat/tess-discord | TESS-M2-V,TESS-M1-SEC-004 | 35K | TESS-DSC-001 |
|
|
||||||
| TESS-M3-002 | not-started | Implement `mosaic tess` chat/status/sessions/tree/attach/send/stop/health/recover CLI | #709 | codex | packages/mosaic | feat/tess-cli | TESS-M2-V | 30K | TESS-CLI-001 |
|
|
||||||
| TESS-M3-V | not-started | Discord+CLI same-session E2E, denial/approval tests, and operator-flow review | #709 | sonnet | apps/gateway/src/__tests__/integration, plugins/discord, packages/mosaic/src | review/tess-m3 | TESS-M3-001,TESS-M3-002 | 20K | Gate M4 |
|
|
||||||
| TESS-M4-001 | not-started | Implement Mos coordination handoff/observe/result contract with authority-boundary tests | #710 | codex | packages/coord, apps/gateway | feat/tess-mos-coordination | TESS-M3-V | 25K | TESS-MOS-001 |
|
|
||||||
| TESS-M4-002 | not-started | Implement transitional Hermes runtime/capability adapter | #710 | codex | packages/agent, apps/gateway | feat/tess-hermes-adapter | TESS-M3-V | 40K | TESS-HRM-001; no legacy schema in core contracts |
|
|
||||||
| TESS-M4-003 | not-started | Implement memory/retrieval, state/inbox, runtime bootstrap, fleet diagnostics and GitOps plugin foundations | #710 | codex | packages/memory, packages/agent, packages/mosaic | feat/tess-operator-plugins | TESS-M3-V | 40K | TESS-MEM-001, TESS-PLG-001 |
|
|
||||||
| TESS-M4-V | not-started | Cross-provider capability, privacy, authority and failure-path qualification | #710 | sonnet | apps/gateway/src/__tests__/integration, packages/agent | review/tess-m4 | TESS-M4-001,TESS-M4-002,TESS-M4-003 | 22K | Gate M5 |
|
|
||||||
| TESS-M5-001 | not-started | Implement Matrix/native runtime provider behind common contracts and parity suite | #711 | codex | packages/mosaic, packages/agent | feat/tess-matrix-provider | TESS-M4-V | 30K | TESS-TRN-001 |
|
|
||||||
| TESS-M5-002 | not-started | Complete migration inventory, cutover, rollback, retention and deprecation evidence | #711 | sonnet | docs/tess | feat/tess-migration-docs | TESS-M4-V | 18K | TESS-MIG-001 |
|
|
||||||
| TESS-M5-003 | not-started | Complete OpenAPI, user/admin/developer/plugin/operations docs and checklist | #711 | codex | docs | feat/tess-docs | TESS-M5-001,TESS-M5-002 | 22K | Documentation hard gate |
|
|
||||||
| TESS-M5-V | not-started | Full baseline, contract, integration, Discord/CLI E2E, security review, recovery drill and rollback qualification | #711 | sonnet | apps/gateway, packages/agent, plugins/discord, packages/mosaic | review/tess-final | TESS-M5-003 | 35K | Maps AC-TESS-01..11 to evidence |
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# Tess Threat Model
|
|
||||||
|
|
||||||
## Assets and Trust Boundaries
|
|
||||||
|
|
||||||
Assets: operator identity, tenant/project data, agent sessions, fleet control, approvals, credentials, memories, tool outputs, audit evidence, and provider transports.
|
|
||||||
|
|
||||||
Trust boundaries: Discord→plugin, CLI→gateway, plugin→gateway service identity, gateway→Pi/provider, Tess→Mos/fleet, Tess→Hermes, MCP→gateway, persistence, and tmux/Matrix transports.
|
|
||||||
|
|
||||||
## Threat Matrix
|
|
||||||
|
|
||||||
| ID | Severity | Threat | Required control | Required verification |
|
|
||||||
| ----- | -------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
|
|
||||||
| TM-01 | critical | Client invokes admin/system command without role | Server-side scope/role enforcement in executor; durable approval for privileged/destructive commands | Authenticated non-admin and forged-scope tests deny and audit |
|
|
||||||
| TM-02 | critical | Cross-user/tenant list, attach, send, or terminate by guessed session ID | Owner/tenant binding on every session operation; admin override is explicit and audited | Cross-tenant matrix for REST, WS, CLI, Discord and provider methods |
|
|
||||||
| TM-03 | high | MCP caller supplies another `userId` | Remove actor IDs from schemas; derive actor/tenant from authenticated context; per-tool scopes | Forged actor/tool calls deny; no victim data returned |
|
|
||||||
| TM-04 | high | Discord ingress impersonates user/channel or bypasses gateway auth | Service-to-service identity, guild/channel/user allowlists, signed/correlated envelope, replay protection | Invalid service identity, unlisted IDs, replayed message IDs all deny |
|
|
||||||
| TM-05 | high | Secrets/PII leak in chat, auth links, tool args, logs, memory, or DB | Redact before persistence/egress; DM/out-of-band auth flow; short-lived hashed token state; output classification | Seeded secret/PII canary absent from durable stores/logs/public channel |
|
|
||||||
| TM-06 | high | Prompt/tool injection escalates from content to privileged action | Treat messages/files/tool output as untrusted data; structured proposals only; allowlisted tools; approval binds exact action digest | Injection corpus cannot invoke unapproved tools or alter authority |
|
|
||||||
| TM-07 | high | Approval forged, replayed, or applied to modified action | One-time approval with actor, tenant, action digest, expiry, correlation and consumption record | Forged/replayed/expired/mutated approvals deny and audit |
|
|
||||||
| TM-08 | medium | Restart causes message loss or duplicate side effects | Durable inbox/outbox/checkpoint; idempotency keys; transactional state transitions; bounded replay | Kill/restart at each state transition; exactly-once effect or safe dedupe |
|
|
||||||
| TM-09 | medium | Session GC/retention crosses tenant/session scope | Session/user-scoped GC or separately authorized global retention job | GC one session; unrelated logs/memory remain unchanged |
|
|
||||||
| TM-10 | high | tmux/Matrix transport target or identity spoofing | Exact target/socket binding, peer identity verification, Matrix whoami, authenticated transport metadata | Wrong socket/peer/room/identity refuses delivery/attach |
|
|
||||||
| TM-11 | medium | Hermes adapter exposes unsupported or broader legacy powers | Capability negotiation, default deny, normalized scopes, adapter sandbox/timeouts | Unsupported and over-scoped operations fail closed |
|
|
||||||
| TM-12 | medium | Tess competes with Mos or bypasses orchestration gates | Authority policy and correlated Mos handoff; no Tess worker-claim capability by default | Coding/decomposition intent produces handoff, not direct claim |
|
|
||||||
|
|
||||||
## Security Invariants
|
|
||||||
|
|
||||||
1. Authentication is not authorization; every command/tool/provider operation is authorized server-side.
|
|
||||||
2. Actor, tenant, roles, and channel bindings come only from authenticated gateway context.
|
|
||||||
3. No client-provided session ID grants ownership or attachment.
|
|
||||||
4. No privileged action executes without a matching, unexpired, one-time approval when policy requires it.
|
|
||||||
5. Redaction occurs before persistence and before channel egress.
|
|
||||||
6. Every externally caused operation is replay-safe and correlated.
|
|
||||||
7. Provider capability absence is a denial, not an invitation to shell around it.
|
|
||||||
|
|
||||||
## Existing Findings That Block Tess
|
|
||||||
|
|
||||||
- Command executor lacks server-side enforcement for declared scopes.
|
|
||||||
- Session list/reuse/destroy surfaces are not owner-filtered consistently.
|
|
||||||
- MCP schemas accept caller-supplied user identity.
|
|
||||||
- Discord plugin lacks a complete authenticated service ingress and user/channel allowlists.
|
|
||||||
- Chat/tool persistence lacks mandatory redaction.
|
|
||||||
- Sessions/pending Discord output are in-memory and not restart-safe.
|
|
||||||
- Session GC currently performs globally scoped promotion.
|
|
||||||
|
|
||||||
These are tracked as M1 security prerequisites and must pass independent security review before Tess ingress is enabled.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# Tess Verification Matrix
|
|
||||||
|
|
||||||
| Acceptance criterion | Requirements | Planned evidence | Gate |
|
|
||||||
| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
|
|
||||||
| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V |
|
|
||||||
| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V |
|
|
||||||
| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V |
|
|
||||||
| AC-TESS-04 | TESS-MOS-001, TESS-FLT-001 | Authority E2E: coding request creates Mos handoff; safe status runs in Tess; no competing worker claim | M4-V |
|
|
||||||
| AC-TESS-05 | TESS-HRM-001 | Hermes capability contract suite: sessions/stream/send/tree plus Kanban/skills/memory/tools/cron supported-or-denied matrix | M4-V |
|
|
||||||
| AC-TESS-06 | TESS-STA-001, TESS-SEC-008 | Kill/restart/compaction fault injection across inbox/outbox/checkpoint transitions; duplicate side-effect detector | M2-V, M5-V |
|
|
||||||
| AC-TESS-07 | TESS-SEC-001..009 | Threat-model abuse suite: authz, tenant isolation, forged identity/approval, injection, redaction, transport identity, GC scope | M1-V, M3-V, M5-V |
|
|
||||||
| AC-TESS-08 | TESS-TRN-001 | Common provider contract suite against tmux/fleet and Matrix/native; identity and replay tests | M5-V |
|
|
||||||
| AC-TESS-09 | all | `pnpm typecheck`, lint, format, unit/integration/contract/E2E; independent code and security reviews; CI URLs | Every milestone |
|
|
||||||
| AC-TESS-10 | TESS-MIG-001 | Completed capability inventory with native/adapted/deferred/rejected state, owner, cutover/rollback evidence | M5-V |
|
|
||||||
| AC-TESS-11 | TESS-PLG-001, TESS-OBS-001 | OpenAPI and user/admin/developer/plugin/ops docs, sitemap links, documentation checklist | M5-V |
|
|
||||||
|
|
||||||
## Security Abuse Suite Minimum
|
|
||||||
|
|
||||||
- Role/scope matrix for every command and provider capability.
|
|
||||||
- Cross-tenant and cross-user session ID matrix across REST, WS, Discord, CLI, MCP, and providers.
|
|
||||||
- Discord service identity, guild/channel/user allowlist, replay, attachment, and mention/DM policy cases.
|
|
||||||
- Prompt/tool injection corpus and structured-proposal enforcement.
|
|
||||||
- Approval action-digest mutation, replay, expiry, tenant, and actor mismatch cases.
|
|
||||||
- Secret/PII canaries through message, attachment, tool args/output, logs, memory, audit, and error paths.
|
|
||||||
- Restart fault injection before/after enqueue, provider send, side effect, response persistence, and acknowledgement.
|
|
||||||
- Wrong tmux socket/target and Matrix identity/room/replay cases.
|
|
||||||
|
|
||||||
## Evidence Rules
|
|
||||||
|
|
||||||
Evidence must include command/test name, terminal result, CI run URL, PR/merge reference, environment, and artifact/log location. A worker self-report is not evidence until independently verified.
|
|
||||||
@@ -15,22 +15,6 @@ This guide covers how to bootstrap a project so AI agents (Claude, Codex, etc.)
|
|||||||
7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges
|
7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges
|
||||||
8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention
|
8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention
|
||||||
|
|
||||||
## Agent Host Prerequisites
|
|
||||||
|
|
||||||
Agent hosts must provide the Python runtime shape that runtime agents and
|
|
||||||
Mosaic automation assume is present.
|
|
||||||
|
|
||||||
For Debian/Ubuntu hosts:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt-get update
|
|
||||||
# #561: bare python invocations from agents must resolve.
|
|
||||||
sudo apt-get install -y python3 python-is-python3
|
|
||||||
```
|
|
||||||
|
|
||||||
For non-Debian hosts, install the equivalent Python 3 runtime and ensure
|
|
||||||
`/usr/bin/python` resolves to `python3` (for example, via a managed symlink).
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
"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",
|
"test": "turbo run test && pnpm run test:installer",
|
||||||
|
"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"
|
||||||
|
|||||||
@@ -58,28 +58,9 @@ export function createAgentLogsRepo(db: Db) {
|
|||||||
return rows[0];
|
return rows[0];
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Transition hot logs for one session to warm tier. Session retention is
|
|
||||||
* default-deny: no other session's logs can be changed by this operation.
|
|
||||||
*/
|
|
||||||
async promoteSessionToWarm(sessionId: string, olderThan: Date): Promise<number> {
|
|
||||||
const result = await db
|
|
||||||
.update(agentLogs)
|
|
||||||
.set({ tier: 'warm', summarizedAt: new Date() })
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(agentLogs.sessionId, sessionId),
|
|
||||||
eq(agentLogs.tier, 'hot'),
|
|
||||||
lt(agentLogs.createdAt, olderThan),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.returning();
|
|
||||||
return result.length;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transition hot logs older than the cutoff to warm tier.
|
* Transition hot logs older than the cutoff to warm tier.
|
||||||
* Reserved for a separately authorized global retention job.
|
* Returns the number of logs transitioned.
|
||||||
*/
|
*/
|
||||||
async promoteToWarm(olderThan: Date): Promise<number> {
|
async promoteToWarm(olderThan: Date): Promise<number> {
|
||||||
const result = await db
|
const result = await db
|
||||||
|
|||||||
@@ -11,9 +11,37 @@ 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),
|
||||||
@@ -23,6 +51,14 @@ 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';
|
||||||
|
|
||||||
@@ -44,6 +80,16 @@ 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 });
|
||||||
@@ -98,8 +144,12 @@ describe('Unified wizard (runWizard with default skipGateway)', () => {
|
|||||||
expect(bootstrapCall[2]).toMatchObject({ host: 'localhost', port: 14242 });
|
expect(bootstrapCall[2]).toMatchObject({ host: 'localhost', port: 14242 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not invoke bootstrap when config stage reports not ready', async () => {
|
it('prints the success summary only after gateway health succeeds', async () => {
|
||||||
gatewayConfigMock.mockResolvedValue({ ready: false });
|
gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => {
|
||||||
|
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',
|
||||||
@@ -118,6 +168,43 @@ 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();
|
||||||
});
|
});
|
||||||
@@ -143,4 +230,34 @@ 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.'),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,6 +43,16 @@ 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:
|
||||||
@@ -108,8 +118,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
|
||||||
@@ -174,7 +184,9 @@ 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 --ref v1.0 # Install from a specific git ref
|
bash tools/install.sh --next # Prerelease lane: npm @next, source fallback
|
||||||
|
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
|
||||||
@@ -183,14 +195,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/bin/mosaic-sync-skills --link-only # Re-link only
|
~/.config/mosaic/tools/_scripts/mosaic-sync-skills --link-only # Re-link only
|
||||||
```
|
```
|
||||||
|
|
||||||
## Health Audit
|
## Health Audit
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mosaic doctor # Standard audit
|
mosaic doctor # Standard audit
|
||||||
~/.config/mosaic/bin/mosaic-doctor --fail-on-warn # Strict mode
|
~/.config/mosaic/tools/_scripts/mosaic-doctor --fail-on-warn # Strict mode
|
||||||
```
|
```
|
||||||
|
|
||||||
## MCP Registration
|
## MCP Registration
|
||||||
@@ -201,8 +213,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/bin/mosaic-ensure-sequential-thinking
|
~/.config/mosaic/tools/_scripts/mosaic-ensure-sequential-thinking
|
||||||
~/.config/mosaic/bin/mosaic-ensure-sequential-thinking --check
|
~/.config/mosaic/tools/_scripts/mosaic-ensure-sequential-thinking --check
|
||||||
```
|
```
|
||||||
|
|
||||||
### Claude Code MCP Registration
|
### Claude Code MCP Registration
|
||||||
|
|||||||
@@ -15,22 +15,6 @@ This guide covers how to bootstrap a project so AI agents (Claude, Codex, etc.)
|
|||||||
7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges
|
7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges
|
||||||
8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention
|
8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention
|
||||||
|
|
||||||
## Agent Host Prerequisites
|
|
||||||
|
|
||||||
Agent hosts must provide the Python runtime shape that runtime agents and
|
|
||||||
Mosaic automation assume is present.
|
|
||||||
|
|
||||||
For Debian/Ubuntu hosts:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt-get update
|
|
||||||
# #561: bare python invocations from agents must resolve.
|
|
||||||
sudo apt-get install -y python3 python-is-python3
|
|
||||||
```
|
|
||||||
|
|
||||||
For non-Debian hosts, install the equivalent Python 3 runtime and ensure
|
|
||||||
`/usr/bin/python` resolves to `python3` (for example, via a managed symlink).
|
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -15,23 +15,13 @@
|
|||||||
#
|
#
|
||||||
# After loading, service-specific env vars are exported.
|
# After loading, service-specific env vars are exported.
|
||||||
# Run `load_credentials --help` for details.
|
# Run `load_credentials --help` for details.
|
||||||
#
|
|
||||||
# Resolution order (first match wins):
|
|
||||||
# 1. $MOSAIC_CREDENTIALS_FILE (explicit override — never second-guessed)
|
|
||||||
# 2. $HOME/.config/mosaic/credentials.json
|
|
||||||
# 3. /etc/mosaic/credentials.json (host-level fallback)
|
|
||||||
# The /etc fallback exists for HOME-redirected profile environments, where
|
|
||||||
# $HOME points at a per-profile directory that has no credentials file.
|
|
||||||
# Operators symlink /etc/mosaic/credentials.json to the host's canonical
|
|
||||||
# file once, instead of exporting MOSAIC_CREDENTIALS_FILE per invocation.
|
|
||||||
|
|
||||||
if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then
|
if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then
|
||||||
for _cand in "$HOME/.config/mosaic/credentials.json" "/etc/mosaic/credentials.json"; do
|
for _cand in "$HOME/.config/mosaic/credentials.json"; do
|
||||||
if [[ -f "$_cand" ]]; then MOSAIC_CREDENTIALS_FILE="$_cand"; break; fi
|
if [[ -f "$_cand" ]]; then MOSAIC_CREDENTIALS_FILE="$_cand"; break; fi
|
||||||
done
|
done
|
||||||
: "${MOSAIC_CREDENTIALS_FILE:=$HOME/.config/mosaic/credentials.json}"
|
: "${MOSAIC_CREDENTIALS_FILE:=$HOME/.config/mosaic/credentials.json}"
|
||||||
fi
|
fi
|
||||||
export MOSAIC_CREDENTIALS_FILE
|
|
||||||
|
|
||||||
_mosaic_require_jq() {
|
_mosaic_require_jq() {
|
||||||
if ! command -v jq &>/dev/null; then
|
if ! command -v jq &>/dev/null; then
|
||||||
|
|||||||
@@ -86,16 +86,7 @@ gitea_url_matches_host() {
|
|||||||
|
|
||||||
get_gitea_service_for_host() {
|
get_gitea_service_for_host() {
|
||||||
local host="$1"
|
local host="$1"
|
||||||
local cred_file="${MOSAIC_CREDENTIALS_FILE:-}"
|
local cred_file="${MOSAIC_CREDENTIALS_FILE:-$HOME/.config/mosaic/credentials.json}"
|
||||||
if [[ -z "$cred_file" ]]; then
|
|
||||||
# Same resolution chain as _lib/credentials.sh: profile HOME, then
|
|
||||||
# host-level /etc only if it exists; neither existing keeps the
|
|
||||||
# $HOME default (matches the lib's final := fallback).
|
|
||||||
cred_file="$HOME/.config/mosaic/credentials.json"
|
|
||||||
if [[ ! -f "$cred_file" && -f /etc/mosaic/credentials.json ]]; then
|
|
||||||
cred_file="/etc/mosaic/credentials.json"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "$host" in
|
case "$host" in
|
||||||
git.mosaicstack.dev)
|
git.mosaicstack.dev)
|
||||||
@@ -240,33 +231,6 @@ get_gitea_login_for_host() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Validate the current authenticated Gitea user for a resolved Tea login.
|
|
||||||
# Tea stores a user name with each login which can become stale after user rename,
|
|
||||||
# token rotation, or server migration. Querying /user derives the identity from the
|
|
||||||
# active credential instead of trusting that saved name. Callers fall back to the
|
|
||||||
# host-scoped API path when this validation fails.
|
|
||||||
get_gitea_authenticated_user() {
|
|
||||||
local login_name="$1" response
|
|
||||||
|
|
||||||
command -v tea >/dev/null 2>&1 || return 1
|
|
||||||
response=$(tea api --login "$login_name" /user 2>/dev/null) || return 1
|
|
||||||
TEA_AUTHENTICATED_USER_JSON="$response" python3 - <<'PY'
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
|
|
||||||
try:
|
|
||||||
user = json.loads(os.environ["TEA_AUTHENTICATED_USER_JSON"])
|
|
||||||
except (KeyError, json.JSONDecodeError):
|
|
||||||
raise SystemExit(1)
|
|
||||||
|
|
||||||
login = user.get("login") if isinstance(user, dict) else None
|
|
||||||
if isinstance(login, str) and login:
|
|
||||||
print(login)
|
|
||||||
raise SystemExit(0)
|
|
||||||
raise SystemExit(1)
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
get_default_tea_login() {
|
get_default_tea_login() {
|
||||||
local logins_json
|
local logins_json
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ Examples:
|
|||||||
$(basename "$0") -i 42 -l "in-progress" -m "0.2.0"
|
$(basename "$0") -i 42 -l "in-progress" -m "0.2.0"
|
||||||
$(basename "$0") -i 42 -a @me
|
$(basename "$0") -i 42 -a @me
|
||||||
EOF
|
EOF
|
||||||
exit "${1:-1}"
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
@@ -60,7 +60,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage 0
|
usage
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1" >&2
|
echo "Unknown option: $1" >&2
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ TITLE=""
|
|||||||
BODY=""
|
BODY=""
|
||||||
LABELS=""
|
LABELS=""
|
||||||
MILESTONE=""
|
MILESTONE=""
|
||||||
INTERACTIVE=false
|
|
||||||
|
|
||||||
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
||||||
|
|
||||||
@@ -67,15 +66,13 @@ Options:
|
|||||||
-b, --body BODY Issue body/description
|
-b, --body BODY Issue body/description
|
||||||
-l, --labels LABELS Comma-separated labels (e.g., "bug,feature")
|
-l, --labels LABELS Comma-separated labels (e.g., "bug,feature")
|
||||||
-m, --milestone NAME Milestone name to assign
|
-m, --milestone NAME Milestone name to assign
|
||||||
-i, --interactive Prompt for missing issue fields
|
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
$(basename "$0") -t "Fix login bug" -l "bug,priority-high"
|
$(basename "$0") -t "Fix login bug" -l "bug,priority-high"
|
||||||
$(basename "$0") -t "Add dark mode" -b "Implement theme switching" -m "0.2.0"
|
$(basename "$0") -t "Add dark mode" -b "Implement theme switching" -m "0.2.0"
|
||||||
$(basename "$0") -i
|
|
||||||
EOF
|
EOF
|
||||||
exit "${1:-1}"
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
@@ -97,12 +94,8 @@ while [[ $# -gt 0 ]]; do
|
|||||||
MILESTONE="$2"
|
MILESTONE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-i|--interactive)
|
|
||||||
INTERACTIVE=true
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage 0
|
usage
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1" >&2
|
echo "Unknown option: $1" >&2
|
||||||
@@ -111,13 +104,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ "$INTERACTIVE" == true ]]; then
|
|
||||||
[[ -n "$TITLE" ]] || read -r -p "Issue title: " TITLE
|
|
||||||
[[ -n "$BODY" ]] || read -r -p "Issue body (optional): " BODY || true
|
|
||||||
[[ -n "$LABELS" ]] || read -r -p "Labels, comma-separated (optional): " LABELS || true
|
|
||||||
[[ -n "$MILESTONE" ]] || read -r -p "Milestone (optional): " MILESTONE || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -z "$TITLE" ]]; then
|
if [[ -z "$TITLE" ]]; then
|
||||||
echo "Error: Title is required (-t)" >&2
|
echo "Error: Title is required (-t)" >&2
|
||||||
usage
|
usage
|
||||||
@@ -141,11 +127,6 @@ case "$PLATFORM" in
|
|||||||
gitea_issue_create_api
|
gitea_issue_create_api
|
||||||
exit $?
|
exit $?
|
||||||
}
|
}
|
||||||
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
|
|
||||||
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
|
|
||||||
gitea_issue_create_api
|
|
||||||
exit $?
|
|
||||||
fi
|
|
||||||
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
|
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
|
||||||
CMD=(tea issue create "${REPO_ARGS[@]}" --title "$TITLE")
|
CMD=(tea issue create "${REPO_ARGS[@]}" --title "$TITLE")
|
||||||
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ Examples:
|
|||||||
$(basename "$0") -m "0.2.0" # Issues in milestone 0.2.0
|
$(basename "$0") -m "0.2.0" # Issues in milestone 0.2.0
|
||||||
$(basename "$0") --repo ddk/ai-bma # List issues from anywhere
|
$(basename "$0") --repo ddk/ai-bma # List issues from anywhere
|
||||||
EOF
|
EOF
|
||||||
exit "${1:-1}"
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
@@ -67,7 +67,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage 0
|
usage
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1" >&2
|
echo "Unknown option: $1" >&2
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ Examples:
|
|||||||
$(basename "$0") -t "0.0.1" -d "Pre-MVP Foundation Sprint"
|
$(basename "$0") -t "0.0.1" -d "Pre-MVP Foundation Sprint"
|
||||||
$(basename "$0") -t "0.1.0" -d "MVP Release" --due "2025-03-01"
|
$(basename "$0") -t "0.1.0" -d "MVP Release" --due "2025-03-01"
|
||||||
EOF
|
EOF
|
||||||
exit "${1:-1}"
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
@@ -60,7 +60,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage 0
|
usage
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1" >&2
|
echo "Unknown option: $1" >&2
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ Examples:
|
|||||||
$(basename "$0") -i 42 -b "Implements the feature described in #42"
|
$(basename "$0") -i 42 -b "Implements the feature described in #42"
|
||||||
$(basename "$0") -t "WIP: New feature" --draft
|
$(basename "$0") -t "WIP: New feature" --draft
|
||||||
EOF
|
EOF
|
||||||
exit "${1:-1}"
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
@@ -125,7 +125,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage 0
|
usage
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1" >&2
|
echo "Unknown option: $1" >&2
|
||||||
@@ -183,11 +183,6 @@ case "$PLATFORM" in
|
|||||||
gitea_pr_create_api
|
gitea_pr_create_api
|
||||||
exit $?
|
exit $?
|
||||||
}
|
}
|
||||||
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
|
|
||||||
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
|
|
||||||
gitea_pr_create_api
|
|
||||||
exit $?
|
|
||||||
fi
|
|
||||||
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
|
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
|
||||||
CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE")
|
CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE")
|
||||||
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Examples:
|
|||||||
$(basename "$0") -s merged -a username # Merged PRs by user
|
$(basename "$0") -s merged -a username # Merged PRs by user
|
||||||
$(basename "$0") --repo ddk/ai-bma # List PRs from anywhere
|
$(basename "$0") --repo ddk/ai-bma # List PRs from anywhere
|
||||||
EOF
|
EOF
|
||||||
exit "${1:-1}"
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
@@ -61,7 +61,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage 0
|
usage
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1" >&2
|
echo "Unknown option: $1" >&2
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ Examples:
|
|||||||
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
||||||
$(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait
|
$(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait
|
||||||
EOF
|
EOF
|
||||||
exit "${1:-1}"
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
@@ -63,7 +63,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage 0
|
usage
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1" >&2
|
echo "Unknown option: $1" >&2
|
||||||
|
|||||||
@@ -45,11 +45,6 @@ JSON
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "${1:-}" == "api" ]]; then
|
|
||||||
printf '%s\n' '{"login":"ci-bot"}'
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
||||||
if [[ "${MOSAIC_TEA_FAIL_PR_CREATE:-}" == "1" && "$*" == pr\ create* ]]; then
|
if [[ "${MOSAIC_TEA_FAIL_PR_CREATE:-}" == "1" && "$*" == pr\ create* ]]; then
|
||||||
echo 'GetUserByName: simulated stale login failure' >&2
|
echo 'GetUserByName: simulated stale login failure' >&2
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Regression harness for #701: -h/--help must exit 0, bad args must still exit nonzero.
|
|
||||||
#
|
|
||||||
# Covers the 7 wrappers whose usage() previously hard-coded `exit 1`, so every
|
|
||||||
# --help invocation exited nonzero and logged a phantom isError across fleet lanes.
|
|
||||||
# Asserts, per wrapper:
|
|
||||||
# 1. `--help` exits 0 and prints usage.
|
|
||||||
# 2. `-h` exits 0 and prints usage.
|
|
||||||
# 3. A genuine unknown flag still exits nonzero (usage() default path untouched).
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
WRAPPERS=(
|
|
||||||
issue-assign.sh
|
|
||||||
issue-create.sh
|
|
||||||
issue-list.sh
|
|
||||||
milestone-create.sh
|
|
||||||
pr-create.sh
|
|
||||||
pr-list.sh
|
|
||||||
pr-merge.sh
|
|
||||||
)
|
|
||||||
|
|
||||||
fail=0
|
|
||||||
|
|
||||||
for wrapper in "${WRAPPERS[@]}"; do
|
|
||||||
path="$SCRIPT_DIR/$wrapper"
|
|
||||||
|
|
||||||
if ! output=$(bash "$path" --help 2>&1); then
|
|
||||||
echo "FAIL: $wrapper --help exited nonzero" >&2
|
|
||||||
fail=1
|
|
||||||
elif [[ "$output" != Usage:* ]]; then
|
|
||||||
echo "FAIL: $wrapper --help did not print usage" >&2
|
|
||||||
fail=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! bash "$path" -h >/dev/null 2>&1; then
|
|
||||||
echo "FAIL: $wrapper -h exited nonzero" >&2
|
|
||||||
fail=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if bash "$path" --this-is-not-a-real-flag >/dev/null 2>&1; then
|
|
||||||
echo "FAIL: $wrapper accepted an unknown flag (should have exited nonzero)" >&2
|
|
||||||
fail=1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ "$fail" -eq 0 ]]; then
|
|
||||||
echo "help-exit-code regression passed (7/7 wrappers)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
exit "$fail"
|
|
||||||
@@ -55,11 +55,6 @@ JSON
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "${1:-}" == "api" ]]; then
|
|
||||||
printf '%s\n' '{"login":"ci-bot"}'
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${1:-}" == "issue" && "${2:-}" == "create" ]]; then
|
if [[ "${1:-}" == "issue" && "${2:-}" == "create" ]]; then
|
||||||
desc=""
|
desc=""
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Regression harness for #703: interactive issue creation and stale Tea-user fallback.
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-interactive-auth}"
|
|
||||||
REPO_DIR="$WORK_DIR/repo"
|
|
||||||
BIN_DIR="$WORK_DIR/bin"
|
|
||||||
LOG_FILE="$WORK_DIR/calls.log"
|
|
||||||
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
|
||||||
|
|
||||||
rm -rf "$WORK_DIR"
|
|
||||||
mkdir -p "$REPO_DIR" "$BIN_DIR"
|
|
||||||
git -C "$REPO_DIR" init -q
|
|
||||||
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
|
||||||
|
|
||||||
cat > "$CREDENTIALS_FILE" <<'JSON'
|
|
||||||
{"gitea":{"mosaicstack":{"url":"https://git.mosaicstack.dev","token":"test-token"}}}
|
|
||||||
JSON
|
|
||||||
|
|
||||||
cat > "$BIN_DIR/tea" <<'SH'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
if [[ "$*" == "login list --output json" ]]; then
|
|
||||||
printf '%s\n' '[{"name":"mosaicstack","url":"https://git.mosaicstack.dev"}]'
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
if [[ "${1:-}" == "api" ]]; then
|
|
||||||
if [[ "${MOSAIC_TEA_STALE_USER:-0}" == "1" ]]; then
|
|
||||||
echo 'GetUserByName: stale configured user' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
printf '%s\n' '{"login":"current-user"}'
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
|
||||||
exit 0
|
|
||||||
SH
|
|
||||||
|
|
||||||
cat > "$BIN_DIR/curl" <<'SH'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
printf 'curl %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
|
||||||
printf '%s\n' '{"number":703}'
|
|
||||||
SH
|
|
||||||
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
|
|
||||||
|
|
||||||
run_wrapper() {
|
|
||||||
(
|
|
||||||
cd "$REPO_DIR"
|
|
||||||
PATH="$BIN_DIR:$PATH" \
|
|
||||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
|
||||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
|
||||||
"$@"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
: > "$LOG_FILE"
|
|
||||||
printf 'Interactive title\nInteractive body\nlabel-a,label-b\nM1\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i >/dev/null
|
|
||||||
|
|
||||||
grep -q -- 'tea issue create --repo mosaicstack/stack --login mosaicstack --title Interactive title --description Interactive body --labels label-a,label-b --milestone M1' "$LOG_FILE"
|
|
||||||
|
|
||||||
# Explicit values take precedence in interactive mode: no title input is
|
|
||||||
# supplied, but the wrapper still creates the issue with the explicit title.
|
|
||||||
: > "$LOG_FILE"
|
|
||||||
printf '\n\n\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i -t 'Explicit title' >/dev/null
|
|
||||||
grep -q -- 'tea issue create --repo mosaicstack/stack --login mosaicstack --title Explicit title' "$LOG_FILE"
|
|
||||||
|
|
||||||
: > "$LOG_FILE"
|
|
||||||
run_wrapper env MOSAIC_TEA_STALE_USER=1 "$SCRIPT_DIR/issue-create.sh" -t 'Fallback title' -b 'Fallback body' >/dev/null 2>"$WORK_DIR/issue-stderr"
|
|
||||||
grep -q -- 'curl .*https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues' "$LOG_FILE"
|
|
||||||
grep -q -- 'Tea authenticated-user validation failed' "$WORK_DIR/issue-stderr"
|
|
||||||
if grep -q -- 'tea issue create' "$LOG_FILE"; then
|
|
||||||
echo 'FAIL: issue-create invoked Tea mutation after stale-user validation failed' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
: > "$LOG_FILE"
|
|
||||||
run_wrapper env MOSAIC_TEA_STALE_USER=1 "$SCRIPT_DIR/pr-create.sh" -t 'PR fallback' -H feature/wrapfix >/dev/null 2>"$WORK_DIR/pr-stderr"
|
|
||||||
grep -q -- 'curl .*https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls' "$LOG_FILE"
|
|
||||||
grep -q -- 'Tea authenticated-user validation failed' "$WORK_DIR/pr-stderr"
|
|
||||||
if grep -q -- 'tea pr create' "$LOG_FILE"; then
|
|
||||||
echo 'FAIL: pr-create invoked Tea mutation after stale-user validation failed' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo 'issue-create interactive/auth regression harness passed'
|
|
||||||
@@ -87,10 +87,6 @@ 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
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -77,20 +77,10 @@ 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`.
|
||||||
# Buffer name MUST be unique per invocation: concurrent senders on the shared
|
printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b __mosaic_send -
|
||||||
# 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 "$BUF" -t "$EFFECTIVE_TARGET" 2>/dev/null \
|
"${tmux_cmd[@]}" paste-buffer -d -p -b __mosaic_send -t "$EFFECTIVE_TARGET" 2>/dev/null \
|
||||||
|| "${tmux_cmd[@]}" paste-buffer -d -b "$BUF" -t "$EFFECTIVE_TARGET" \
|
|| "${tmux_cmd[@]}" paste-buffer -d -b __mosaic_send -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.
|
||||||
|
|||||||
@@ -47,32 +47,4 @@ 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"
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const RUNTIME_DEFS: Record<
|
|||||||
label: 'Pi',
|
label: 'Pi',
|
||||||
command: 'pi',
|
command: 'pi',
|
||||||
versionFlag: '--version',
|
versionFlag: '--version',
|
||||||
installHint: 'npm install -g @mariozechner/pi-coding-agent',
|
installHint: 'curl -fsSL https://pi.dev/install.sh | sh',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -85,16 +85,16 @@ function makeConfigService(): ConfigService {
|
|||||||
|
|
||||||
describe('finalizeStage — skill installer', () => {
|
describe('finalizeStage — skill installer', () => {
|
||||||
let tmp: string;
|
let tmp: string;
|
||||||
let binDir: string;
|
let scriptsDir: string;
|
||||||
let syncScript: string;
|
let syncScript: string;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tmp = mkdtempSync(join(tmpdir(), 'mosaic-finalize-'));
|
tmp = mkdtempSync(join(tmpdir(), 'mosaic-finalize-'));
|
||||||
binDir = join(tmp, 'bin');
|
scriptsDir = join(tmp, 'tools', '_scripts');
|
||||||
mkdirSync(binDir, { recursive: true });
|
mkdirSync(scriptsDir, { recursive: true });
|
||||||
syncScript = join(binDir, 'mosaic-sync-skills');
|
syncScript = join(scriptsDir, 'mosaic-sync-skills');
|
||||||
|
|
||||||
// Default: script exists and succeeds
|
// Default: current framework layout has tools/_scripts 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,10 +122,29 @@ 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();
|
||||||
@@ -165,7 +184,9 @@ 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.stringContaining('not found'));
|
expect(p.warn).toHaveBeenCalledWith(
|
||||||
|
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 () => {
|
||||||
|
|||||||
@@ -7,8 +7,21 @@ 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 = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets');
|
const script = frameworkScriptPath(mosaicHome, 'mosaic-link-runtime-assets');
|
||||||
if (existsSync(script)) {
|
if (existsSync(script)) {
|
||||||
try {
|
try {
|
||||||
spawnSync('bash', [script], {
|
spawnSync('bash', [script], {
|
||||||
@@ -48,7 +61,7 @@ function syncSkills(mosaicHome: string, selectedSkills: string[]): SyncSkillsRes
|
|||||||
return { success: true, installedCount: 0 };
|
return { success: true, installedCount: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const script = join(mosaicHome, 'bin', 'mosaic-sync-skills');
|
const script = frameworkScriptPath(mosaicHome, 'mosaic-sync-skills');
|
||||||
if (!existsSync(script)) {
|
if (!existsSync(script)) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
@@ -96,7 +109,7 @@ interface DoctorResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function runDoctor(mosaicHome: string): DoctorResult {
|
function runDoctor(mosaicHome: string): DoctorResult {
|
||||||
const script = join(mosaicHome, 'bin', 'mosaic-doctor');
|
const script = frameworkScriptPath(mosaicHome, 'mosaic-doctor');
|
||||||
if (!existsSync(script)) {
|
if (!existsSync(script)) {
|
||||||
return { warnings: 0, output: 'mosaic-doctor not found' };
|
return { warnings: 0, output: 'mosaic-doctor not found' };
|
||||||
}
|
}
|
||||||
@@ -149,11 +162,24 @@ 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,
|
||||||
): Promise<void> {
|
options: FinalizeStageOptions = {},
|
||||||
|
): Promise<FinalizeStageResult> {
|
||||||
p.separator();
|
p.separator();
|
||||||
|
|
||||||
const spin = p.spinner();
|
const spin = p.spinner();
|
||||||
@@ -200,6 +226,11 @@ 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
|
||||||
@@ -240,4 +271,11 @@ 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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ 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(() => {
|
||||||
@@ -167,6 +168,75 @@ 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');
|
||||||
|
|||||||
@@ -294,7 +294,12 @@ 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.
|
||||||
if (!opts.skipInstall && !daemonRunning) {
|
// MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1 forces a skip even without opts.skipInstall:
|
||||||
|
// 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,6 +511,9 @@ 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)',
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ 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';
|
||||||
@@ -57,9 +58,13 @@ 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, links runtime assets, syncs skills)
|
// Finalize writes configs/assets/skills, but defer the success summary until
|
||||||
await finalizeStage(prompter, state, configService);
|
// after the gateway health/bootstrap gates complete.
|
||||||
|
const finalizeResult = await finalizeStage(prompter, state, configService, {
|
||||||
|
deferSummary: true,
|
||||||
|
});
|
||||||
|
|
||||||
// Gateway config + bootstrap
|
// Gateway config + bootstrap
|
||||||
if (!options.skipGateway) {
|
if (!options.skipGateway) {
|
||||||
@@ -72,7 +77,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 ?? 'none',
|
providerType: state.providerType,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!configResult.ready || !configResult.host || !configResult.port) {
|
if (!configResult.ready || !configResult.host || !configResult.port) {
|
||||||
@@ -80,7 +85,9 @@ 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);
|
||||||
}
|
}
|
||||||
} else {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||||
host: configResult.host,
|
host: configResult.host,
|
||||||
port: configResult.port,
|
port: configResult.port,
|
||||||
@@ -88,11 +95,14 @@ 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,11 @@ 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',
|
||||||
@@ -137,14 +142,24 @@ function menuLabel(section: MenuChoice, completed: Set<MenuSection>): string {
|
|||||||
finish: 'Finish & Apply',
|
finish: 'Finish & Apply',
|
||||||
};
|
};
|
||||||
const base = labels[section];
|
const base = labels[section];
|
||||||
const sectionKey: MenuSection =
|
const sectionKey = menuSectionKey(section);
|
||||||
section === 'gateway-config' ? 'gateway' : (section as MenuSection);
|
if (sectionKey && completed.has(sectionKey)) {
|
||||||
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,
|
||||||
@@ -201,21 +216,25 @@ 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);
|
||||||
@@ -223,6 +242,7 @@ 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;
|
||||||
@@ -310,8 +330,11 @@ async function runFinishPath(
|
|||||||
await skillsSelectStage(prompter, state);
|
await skillsSelectStage(prompter, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finalize (writes configs, links runtime assets, syncs skills)
|
// Finalize writes configs/assets/skills, but defer the success summary until
|
||||||
await finalizeStage(prompter, state, configService);
|
// after the gateway health/bootstrap gates complete.
|
||||||
|
const finalizeResult = await finalizeStage(prompter, state, configService, {
|
||||||
|
deferSummary: true,
|
||||||
|
});
|
||||||
|
|
||||||
// Gateway stages
|
// Gateway stages
|
||||||
if (!options.skipGateway) {
|
if (!options.skipGateway) {
|
||||||
@@ -322,7 +345,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 ?? 'none',
|
providerType: state.providerType,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (configResult.ready && configResult.host && configResult.port) {
|
if (configResult.ready && configResult.host && configResult.port) {
|
||||||
@@ -333,12 +356,16 @@ 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,8 +401,11 @@ async function runHeadlessPath(
|
|||||||
// Skills
|
// Skills
|
||||||
await skillsSelectStage(prompter, state);
|
await skillsSelectStage(prompter, state);
|
||||||
|
|
||||||
// Finalize
|
// Finalize writes configs/assets/skills, but defer the success summary until
|
||||||
await finalizeStage(prompter, state, configService);
|
// after the gateway health/bootstrap gates complete.
|
||||||
|
const finalizeResult = await finalizeStage(prompter, state, configService, {
|
||||||
|
deferSummary: true,
|
||||||
|
});
|
||||||
|
|
||||||
// Gateway stages
|
// Gateway stages
|
||||||
if (!options.skipGateway) {
|
if (!options.skipGateway) {
|
||||||
@@ -386,13 +416,15 @@ async function runHeadlessPath(
|
|||||||
portOverride: options.gatewayPortOverride,
|
portOverride: options.gatewayPortOverride,
|
||||||
skipInstall: options.skipGatewayNpmInstall,
|
skipInstall: options.skipGatewayNpmInstall,
|
||||||
providerKey: state.providerKey,
|
providerKey: state.providerKey,
|
||||||
providerType: state.providerType ?? 'none',
|
providerType: state.providerType,
|
||||||
});
|
});
|
||||||
|
|
||||||
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);
|
||||||
} else {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||||
host: configResult.host,
|
host: configResult.host,
|
||||||
port: configResult.port,
|
port: configResult.port,
|
||||||
@@ -400,12 +432,15 @@ 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,8 +461,11 @@ async function runKeepPath(
|
|||||||
// Skills
|
// Skills
|
||||||
await skillsSelectStage(prompter, state);
|
await skillsSelectStage(prompter, state);
|
||||||
|
|
||||||
// Finalize
|
// Finalize writes configs/assets/skills, but defer the success summary until
|
||||||
await finalizeStage(prompter, state, configService);
|
// after the gateway health/bootstrap gates complete.
|
||||||
|
const finalizeResult = await finalizeStage(prompter, state, configService, {
|
||||||
|
deferSummary: true,
|
||||||
|
});
|
||||||
|
|
||||||
// Gateway stages
|
// Gateway stages
|
||||||
if (!options.skipGateway) {
|
if (!options.skipGateway) {
|
||||||
@@ -447,11 +485,15 @@ 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
222
tools/install-next-lane.test.sh
Executable file
222
tools/install-next-lane.test.sh
Executable file
@@ -0,0 +1,222 @@
|
|||||||
|
#!/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"
|
||||||
202
tools/install.sh
202
tools/install.sh
@@ -16,6 +16,10 @@
|
|||||||
# --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
|
||||||
@@ -31,6 +35,7 @@
|
|||||||
# 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)
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -49,7 +54,12 @@ 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
|
||||||
@@ -61,13 +71,24 @@ 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}"; shift 2 ;;
|
--ref) GIT_REF="${2:-main}"; GIT_REF_EXPLICIT=true; 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 ;;
|
||||||
@@ -75,12 +96,24 @@ 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"
|
||||||
|
|
||||||
@@ -95,6 +128,20 @@ 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.
|
||||||
@@ -158,7 +205,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="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)"
|
backup="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"
|
||||||
fi
|
fi
|
||||||
if [[ -n "$backup" ]] && [[ -f "$backup" ]]; then
|
if [[ -n "$backup" ]] && [[ -f "$backup" ]]; then
|
||||||
cp "$backup" "$dest"
|
cp "$backup" "$dest"
|
||||||
@@ -214,6 +261,22 @@ 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() {
|
||||||
@@ -236,10 +299,43 @@ 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];
|
||||||
@@ -332,8 +428,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="$(ls -1t "$out_dir"/mosaicstack-mosaic-*.tgz 2>/dev/null | head -1)"
|
cli_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-mosaic-*.tgz')"
|
||||||
gw_tgz="$(ls -1t "$out_dir"/mosaicstack-gateway-*.tgz 2>/dev/null | head -1)"
|
gw_tgz="$(newest_matching_file "$out_dir" 'mosaicstack-gateway-*.tgz')"
|
||||||
|
|
||||||
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."
|
||||||
@@ -355,6 +451,49 @@ 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
|
||||||
@@ -388,7 +527,7 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then
|
|||||||
else
|
else
|
||||||
dim " Installed: (none)"
|
dim " Installed: (none)"
|
||||||
fi
|
fi
|
||||||
dim " Source: ${REPO_BASE} (ref: ${GIT_REF})"
|
dim " Source: ${REPO_BASE} ($(source_ref_details))"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
if [[ "$FLAG_CHECK" == "true" ]]; then
|
if [[ "$FLAG_CHECK" == "true" ]]; then
|
||||||
@@ -455,8 +594,12 @@ 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
|
||||||
@@ -468,7 +611,19 @@ if [[ "$FLAG_CLI" == "true" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$FLAG_DEV" == "true" ]]; then
|
if [[ "$FLAG_DEV" == "true" ]]; then
|
||||||
dim " Source: ${REPO_BASE} (ref: ${GIT_REF}, build-from-source)"
|
dim " Source: ${REPO_BASE} ($(source_ref_details), 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
|
||||||
@@ -479,6 +634,12 @@ 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
|
||||||
@@ -495,6 +656,23 @@ 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"
|
||||||
@@ -603,7 +781,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="$(ls -1t "$dir/${base}.mosaic-bak-"* 2>/dev/null | head -1 || true)"
|
backup_path="$(newest_matching_file "$dir" "${base}.mosaic-bak-*")"
|
||||||
if [[ -n "$backup_path" ]]; then
|
if [[ -n "$backup_path" ]]; then
|
||||||
backup_val="\"$backup_path\""
|
backup_val="\"$backup_path\""
|
||||||
else
|
else
|
||||||
@@ -628,7 +806,7 @@ if [[ "$FLAG_CHECK" == "false" ]]; then
|
|||||||
NPMRC_LINES_JSON="[\"$MANIFEST_SCOPE_LINE\"]"
|
NPMRC_LINES_JSON="[\"$MANIFEST_SCOPE_LINE\"]"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
node -e "
|
if 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];
|
||||||
@@ -653,9 +831,11 @@ 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 \
|
"$RUNTIME_COPIES" 2>/dev/null; then
|
||||||
&& ok "Install manifest written: $MANIFEST_PATH" \
|
ok "Install manifest written: $MANIFEST_PATH"
|
||||||
|| warn "Could not write install manifest (non-fatal)"
|
else
|
||||||
|
warn "Could not write install manifest (non-fatal)"
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
ok "Done."
|
ok "Done."
|
||||||
|
|||||||
Reference in New Issue
Block a user