Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4df478cdd1 | ||
|
|
b8844e1ff0 | ||
|
|
906ad8dc30 | ||
|
|
5916aeefd6 | ||
|
|
58b971aba3 | ||
|
|
193331544d | ||
|
|
495f73bfdb | ||
|
|
b96cc7982a | ||
|
|
0883fb91ec | ||
|
|
56787fabf1 | ||
|
|
940ae3cc41 | ||
|
|
c25a551c28 | ||
|
|
94d6538061 | ||
|
|
a3c1ab923c | ||
|
|
838701bde2 |
+104
-5
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -361,7 +371,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 {
|
||||||
|
|||||||
@@ -72,13 +72,13 @@ const mockChatGateway = {
|
|||||||
broadcastSessionInfo: vi.fn(),
|
broadcastSessionInfo: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildService(): CommandExecutorService {
|
function buildService(redis: typeof mockRedis | null = mockRedis): CommandExecutorService {
|
||||||
return new CommandExecutorService(
|
return new CommandExecutorService(
|
||||||
mockRegistry as never,
|
mockRegistry as never,
|
||||||
mockAgentService as never,
|
mockAgentService as never,
|
||||||
mockSystemOverride as never,
|
mockSystemOverride as never,
|
||||||
mockSessionGC as never,
|
mockSessionGC as never,
|
||||||
mockRedis as never,
|
redis as never,
|
||||||
mockBrain as never,
|
mockBrain as never,
|
||||||
null,
|
null,
|
||||||
mockChatGateway as never,
|
mockChatGateway as never,
|
||||||
@@ -131,6 +131,22 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
expect(ttl).toBe(300);
|
expect(ttl).toBe(300);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('/provider login remains available without Redis on the local tier', async () => {
|
||||||
|
const localService = buildService(null);
|
||||||
|
const payload: SlashCommandPayload = {
|
||||||
|
command: 'provider',
|
||||||
|
args: 'login anthropic',
|
||||||
|
conversationId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await localService.execute(payload, userScope);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).not.toContain('token=');
|
||||||
|
expect(result.data).toEqual({ provider: 'anthropic' });
|
||||||
|
expect(mockRedis.set).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
// /provider with no args — returns usage
|
// /provider with no args — returns usage
|
||||||
it('/provider with no args returns usage message', async () => {
|
it('/provider with no args returns usage message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
||||||
|
|||||||
@@ -23,7 +23,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))
|
||||||
@@ -443,6 +446,7 @@ export class CommandExecutorService {
|
|||||||
byte.toString(16).padStart(2, '0'),
|
byte.toString(16).padStart(2, '0'),
|
||||||
).join('');
|
).join('');
|
||||||
const key = `mosaic:auth:poll:${tokenHash}`;
|
const key = `mosaic:auth:poll:${tokenHash}`;
|
||||||
|
if (this.redis) {
|
||||||
// Persist only a short-lived token digest. The raw token is delivered only by
|
// Persist only a short-lived token digest. The raw token is delivered only by
|
||||||
// the authenticated dashboard flow, never in chat output or command metadata.
|
// the authenticated dashboard flow, never in chat output or command metadata.
|
||||||
await this.redis.set(
|
await this.redis.set(
|
||||||
@@ -451,6 +455,7 @@ export class CommandExecutorService {
|
|||||||
'EX',
|
'EX',
|
||||||
300,
|
300,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
command: 'provider',
|
command: 'provider',
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -16,13 +18,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,
|
||||||
@@ -38,9 +44,13 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,19 @@ describe('SessionGCService', () => {
|
|||||||
).resolves.toEqual({ allowed: true });
|
).resolves.toEqual({ allowed: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('collect() skips Valkey but still demotes only the requested session on local tier', async () => {
|
||||||
|
const localService = new SessionGCService(null, mockLogService as unknown as LogService);
|
||||||
|
|
||||||
|
const result = await localService.collect('local-session');
|
||||||
|
|
||||||
|
expect(result.sessionId).toBe('local-session');
|
||||||
|
expect(result.cleaned.valkeyKeys).toBeUndefined();
|
||||||
|
expect(mockLogService.logs.promoteSessionToWarm).toHaveBeenCalledWith(
|
||||||
|
'local-session',
|
||||||
|
expect.any(Date),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('collect() returns sessionId in result', async () => {
|
it('collect() returns sessionId in result', async () => {
|
||||||
const result = await service.collect('test-session-id');
|
const result = await service.collect('test-session-id');
|
||||||
expect(result.sessionId).toBe('test-session-id');
|
expect(result.sessionId).toBe('test-session-id');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable, Optional } 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';
|
||||||
@@ -21,7 +21,10 @@ function escapeRedisGlobLiteral(value: string): string {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class SessionGCService {
|
export class SessionGCService {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(REDIS) private readonly redis: QueueHandle['redis'],
|
// Local tier has no Redis; lifecycle cleanup still demotes this session's logs.
|
||||||
|
@Optional()
|
||||||
|
@Inject(REDIS)
|
||||||
|
private readonly redis: QueueHandle['redis'] | null,
|
||||||
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
@Inject(LOG_SERVICE) private readonly logService: LogService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -29,8 +32,10 @@ export class SessionGCService {
|
|||||||
* 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 an empty population on the Local tier where Redis is disabled.
|
||||||
*/
|
*/
|
||||||
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 {
|
||||||
@@ -47,13 +52,15 @@ 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:${escapeRedisGlobLiteral(sessionId)}:*`;
|
const pattern = `mosaic:session:${escapeRedisGlobLiteral(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 only.
|
||||||
const cutoff = new Date();
|
const cutoff = new Date();
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import type { MosaicJobData } from '../queue/queue.service.js';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class CronService implements OnModuleInit, OnModuleDestroy {
|
export class CronService implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly logger = new Logger(CronService.name);
|
private readonly logger = new Logger(CronService.name);
|
||||||
private readonly registeredWorkers: Worker<MosaicJobData>[] = [];
|
private readonly registeredWorkers: Array<Worker<MosaicJobData>> = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
@Inject(SummarizationService) private readonly summarization: SummarizationService,
|
||||||
@@ -26,6 +26,12 @@ export class CronService implements OnModuleInit, OnModuleDestroy {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
|
// Local tier deliberately has no BullMQ consumers or repeatable jobs.
|
||||||
|
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
|
||||||
|
|
||||||
@@ -39,7 +45,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,7 +57,7 @@ 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.
|
// Retire any repeatable global GC schedule created by older deployments.
|
||||||
// Session cleanup is now triggered only by an authorized session lifecycle operation.
|
// Session cleanup is now triggered only by an authorized session lifecycle operation.
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { MosaicConfig } from '@mosaicstack/config';
|
||||||
|
import { SystemOverrideService } from './system-override.service.js';
|
||||||
|
|
||||||
|
const localConfig = { queue: { type: 'local' } } as MosaicConfig;
|
||||||
|
|
||||||
|
describe('SystemOverrideService local tier', () => {
|
||||||
|
it('keeps ephemeral overrides isolated by tenant and user scope', async () => {
|
||||||
|
const service = new SystemOverrideService(localConfig);
|
||||||
|
const firstScope = { tenantId: 'tenant-a', userId: 'user-a' };
|
||||||
|
const secondScope = { tenantId: 'tenant-b', userId: 'user-b' };
|
||||||
|
|
||||||
|
await service.set('shared-session', 'first override', firstScope);
|
||||||
|
await service.set('shared-session', 'second override', secondScope);
|
||||||
|
|
||||||
|
await expect(service.get('shared-session', firstScope)).resolves.toBe('first override');
|
||||||
|
await expect(service.get('shared-session', secondScope)).resolves.toBe('second override');
|
||||||
|
|
||||||
|
await service.clear('shared-session', firstScope);
|
||||||
|
await expect(service.get('shared-session', firstScope)).resolves.toBeNull();
|
||||||
|
await expect(service.get('shared-session', secondScope)).resolves.toBe('second override');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
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 type { ActorTenantScope } from '../auth/session-scope.js';
|
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||||
|
import { MOSAIC_CONFIG } from '../config/config.module.js';
|
||||||
|
|
||||||
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
|
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
|
||||||
`${scope.tenantId}:${scope.userId}:${sessionId}`;
|
`${scope.tenantId}:${scope.userId}:${sessionId}`;
|
||||||
@@ -15,16 +17,45 @@ 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()
|
||||||
this.handle = createQueue();
|
export class SystemOverrideService implements OnApplicationShutdown {
|
||||||
|
private readonly logger = new Logger(SystemOverrideService.name);
|
||||||
|
private readonly handle: QueueHandle | null;
|
||||||
|
/** Local-tier fallback, keyed by the same tenant/user/session scope as Redis. */
|
||||||
|
private readonly localStore = new Map<string, LocalOverrideEntry>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Optional()
|
||||||
|
@Inject(MOSAIC_CONFIG)
|
||||||
|
private readonly mosaicConfig: MosaicConfig | null,
|
||||||
|
) {
|
||||||
|
this.handle = this.mosaicConfig?.queue?.type === 'local' ? null : createQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onApplicationShutdown(): Promise<void> {
|
||||||
|
await this.handle?.close().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
|
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
|
||||||
|
if (!this.handle) {
|
||||||
|
const key = scopedSessionId(sessionId, scope);
|
||||||
|
const entry = this.localStore.get(key) ?? { condensed: '', fragments: [] };
|
||||||
|
entry.fragments.push({ text: override, addedAt: Date.now() });
|
||||||
|
entry.condensed = await this.condenseOverrides(
|
||||||
|
entry.fragments.map((fragment) => fragment.text),
|
||||||
|
);
|
||||||
|
this.localStore.set(key, 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, scope));
|
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
|
||||||
const fragments: OverrideFragment[] = existing
|
const fragments: OverrideFragment[] = existing
|
||||||
@@ -54,10 +85,14 @@ export class SystemOverrideService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
|
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
|
||||||
|
if (!this.handle) {
|
||||||
|
return this.localStore.get(scopedSessionId(sessionId, scope))?.condensed ?? null;
|
||||||
|
}
|
||||||
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
|
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
|
||||||
}
|
}
|
||||||
|
|
||||||
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||||
|
if (!this.handle) return;
|
||||||
const pipeline = this.handle.redis.pipeline();
|
const pipeline = this.handle.redis.pipeline();
|
||||||
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||||
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||||
@@ -65,6 +100,11 @@ export class SystemOverrideService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||||
|
if (!this.handle) {
|
||||||
|
this.localStore.delete(scopedSessionId(sessionId, scope));
|
||||||
|
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, scope),
|
SESSION_SYSTEM_KEY(sessionId, scope),
|
||||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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.removeRepeatableJobs('mosaic-test', 'local-noop')).resolves.toBe(0);
|
||||||
|
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 },
|
||||||
@@ -167,7 +199,14 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
* safe retirement of previously registered system-wide jobs.
|
* safe retirement of previously registered system-wide jobs.
|
||||||
*/
|
*/
|
||||||
async removeRepeatableJobs(queueName: string, jobName: string): Promise<number> {
|
async removeRepeatableJobs(queueName: string, jobName: string): Promise<number> {
|
||||||
|
if (!this.enabled) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Skipping repeatable-job removal for "${jobName}" on "${queueName}" (local tier — BullMQ disabled)`,
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
const queue = this.getQueue(queueName);
|
const queue = this.getQueue(queueName);
|
||||||
|
if (!queue) return 0;
|
||||||
const jobs = await queue.getRepeatableJobs();
|
const jobs = await queue.getRepeatableJobs();
|
||||||
const matchingJobs = jobs.filter((job) => job.name === jobName);
|
const matchingJobs = jobs.filter((job) => job.name === jobName);
|
||||||
await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key)));
|
await Promise.all(matchingJobs.map((job) => queue.removeRepeatableByKey(job.key)));
|
||||||
@@ -182,8 +221,18 @@ export class QueueService implements OnModuleInit, OnModuleDestroy {
|
|||||||
/**
|
/**
|
||||||
* Register a Worker for the given queue name with error handling and
|
* Register a Worker for the given queue name with error handling and
|
||||||
* exponential backoff.
|
* exponential backoff.
|
||||||
|
* Returns null on Local tier where BullMQ is disabled.
|
||||||
*/
|
*/
|
||||||
registerWorker<T extends MosaicJobData>(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 +289,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 +325,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 +355,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 +390,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 +402,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();
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -195,6 +195,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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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/[email protected]` and `@mosaicstack/[email protected]`.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# RM-03 — CI Queue Guard Repair
|
||||||
|
|
||||||
|
- **Task:** RM-03
|
||||||
|
- **Issue:** #1019
|
||||||
|
- **Branch:** `fix/rm-03-queue-guard`
|
||||||
|
- **Owner:** coder-mos1
|
||||||
|
- **Reviewer:** rev-974 (independent; author != reviewer)
|
||||||
|
- **Started:** 2026-08-01
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Repair the mandatory CI queue guard so it reads provider payloads, blocks asserted non-green CI, distinguishes provider unavailability from a real non-green result, and inspects the branch actually being pushed or merged.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Worktree only: `/home/hermes/agent-work/rm-03`; never mutate `/src/mosaic-stack`.
|
||||||
|
- JSON payload travels through stdin; never argv. Large payload must remain below no ARG_MAX dependency.
|
||||||
|
- TDD is mandatory. Every behavior case must be observed red before implementation.
|
||||||
|
- No bypass flags or hook suppression.
|
||||||
|
- Do not cite the existing guard's green as evidence; D-23 establishes it is zero-information.
|
||||||
|
- Gate-ready is a frozen exact head. Any push after a merge-gate verdict voids that verdict.
|
||||||
|
- No merge: coordinator holds the merge hand pending Jason.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
1. Feed JSON to `python3 -c` on stdin, including pending-context rendering.
|
||||||
|
2. Classify valid green as `READY`; pending/failure/no-status/malformed/mixed as `ASSERTED_NOT_READY`; provider/credential/transport inability as `CANNOT_ASSERT`.
|
||||||
|
3. `ASSERTED_NOT_READY` exits nonzero. `CANNOT_ASSERT` emits a loud diagnostic and appends a local JSONL audit record. Push degrades to exit 0; merge holds with distinct retryable exit 75 until provider recovery, then self-clears without manual reset. Inability to write the audit exits nonzero.
|
||||||
|
4. Derive the current branch when `-B` is omitted. The merge wrapper passes the exact PR head branch, repository, and full commit SHA—not its `main` base—so fork PRs cannot resolve against an adjacent base-repository branch.
|
||||||
|
|
||||||
|
## Test matrix
|
||||||
|
|
||||||
|
| Case | Required outcome |
|
||||||
|
| --- | --- |
|
||||||
|
| success | exit 0; terminal-success |
|
||||||
|
| pending | nonzero after bounded timeout |
|
||||||
|
| failure | nonzero |
|
||||||
|
| no-status | nonzero |
|
||||||
|
| malformed | nonzero |
|
||||||
|
| >=150 KiB payload | unchanged classification; never rc126 |
|
||||||
|
| provider unreachable on push | loud audited CANNOT_ASSERT; degraded exit 0 |
|
||||||
|
| provider unreachable on merge | loud audited CANNOT_ASSERT; retryable exit 75/HOLD |
|
||||||
|
| audit unavailable | nonzero |
|
||||||
|
| implicit push branch | provider URL uses checked-out feature branch |
|
||||||
|
| merge wrapper | queue guard receives exact PR head branch/repository/full SHA |
|
||||||
|
|
||||||
|
## RED-first evidence
|
||||||
|
|
||||||
|
Observed against the unmodified `origin/main` implementation before source edits:
|
||||||
|
|
||||||
|
- `bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` → rc 1 with 15 failed assertions.
|
||||||
|
- Success payload was reported `state=unknown`.
|
||||||
|
- Pending, failure, no-status, and malformed payloads each exited 0 and omitted `ASSERTED_NOT_READY`.
|
||||||
|
- The 160 KiB payload produced rc 141 because Python never consumed the pipe; it did not classify success.
|
||||||
|
- Provider-unreachable exited 7 with no `CANNOT_ASSERT` audit record.
|
||||||
|
- Implicit push queried `/branches/main`, not `/branches/fix/rm-03-fixture`.
|
||||||
|
- Audit-unavailable emitted no audit diagnostic.
|
||||||
|
- A credential-resolution hard-block mutant was then run before trusting that added case: `credential-unresolvable` returned rc 1 and omitted `CANNOT_ASSERT`; the matrix returned rc 1 with two named assertion failures.
|
||||||
|
- Review-blocker controls were observed red: structurally invalid `statuses` string and null-entry payloads each exited 0 as `terminal-success`; unsupported-platform discovery exited 1 without diagnostic or audit (seven named assertion failures total).
|
||||||
|
- After the push/merge asymmetry ruling, merge-side provider unavailability was observed red at rc 0; its registered case required distinct retryable rc 75.
|
||||||
|
- Aggregate `state=success` with zero contexts was observed red: it exited 0 as `terminal-success`; the registered case requires `no-status`/nonzero.
|
||||||
|
- Fork/exact-head controls were observed red: `pr-merge.sh` omitted the fork repository and full SHA, and an ignored-arguments mutant re-resolved through `/branches/` instead of the exact fork commit (two named failures).
|
||||||
|
- GitHub check-run-only success/pending/failure were each misclassified as `no-status`; the RED run had five named failures and proved the Checks API was never queried.
|
||||||
|
- The first merge-pin control was unrunnable because one `local` declaration referenced a variable before assignment under `set -u`; this was disclosed and corrected rather than counted. The runnable RED then showed Gitea payload `{"Do":"squash"}` lacked `head_commit_id`; a separate GitHub run showed `gh pr merge 123 --squash` lacked `--match-head-commit`.
|
||||||
|
- A stale-verdict mutant removed the `--expect-head` comparison and was observed red because a moved head reached the provider merge call.
|
||||||
|
- `bash packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh` initially returned rc 1; captured call was `--purpose merge -B main -t 900 -i 15`.
|
||||||
|
|
||||||
|
Logs remain untracked under the worktree as `.mosaic-test-work-red-*.log` and will not be committed.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- [x] Mission, remediation charter, task evidence, board, issue #1019, and superseded PR #1023 read.
|
||||||
|
- [x] Isolated worktree created and identity configured coherently.
|
||||||
|
- [x] Mutant tests authored and observed red.
|
||||||
|
- [x] Implementation green.
|
||||||
|
- [x] Baseline and focused situational gates green; full package suite has an unrelated framework-shell environment abort recorded below.
|
||||||
|
- [ ] Independent review clean (rev-974 requested changes at `44ffa99a`; bypass remediation committed and awaiting re-review).
|
||||||
|
- [ ] PR CI terminal-green at exact head by full step scan.
|
||||||
|
- [ ] Merge-gate verdict issued against frozen head.
|
||||||
|
|
||||||
|
## Scope disposition
|
||||||
|
|
||||||
|
- The five framework guides are consequential documentation: they define the purpose-aware tri-state contract, including audited push degradation and merge HOLD.
|
||||||
|
- The agent templates are consequential because they ship the same queue-guard instructions into newly seeded agent contracts; leaving them binary/stale would contradict the repaired tool.
|
||||||
|
- `pr-merge.sh` is consequential: it must inspect the PR's exact head branch/repository/SHA and enforce the exact-head merge pin.
|
||||||
|
- `pr-metadata.sh` is consequential only as the normalized source of that head branch/repository/SHA. Its diff is limited to exposing those fields on GitHub and Gitea.
|
||||||
|
- `test-pr-merge-gitea-empty-uid.sh` changes because exact-head Gitea merges now always use the API path (the only path that can send `head_commit_id`), superseding the prior tea-empty-identity fallback behavior.
|
||||||
|
|
||||||
|
## Review remediation
|
||||||
|
|
||||||
|
- rev-974 independently proved that the documented `--skip-queue-guard` merge option bypassed an exit-99 guard stub, reached the provider merge payload, printed success, and exited 0 at head `44ffa99a`.
|
||||||
|
- RED-first reproduction was added to `test-pr-merge-head-pin.sh` before the production fix: `FAIL merge-bypass: --skip-queue-guard reached the provider merge path`, suite rc 1. The test-only commit is `241113e6`.
|
||||||
|
- Production remediation `37aae650` removes the option from parsing, usage, help, and examples. Every merge-capable path now invokes the queue guard; `--dry-run` alone omits it and has a regression proving that it exits before provider dispatch and creates no merge payload.
|
||||||
|
- Existing Gitea merge tests now exercise a successful guard response rather than bypassing the guard.
|
||||||
|
|
||||||
|
## Risks / boundaries
|
||||||
|
|
||||||
|
- The local JSONL audit is durable operational evidence but not tamper-resistant against the same UID. RM-03 does not claim otherwise.
|
||||||
|
- Push-side audited exit 0 is an explicit owner ruling (Option B), accepted to avoid bricking recovery work; merge-side CANNOT_ASSERT remains retryable exit 75/HOLD. The automated security reviewer continues to flag the deliberate push availability tradeoff.
|
||||||
|
- Source/deployed-copy equality is owned by RM-02/D-22; this branch changes repository source and its tests only.
|
||||||
|
|
||||||
|
## Test evidence
|
||||||
|
|
||||||
|
Fresh after rescue checkpoint `b7175012`:
|
||||||
|
|
||||||
|
- Focused situational matrix: tri-state, GitHub checks pagination, branch-absent, merge head branch/repository/SHA, exact-head pin, and Gitea exact-head API regressions all passed.
|
||||||
|
- `bash -n` on the three production shell scripts passed.
|
||||||
|
- `shellcheck -x -P packages/mosaic/framework/tools/git ...` on all changed shell scripts passed.
|
||||||
|
- `pnpm typecheck` passed (45/45 Turbo tasks).
|
||||||
|
- `pnpm lint` passed (25/25 Turbo tasks).
|
||||||
|
- `pnpm format:check` passed.
|
||||||
|
- `pnpm --filter @mosaicstack/mosaic test`: Vitest passed 1508/1508 on the confirmation run; framework-shell then aborted at the pre-existing wake coordinate assertion with exit 97: `BASH_LINENO ... probe reported [3 5], expected [3 4] ... (#973)`. This is outside the RM-03 diff and is disclosed rather than substituted or called green.
|
||||||
|
- The prior package-suite attempt had one transient, out-of-diff `install-ordering-guard.spec.ts` failure (1/1508); its isolated rerun passed 19/19 and the confirmation full Vitest run passed 1508/1508.
|
||||||
|
- After bypass remediation: all six focused RM-03 queue/merge regressions passed, including bypass refusal and dry-run non-dispatch; shell syntax and source-aware ShellCheck passed; `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` passed.
|
||||||
|
- Fresh `test:framework-shell` reached and passed every RM-03 test, then again aborted at the unrelated wake coordinate assertion with exit 97; it remains explicitly non-green rather than substituted.
|
||||||
|
- An ad hoc raw Prettier invocation over `.template` and `.sh` files was unrunnable because no parser is registered for those extensions; it was not used as a substitute for canonical `pnpm format:check`.
|
||||||
|
|
||||||
|
## Final evidence
|
||||||
|
|
||||||
|
Pending.
|
||||||
+2
-1
@@ -10,7 +10,8 @@
|
|||||||
"clean:generated": "node scripts/clean-generated.mjs",
|
"clean:generated": "node scripts/clean-generated.mjs",
|
||||||
"typecheck": "pnpm preflight && turbo run typecheck",
|
"typecheck": "pnpm preflight && turbo run typecheck",
|
||||||
"test:checkout": "node --test scripts/*.test.mjs",
|
"test:checkout": "node --test scripts/*.test.mjs",
|
||||||
"test": "pnpm test:checkout && turbo run test",
|
"test": "pnpm test:checkout && 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": "node scripts/install-hooks.mjs"
|
"prepare": "node scripts/install-hooks.mjs"
|
||||||
|
|||||||
@@ -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)
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
|
The installer rejects unrecognized flags or positional arguments before making changes and prints the supported-option usage.
|
||||||
@@ -185,6 +197,7 @@ The installer syncs skills from `mosaic/agent-skills` into `~/.config/mosaic/ski
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
mosaic sync # Full canonical catalog sync
|
mosaic sync # Full canonical catalog sync
|
||||||
|
~/.config/mosaic/tools/_scripts/mosaic-sync-skills --link-only # Re-link only
|
||||||
mosaic skill list # Show registered, missing, dangling, and foreign entries
|
mosaic skill list # Show registered, missing, dangling, and foreign entries
|
||||||
mosaic skill register <name> # Register or repair one canonical Claude link
|
mosaic skill register <name> # Register or repair one canonical Claude link
|
||||||
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
|
mosaic skill unregister <name> # Remove one Mosaic-owned Claude link
|
||||||
@@ -198,7 +211,7 @@ M1 lifecycle management targets Claude Code. Pi can discover the canonical Mosai
|
|||||||
|
|
||||||
```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
|
||||||
@@ -209,8 +222,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
|
||||||
|
|||||||
@@ -925,14 +925,16 @@ Woodpecker note:
|
|||||||
Before pushing a branch or merging a PR, guard against overlapping project pipelines:
|
Before pushing a branch or merging a PR, guard against overlapping project pipelines:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main
|
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push
|
||||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main
|
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>
|
||||||
```
|
```
|
||||||
|
|
||||||
Behavior:
|
Behavior:
|
||||||
|
|
||||||
- If pipeline state is running/queued/pending, wait until queue clears.
|
- If pipeline state is running/queued/pending, wait until queue clears; timeout is `ASSERTED_NOT_READY` and exits nonzero.
|
||||||
- If timeout or API/auth failure occurs, treat as `blocked`, report exact failed wrapper command, and stop.
|
- Failure, missing status, malformed status, or any other provider-asserted non-green state is `ASSERTED_NOT_READY` and exits nonzero.
|
||||||
|
- Credential, transport, or provider unavailability is `CANNOT_ASSERT`: the guard emits a loud diagnostic and durable JSONL audit record. For push it exits 0 so recovery work is not bricked. For merge it returns distinct retryable exit 75 and holds until provider recovery; rerunning then self-clears without manual reset. This result is never evidence that CI was clear. If the audit cannot be written, the guard exits nonzero.
|
||||||
|
- `pr-merge.sh` resolves and guards the exact PR head repository and full SHA automatically, including fork PRs.
|
||||||
|
|
||||||
## Gitea as Unified Platform
|
## Gitea as Unified Platform
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Merge strategy enforcement (HARD RULE):
|
|||||||
- PR target for delivery is `main`.
|
- PR target for delivery is `main`.
|
||||||
- Direct pushes to `main` are prohibited.
|
- Direct pushes to `main` are prohibited.
|
||||||
- Merge to `main` MUST be squash-only.
|
- Merge to `main` MUST be squash-only.
|
||||||
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash` (or PowerShell equivalent).
|
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` (or PowerShell equivalent).
|
||||||
|
|
||||||
## Review Checklist
|
## Review Checklist
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ For implementation work, you MUST run this cycle in order:
|
|||||||
8. `pre-push queue guard` - before pushing, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
8. `pre-push queue guard` - before pushing, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||||
9. `push` - push immediately after queue guard passes.
|
9. `push` - push immediately after queue guard passes.
|
||||||
10. `PR integration` - if external git provider is available, create/update PR to `main` and merge with required strategy via Mosaic wrappers.
|
10. `PR integration` - if external git provider is available, create/update PR to `main` and merge with required strategy via Mosaic wrappers.
|
||||||
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge`.
|
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines on the exact PR head to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||||
12. `CI/pipeline verification` - wait for terminal CI status and require green before completion (`~/.config/mosaic/tools/git/pr-ci-wait.sh` for PR-based workflow).
|
12. `CI/pipeline verification` - wait for terminal CI status and require green before completion (`~/.config/mosaic/tools/git/pr-ci-wait.sh` for PR-based workflow).
|
||||||
13. `issue closure` - close linked external issue (or close internal `docs/TASKS.md` task ref when provider is unavailable).
|
13. `issue closure` - close linked external issue (or close internal `docs/TASKS.md` task ref when provider is unavailable).
|
||||||
14. `greenfield situational test` - validate required user flows in a clean environment/startup path (post-merge for trunk workflow changes).
|
14. `greenfield situational test` - validate required user flows in a clean environment/startup path (post-merge for trunk workflow changes).
|
||||||
@@ -93,8 +93,8 @@ For implementation work, you MUST run this cycle in order:
|
|||||||
> the gate (AGENTS.md hard gate "Merge authority"). Solo delivery proceeds
|
> the gate (AGENTS.md hard gate "Merge authority"). Solo delivery proceeds
|
||||||
> without asking.
|
> without asking.
|
||||||
|
|
||||||
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
|
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
|
||||||
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash`
|
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash --expect-head <APPROVED_FULL_SHA>`
|
||||||
3. `~/.config/mosaic/tools/git/pr-ci-wait.sh -n <PR_NUMBER>`
|
3. `~/.config/mosaic/tools/git/pr-ci-wait.sh -n <PR_NUMBER>`
|
||||||
4. `~/.config/mosaic/tools/git/issue-close.sh -i <ISSUE_NUMBER>` (or close internal `docs/TASKS.md` ref when no provider exists)
|
4. `~/.config/mosaic/tools/git/issue-close.sh -i <ISSUE_NUMBER>` (or close internal `docs/TASKS.md` ref when no provider exists)
|
||||||
5. If any step fails: set status `blocked`, report the exact failed wrapper command, and stop.
|
5. If any step fails: set status `blocked`, report the exact failed wrapper command, and stop.
|
||||||
|
|||||||
@@ -425,11 +425,11 @@ git push
|
|||||||
and checklist completed (`~/.config/mosaic/templates/docs/DOCUMENTATION-CHECKLIST.md`) when applicable.
|
and checklist completed (`~/.config/mosaic/templates/docs/DOCUMENTATION-CHECKLIST.md`) when applicable.
|
||||||
13. **PR + CI + Issue Closure Gate** (HARD RULE for source-code tasks):
|
13. **PR + CI + Issue Closure Gate** (HARD RULE for source-code tasks):
|
||||||
- Before merging, run queue guard:
|
- Before merging, run queue guard:
|
||||||
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
|
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
|
||||||
- Ensure PR exists for the task branch (create/update via wrappers if needed):
|
- Ensure PR exists for the task branch (create/update via wrappers if needed):
|
||||||
`~/.config/mosaic/tools/git/pr-create.sh ... -B main`
|
`~/.config/mosaic/tools/git/pr-create.sh ... -B main`
|
||||||
- Merge via wrapper:
|
- Merge via wrapper:
|
||||||
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
|
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
|
||||||
- Wait for terminal CI status:
|
- Wait for terminal CI status:
|
||||||
`~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
`~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
||||||
- Close linked issue after merge + green CI:
|
- Close linked issue after merge + green CI:
|
||||||
@@ -630,7 +630,7 @@ Construct this from the task row and pass to worker via Task tool:
|
|||||||
|
|
||||||
**MANDATORY:** This ALWAYS includes linting. If the project has a linter configured
|
**MANDATORY:** This ALWAYS includes linting. If the project has a linter configured
|
||||||
(ESLint, Biome, ruff, etc.), you MUST run it and fix ALL violations in files you touched.
|
(ESLint, Biome, ruff, etc.), you MUST run it and fix ALL violations in files you touched.
|
||||||
Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below)
|
Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {branch}` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below)
|
||||||
|
|
||||||
## Git Scripts
|
## Git Scripts
|
||||||
|
|
||||||
@@ -638,8 +638,9 @@ For issue/PR/milestone operations, use scripts (NOT raw tea/gh):
|
|||||||
|
|
||||||
- `~/.config/mosaic/tools/git/issue-view.sh -i {N}`
|
- `~/.config/mosaic/tools/git/issue-view.sh -i {N}`
|
||||||
- `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B main`
|
- `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B main`
|
||||||
- `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`
|
- Push: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {task_branch}`
|
||||||
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
|
- Merge: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B {pr_head_branch} -R {pr_head_owner/repo} --sha {pr_head_full_sha}`
|
||||||
|
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
|
||||||
- `~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
- `~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
|
||||||
- `~/.config/mosaic/tools/git/issue-close.sh -i {N}`
|
- `~/.config/mosaic/tools/git/issue-close.sh -i {N}`
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection a
|
|||||||
# Milestones
|
# Milestones
|
||||||
~/.config/mosaic/tools/git/milestone-create.sh
|
~/.config/mosaic/tools/git/milestone-create.sh
|
||||||
|
|
||||||
# CI queue guard (required before push/merge)
|
# CI queue guard (required before push/merge; defaults to the checked-out branch)
|
||||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
|
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
|
||||||
|
|
||||||
### Code Review (Codex)
|
### Code Review (Codex)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||||
@@ -88,7 +88,7 @@ Reference:
|
|||||||
5. Do not mark implementation complete until PR is merged.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
7. Close linked issues/tasks only after merge + green CI.
|
||||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## Container Release Strategy (When Applicable)
|
||||||
|
|
||||||
|
|||||||
@@ -147,9 +147,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
|||||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||||
@@ -97,7 +97,7 @@ Reference:
|
|||||||
5. Do not mark implementation complete until PR is merged.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
7. Close linked issues/tasks only after merge + green CI.
|
||||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||||
|
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## Container Release Strategy (When Applicable)
|
||||||
|
|||||||
@@ -198,9 +198,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
|||||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||||
@@ -101,7 +101,7 @@ Reference:
|
|||||||
5. Do not mark implementation complete until PR is merged.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
7. Close linked issues/tasks only after merge + green CI.
|
||||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||||
|
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## Container Release Strategy (When Applicable)
|
||||||
|
|||||||
@@ -230,9 +230,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
|||||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||||
|
|||||||
+2
-2
@@ -9,7 +9,7 @@
|
|||||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||||
@@ -87,7 +87,7 @@ Reference:
|
|||||||
5. Do not mark implementation complete until PR is merged.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
7. Close linked issues/tasks only after merge + green CI.
|
||||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## Container Release Strategy (When Applicable)
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -146,9 +146,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
|||||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||||
|
|||||||
+2
-2
@@ -9,7 +9,7 @@
|
|||||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||||
@@ -84,7 +84,7 @@ Reference:
|
|||||||
5. Do not mark implementation complete until PR is merged.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
7. Close linked issues/tasks only after merge + green CI.
|
||||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## Container Release Strategy (When Applicable)
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -136,9 +136,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
|||||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||||
3. Completion is forbidden at PR-open stage.
|
3. Completion is forbidden at PR-open stage.
|
||||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||||
@@ -85,7 +85,7 @@ Reference:
|
|||||||
5. Do not mark implementation complete until PR is merged.
|
5. Do not mark implementation complete until PR is merged.
|
||||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||||
7. Close linked issues/tasks only after merge + green CI.
|
7. Close linked issues/tasks only after merge + green CI.
|
||||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
|
||||||
|
|
||||||
## Container Release Strategy (When Applicable)
|
## Container Release Strategy (When Applicable)
|
||||||
|
|
||||||
|
|||||||
@@ -133,9 +133,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
|||||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
|
||||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
|
||||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ set -euo pipefail
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
source "$SCRIPT_DIR/detect-platform.sh"
|
source "$SCRIPT_DIR/detect-platform.sh"
|
||||||
|
|
||||||
BRANCH="main"
|
BRANCH=""
|
||||||
|
TARGET_REPO=""
|
||||||
|
HEAD_SHA=""
|
||||||
TIMEOUT_SEC=900
|
TIMEOUT_SEC=900
|
||||||
INTERVAL_SEC=15
|
INTERVAL_SEC=15
|
||||||
PURPOSE="merge"
|
PURPOSE="merge"
|
||||||
@@ -15,10 +17,12 @@ REQUIRE_STATUS=0
|
|||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
Usage: $(basename "$0") [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-B, --branch BRANCH Branch head to inspect (default: main)
|
-B, --branch BRANCH Branch head to inspect (default: current branch)
|
||||||
|
-R, --repo OWNER/REPO Repository containing the branch (default: origin repo)
|
||||||
|
--sha FULL_SHA Inspect this exact 40-character commit instead of resolving the branch
|
||||||
-t, --timeout SECONDS Max wait time in seconds (default: 900)
|
-t, --timeout SECONDS Max wait time in seconds (default: 900)
|
||||||
-i, --interval SECONDS Poll interval in seconds (default: 15)
|
-i, --interval SECONDS Poll interval in seconds (default: 15)
|
||||||
--purpose VALUE Log context: push|merge (default: merge)
|
--purpose VALUE Log context: push|merge (default: merge)
|
||||||
@@ -27,63 +31,65 @@ Options:
|
|||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
$(basename "$0")
|
$(basename "$0")
|
||||||
$(basename "$0") --purpose push -B main -t 600 -i 10
|
$(basename "$0") --purpose push -t 600 -i 10
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
get_state_from_status_json() {
|
get_state_from_status_json() {
|
||||||
python3 - <<'PY'
|
# Python source comes from -c so the provider payload remains on stdin.
|
||||||
|
# Never move the payload to argv: commit-status responses can exceed ARG_MAX.
|
||||||
|
python3 -c '
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payload = json.load(sys.stdin)
|
payload = json.load(sys.stdin)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError("status payload is not an object")
|
||||||
except Exception:
|
except Exception:
|
||||||
print("unknown")
|
print("malformed")
|
||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
|
|
||||||
statuses = payload.get("statuses") or []
|
raw_statuses = payload.get("statuses", [])
|
||||||
state = (payload.get("state") or "").lower()
|
raw_state = payload.get("state", "")
|
||||||
|
if not isinstance(raw_statuses, list) or not isinstance(raw_state, str):
|
||||||
|
print("malformed")
|
||||||
|
raise SystemExit(0)
|
||||||
|
statuses = raw_statuses
|
||||||
|
state = raw_state.lower()
|
||||||
|
|
||||||
pending_values = {"pending", "queued", "running", "waiting"}
|
pending_values = {"pending", "queued", "running", "waiting"}
|
||||||
failure_values = {"failure", "error", "failed"}
|
failure_values = {"failure", "error", "failed"}
|
||||||
success_values = {"success"}
|
success_values = {"success"}
|
||||||
|
|
||||||
if state in pending_values:
|
|
||||||
print("pending")
|
|
||||||
raise SystemExit(0)
|
|
||||||
if state in failure_values:
|
|
||||||
print("terminal-failure")
|
|
||||||
raise SystemExit(0)
|
|
||||||
if state in success_values:
|
|
||||||
print("terminal-success")
|
|
||||||
raise SystemExit(0)
|
|
||||||
|
|
||||||
values = []
|
values = []
|
||||||
for item in statuses:
|
for item in statuses:
|
||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
print("malformed")
|
||||||
value = (item.get("status") or item.get("state") or "").lower()
|
raise SystemExit(0)
|
||||||
if value:
|
raw_value = item.get("status") or item.get("state")
|
||||||
values.append(value)
|
if not isinstance(raw_value, str) or not raw_value:
|
||||||
|
print("malformed")
|
||||||
|
raise SystemExit(0)
|
||||||
|
values.append(raw_value.lower())
|
||||||
|
|
||||||
if not values and not state:
|
if any(value in pending_values for value in values) or state in pending_values:
|
||||||
print("no-status")
|
|
||||||
elif any(v in pending_values for v in values):
|
|
||||||
print("pending")
|
print("pending")
|
||||||
elif any(v in failure_values for v in values):
|
elif any(value in failure_values for value in values) or state in failure_values:
|
||||||
print("terminal-failure")
|
print("terminal-failure")
|
||||||
elif values and all(v in success_values for v in values):
|
elif values and all(value in success_values for value in values) and state in {"", "success"}:
|
||||||
print("terminal-success")
|
print("terminal-success")
|
||||||
|
elif not values:
|
||||||
|
print("no-status")
|
||||||
else:
|
else:
|
||||||
print("unknown")
|
print("unknown")
|
||||||
PY
|
'
|
||||||
}
|
}
|
||||||
|
|
||||||
print_pending_contexts() {
|
print_pending_contexts() {
|
||||||
python3 - <<'PY'
|
python3 -c '
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
@@ -104,17 +110,61 @@ for item in statuses:
|
|||||||
if not isinstance(item, dict):
|
if not isinstance(item, dict):
|
||||||
continue
|
continue
|
||||||
name = item.get("context") or item.get("name") or "unknown-context"
|
name = item.get("context") or item.get("name") or "unknown-context"
|
||||||
value = (item.get("status") or item.get("state") or "unknown").lower()
|
value = str(item.get("status") or item.get("state") or "unknown").lower()
|
||||||
target = item.get("target_url") or item.get("url") or ""
|
target = item.get("target_url") or item.get("url") or ""
|
||||||
if value in pending_values:
|
if value in pending_values:
|
||||||
found = True
|
found = True
|
||||||
if target:
|
suffix = f" ({target})" if target else ""
|
||||||
print(f"[ci-queue-wait] pending: {name}={value} ({target})")
|
print(f"[ci-queue-wait] pending: {name}={value}{suffix}")
|
||||||
else:
|
|
||||||
print(f"[ci-queue-wait] pending: {name}={value}")
|
|
||||||
if not found:
|
if not found:
|
||||||
print("[ci-queue-wait] no pending contexts")
|
print("[ci-queue-wait] no pending contexts")
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
record_cannot_assert() {
|
||||||
|
local reason="$1"
|
||||||
|
local audit_log="${MOSAIC_CI_QUEUE_AUDIT_LOG:-${XDG_STATE_HOME:-${HOME:-}/.local/state}/mosaic/audit/ci-queue-wait.jsonl}"
|
||||||
|
|
||||||
|
if [[ -z "$audit_log" ]] || ! mkdir -p "$(dirname "$audit_log")"; then
|
||||||
|
echo "Error: CANNOT_ASSERT and audit directory is unavailable; refusing degraded pass." >&2
|
||||||
|
return 70
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! python3 - "$audit_log" "$reason" "${PLATFORM:-unknown}" "$PURPOSE" "${BRANCH:-unknown}" "${OWNER:-unknown}/${REPO:-unknown}" <<'PY'
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path, reason, platform, purpose, branch, repo = sys.argv[1:]
|
||||||
|
record = {
|
||||||
|
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||||
|
"outcome": "CANNOT_ASSERT",
|
||||||
|
"reason": reason,
|
||||||
|
"platform": platform,
|
||||||
|
"purpose": purpose,
|
||||||
|
"disposition": "hold" if purpose == "merge" else "degraded-pass",
|
||||||
|
"branch": branch,
|
||||||
|
"repo": repo,
|
||||||
|
}
|
||||||
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
||||||
|
try:
|
||||||
|
os.write(fd, (json.dumps(record, separators=(",", ":")) + "\n").encode())
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
PY
|
PY
|
||||||
|
then
|
||||||
|
echo "Error: CANNOT_ASSERT and audit write failed at ${audit_log}; refusing degraded pass." >&2
|
||||||
|
return 70
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$PURPOSE" == "merge" ]]; then
|
||||||
|
echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=merge branch=${BRANCH:-unknown}; audited=${audit_log}; HOLD (exit 75). Retry after provider recovery; no manual reset is required." >&2
|
||||||
|
return 75
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=push branch=${BRANCH:-unknown}; audited=${audit_log}; push may proceed in degraded mode." >&2
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
github_get_branch_head_sha() {
|
github_get_branch_head_sha() {
|
||||||
@@ -128,7 +178,87 @@ github_get_commit_status_json() {
|
|||||||
local owner="$1"
|
local owner="$1"
|
||||||
local repo="$2"
|
local repo="$2"
|
||||||
local sha="$3"
|
local sha="$3"
|
||||||
gh api "repos/${owner}/${repo}/commits/${sha}/status"
|
local work_root status_file checks_file
|
||||||
|
work_root="${AGENT_WORK_ROOT:-${HOME:-}/.cache/mosaic/ci-queue-wait}"
|
||||||
|
mkdir -p "$work_root" || return 1
|
||||||
|
status_file=$(mktemp "$work_root/github-status.XXXXXX") || return 1
|
||||||
|
checks_file=$(mktemp "$work_root/github-checks.XXXXXX") || {
|
||||||
|
rm -f "$status_file"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if ! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/statuses?per_page=100" > "$status_file" ||
|
||||||
|
! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/check-runs?per_page=100&filter=latest" > "$checks_file"; then
|
||||||
|
rm -f "$status_file" "$checks_file"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 - "$status_file" "$checks_file" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as handle:
|
||||||
|
status_pages = json.load(handle)
|
||||||
|
with open(sys.argv[2], encoding="utf-8") as handle:
|
||||||
|
check_pages = json.load(handle)
|
||||||
|
|
||||||
|
if not isinstance(status_pages, list) or not isinstance(check_pages, list):
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
# The statuses endpoint is newest-first and can contain retries for one context.
|
||||||
|
# Keep only the newest entry per context after flattening every page.
|
||||||
|
combined = []
|
||||||
|
seen_contexts = set()
|
||||||
|
for page in status_pages:
|
||||||
|
if not isinstance(page, list):
|
||||||
|
raise SystemExit(1)
|
||||||
|
for status in page:
|
||||||
|
if not isinstance(status, dict):
|
||||||
|
raise SystemExit(1)
|
||||||
|
context = status.get("context")
|
||||||
|
if not isinstance(context, str) or not context or context in seen_contexts:
|
||||||
|
continue
|
||||||
|
seen_contexts.add(context)
|
||||||
|
combined.append(status)
|
||||||
|
|
||||||
|
check_runs = []
|
||||||
|
reported_total = 0
|
||||||
|
for page in check_pages:
|
||||||
|
if not isinstance(page, dict):
|
||||||
|
raise SystemExit(1)
|
||||||
|
page_runs = page.get("check_runs") or []
|
||||||
|
total_count = page.get("total_count")
|
||||||
|
if not isinstance(page_runs, list) or not isinstance(total_count, int):
|
||||||
|
raise SystemExit(1)
|
||||||
|
reported_total = max(reported_total, total_count)
|
||||||
|
check_runs.extend(page_runs)
|
||||||
|
if len(check_runs) < reported_total:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
for run in check_runs:
|
||||||
|
if not isinstance(run, dict):
|
||||||
|
raise SystemExit(1)
|
||||||
|
status = run.get("status")
|
||||||
|
conclusion = run.get("conclusion")
|
||||||
|
if status != "completed":
|
||||||
|
value = "pending"
|
||||||
|
elif conclusion == "success":
|
||||||
|
value = "success"
|
||||||
|
elif conclusion in {"failure", "cancelled", "timed_out", "action_required", "startup_failure", "stale"}:
|
||||||
|
value = "failure"
|
||||||
|
else:
|
||||||
|
value = "unknown"
|
||||||
|
combined.append({
|
||||||
|
"context": run.get("name") or "github-check",
|
||||||
|
"status": value,
|
||||||
|
"target_url": run.get("html_url") or run.get("details_url") or "",
|
||||||
|
})
|
||||||
|
|
||||||
|
json.dump({"state": "", "statuses": combined}, sys.stdout)
|
||||||
|
PY
|
||||||
|
local status=$?
|
||||||
|
rm -f "$status_file" "$checks_file"
|
||||||
|
return "$status"
|
||||||
}
|
}
|
||||||
|
|
||||||
gitea_get_branch_head_sha() {
|
gitea_get_branch_head_sha() {
|
||||||
@@ -174,6 +304,14 @@ while [[ $# -gt 0 ]]; do
|
|||||||
BRANCH="$2"
|
BRANCH="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
-R|--repo)
|
||||||
|
TARGET_REPO="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--sha)
|
||||||
|
HEAD_SHA="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-t|--timeout)
|
-t|--timeout)
|
||||||
TIMEOUT_SEC="$2"
|
TIMEOUT_SEC="$2"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -206,45 +344,89 @@ if ! [[ "$TIMEOUT_SEC" =~ ^[0-9]+$ ]] || ! [[ "$INTERVAL_SEC" =~ ^[0-9]+$ ]]; th
|
|||||||
echo "Error: timeout and interval must be integer seconds." >&2
|
echo "Error: timeout and interval must be integer seconds." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if [[ -n "$HEAD_SHA" && ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||||
|
echo "Error: --sha must be a full 40-character hexadecimal commit SHA." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -n "$TARGET_REPO" && ! "$TARGET_REPO" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]]; then
|
||||||
|
echo "Error: --repo must be OWNER/REPO." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
OWNER=$(get_repo_owner)
|
if [[ "$PURPOSE" != "push" && "$PURPOSE" != "merge" ]]; then
|
||||||
REPO=$(get_repo_name)
|
echo "Error: --purpose must be push or merge." >&2
|
||||||
detect_platform > /dev/null
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
OWNER="unknown"
|
||||||
|
REPO="unknown"
|
||||||
|
PLATFORM="unknown"
|
||||||
|
if ! OWNER=$(get_repo_owner) || [[ -z "$OWNER" ]]; then
|
||||||
|
record_cannot_assert "repository-owner-unresolvable"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
|
if ! REPO=$(get_repo_name) || [[ -z "$REPO" ]]; then
|
||||||
|
record_cannot_assert "repository-name-unresolvable"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
|
if ! detect_platform > /dev/null; then
|
||||||
|
PLATFORM="${PLATFORM:-unknown}"
|
||||||
|
record_cannot_assert "unsupported-platform"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
PLATFORM="${PLATFORM:-unknown}"
|
PLATFORM="${PLATFORM:-unknown}"
|
||||||
|
|
||||||
|
if [[ -n "$TARGET_REPO" ]]; then
|
||||||
|
OWNER="${TARGET_REPO%%/*}"
|
||||||
|
REPO="${TARGET_REPO##*/}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$BRANCH" ]]; then
|
||||||
|
if ! BRANCH=$(git symbolic-ref --quiet --short HEAD) || [[ -z "$BRANCH" ]]; then
|
||||||
|
record_cannot_assert "current-branch-unresolvable"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
if ! command -v gh >/dev/null 2>&1; then
|
if ! command -v gh >/dev/null 2>&1; then
|
||||||
echo "Error: gh CLI is required for GitHub CI queue guard." >&2
|
record_cannot_assert "github-cli-unavailable"
|
||||||
exit 1
|
exit $?
|
||||||
fi
|
fi
|
||||||
HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH")
|
|
||||||
if [[ -z "$HEAD_SHA" ]]; then
|
if [[ -z "$HEAD_SHA" ]]; then
|
||||||
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
if ! HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH") || [[ -z "$HEAD_SHA" ]]; then
|
||||||
exit 1
|
record_cannot_assert "branch-head-unavailable"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
echo "[ci-queue-wait] platform=github purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
echo "[ci-queue-wait] platform=github purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
HOST=$(get_remote_host) || {
|
if ! HOST=$(get_remote_host) || [[ -z "$HOST" ]]; then
|
||||||
echo "Error: Could not determine remote host." >&2
|
record_cannot_assert "remote-host-unresolvable"
|
||||||
exit 1
|
exit $?
|
||||||
}
|
fi
|
||||||
TOKEN=$(get_gitea_token "$HOST") || {
|
if ! TOKEN=$(get_gitea_token "$HOST") || [[ -z "$TOKEN" ]]; then
|
||||||
echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2
|
record_cannot_assert "credential-unresolvable"
|
||||||
exit 1
|
exit $?
|
||||||
}
|
fi
|
||||||
HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN")
|
if [[ -z "$HEAD_SHA" ]]; then
|
||||||
|
if ! HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN"); then
|
||||||
|
record_cannot_assert "branch-head-unavailable"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
|
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
|
||||||
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
|
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if [[ -z "$HEAD_SHA" ]]; then
|
if [[ -z "$HEAD_SHA" ]]; then
|
||||||
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
|
record_cannot_assert "branch-head-unavailable"
|
||||||
exit 1
|
exit $?
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
echo "[ci-queue-wait] platform=gitea purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
echo "[ci-queue-wait] platform=gitea purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
|
||||||
else
|
else
|
||||||
echo "Error: Unsupported platform '${PLATFORM}'." >&2
|
record_cannot_assert "unsupported-platform"
|
||||||
exit 1
|
exit $?
|
||||||
fi
|
fi
|
||||||
|
|
||||||
START_TS=$(date +%s)
|
START_TS=$(date +%s)
|
||||||
@@ -253,14 +435,20 @@ DEADLINE_TS=$((START_TS + TIMEOUT_SEC))
|
|||||||
while true; do
|
while true; do
|
||||||
NOW_TS=$(date +%s)
|
NOW_TS=$(date +%s)
|
||||||
if (( NOW_TS > DEADLINE_TS )); then
|
if (( NOW_TS > DEADLINE_TS )); then
|
||||||
echo "Error: Timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
|
echo "Error: ASSERTED_NOT_READY state=pending; timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
|
||||||
exit 124
|
exit 124
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA")
|
if ! STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA"); then
|
||||||
|
record_cannot_assert "status-provider-unreachable"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN")
|
if ! STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN"); then
|
||||||
|
record_cannot_assert "status-provider-unreachable"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
STATE=$(printf '%s' "$STATUS_JSON" | get_state_from_status_json)
|
STATE=$(printf '%s' "$STATUS_JSON" | get_state_from_status_json)
|
||||||
@@ -271,21 +459,24 @@ while true; do
|
|||||||
printf '%s' "$STATUS_JSON" | print_pending_contexts
|
printf '%s' "$STATUS_JSON" | print_pending_contexts
|
||||||
sleep "$INTERVAL_SEC"
|
sleep "$INTERVAL_SEC"
|
||||||
;;
|
;;
|
||||||
|
terminal-success)
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
no-status)
|
no-status)
|
||||||
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
|
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
|
||||||
echo "Error: No CI status contexts found for ${BRANCH} while --require-status is set." >&2
|
echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
|
||||||
exit 1
|
else
|
||||||
|
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||||
fi
|
fi
|
||||||
echo "[ci-queue-wait] no status contexts present; proceeding."
|
exit 3
|
||||||
exit 0
|
|
||||||
;;
|
;;
|
||||||
terminal-success|terminal-failure|unknown)
|
terminal-failure|malformed|unknown)
|
||||||
# Queue guard only blocks on pending/running/queued states.
|
echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||||
exit 0
|
exit 3
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "[ci-queue-wait] unrecognized state '${STATE}', proceeding conservatively."
|
echo "Error: ASSERTED_NOT_READY unrecognized-state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
|
||||||
exit 0
|
exit 3
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
# pr-merge.sh - Merge pull requests on Gitea or GitHub
|
||||||
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard]
|
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d]
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -12,8 +12,8 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
PR_NUMBER=""
|
PR_NUMBER=""
|
||||||
MERGE_METHOD="squash"
|
MERGE_METHOD="squash"
|
||||||
DELETE_BRANCH=false
|
DELETE_BRANCH=false
|
||||||
SKIP_QUEUE_GUARD=false
|
|
||||||
DRY_RUN=false
|
DRY_RUN=false
|
||||||
|
EXPECT_HEAD=""
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
@@ -25,15 +25,14 @@ Options:
|
|||||||
-n, --number NUMBER PR number to merge (required)
|
-n, --number NUMBER PR number to merge (required)
|
||||||
-m, --method METHOD Merge method: squash only (default: squash)
|
-m, --method METHOD Merge method: squash only (default: squash)
|
||||||
-d, --delete-branch Delete the head branch after merge
|
-d, --delete-branch Delete the head branch after merge
|
||||||
--skip-queue-guard Skip CI queue guard wait before merge
|
|
||||||
--dry-run Run metadata/login preflight without merging
|
--dry-run Run metadata/login preflight without merging
|
||||||
|
--expect-head SHA Refuse unless the PR head matches this full commit SHA
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
$(basename "$0") -n 42 # Merge PR #42
|
$(basename "$0") -n 42 # Merge PR #42
|
||||||
$(basename "$0") -n 42 -m squash # Squash merge
|
$(basename "$0") -n 42 -m squash # Squash merge
|
||||||
$(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
|
|
||||||
EOF
|
EOF
|
||||||
exit "${1:-1}"
|
exit "${1:-1}"
|
||||||
}
|
}
|
||||||
@@ -53,15 +52,14 @@ while [[ $# -gt 0 ]]; do
|
|||||||
DELETE_BRANCH=true
|
DELETE_BRANCH=true
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
--skip-queue-guard)
|
|
||||||
SKIP_QUEUE_GUARD=true
|
|
||||||
shift
|
|
||||||
;;
|
|
||||||
--dry-run)
|
--dry-run)
|
||||||
DRY_RUN=true
|
DRY_RUN=true
|
||||||
SKIP_QUEUE_GUARD=true
|
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--expect-head)
|
||||||
|
EXPECT_HEAD="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage 0
|
usage 0
|
||||||
;;
|
;;
|
||||||
@@ -86,18 +84,36 @@ if [[ "$MERGE_METHOD" != "squash" ]]; then
|
|||||||
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
|
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if [[ -n "$EXPECT_HEAD" && ! "$EXPECT_HEAD" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||||
|
echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
|
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
|
||||||
BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
|
BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
|
||||||
|
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
|
||||||
|
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
|
||||||
|
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
|
||||||
if [[ "$BASE_BRANCH" != "main" ]]; then
|
if [[ "$BASE_BRANCH" != "main" ]]; then
|
||||||
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$SKIP_QUEUE_GUARD" != true ]]; then
|
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
|
||||||
|
echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -n "$EXPECT_HEAD" && "$HEAD_SHA" != "$EXPECT_HEAD" ]]; then
|
||||||
|
echo "Error: PR head moved: expected $EXPECT_HEAD, found $HEAD_SHA." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$DRY_RUN" != true ]]; then
|
||||||
"$SCRIPT_DIR/ci-queue-wait.sh" \
|
"$SCRIPT_DIR/ci-queue-wait.sh" \
|
||||||
--purpose merge \
|
--purpose merge \
|
||||||
-B "$BASE_BRANCH" \
|
-B "$HEAD_BRANCH" \
|
||||||
|
-R "$HEAD_REPO" \
|
||||||
|
--sha "$HEAD_SHA" \
|
||||||
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
|
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
|
||||||
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
|
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
|
||||||
fi
|
fi
|
||||||
@@ -106,31 +122,22 @@ PLATFORM=$(detect_platform)
|
|||||||
OWNER=$(get_repo_owner)
|
OWNER=$(get_repo_owner)
|
||||||
REPO=$(get_repo_name)
|
REPO=$(get_repo_name)
|
||||||
|
|
||||||
is_known_tea_empty_identity_failure() {
|
|
||||||
local error_file="$1"
|
|
||||||
|
|
||||||
python3 - "$error_file" <<'PY'
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
|
|
||||||
with open(sys.argv[1], encoding="utf-8", errors="replace") as handle:
|
|
||||||
error = handle.read()
|
|
||||||
|
|
||||||
known_empty_identity = re.search(
|
|
||||||
r"user does not exist.*\[.*uid:\s*0,\s*name:\s*\]",
|
|
||||||
error,
|
|
||||||
flags=re.IGNORECASE | re.DOTALL,
|
|
||||||
)
|
|
||||||
raise SystemExit(0 if known_empty_identity else 1)
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
merge_gitea_with_api() {
|
merge_gitea_with_api() {
|
||||||
local host="$1" api_url token basic_auth body_file raw_code payload
|
local host="$1" api_url token basic_auth body_file raw_code payload
|
||||||
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
|
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
|
||||||
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
||||||
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
|
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
|
||||||
payload='{"Do":"squash"}'
|
payload=$(python3 - "$HEAD_SHA" "$DELETE_BRANCH" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
head_sha, delete_branch = sys.argv[1:]
|
||||||
|
payload = {"Do": "squash", "head_commit_id": head_sha}
|
||||||
|
if delete_branch == "true":
|
||||||
|
payload["delete_branch_after_merge"] = True
|
||||||
|
print(json.dumps(payload, separators=(",", ":")))
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
|
||||||
token=$(get_gitea_token "$host" || true)
|
token=$(get_gitea_token "$host" || true)
|
||||||
if [[ -n "$token" ]]; then
|
if [[ -n "$token" ]]; then
|
||||||
@@ -202,7 +209,7 @@ fi
|
|||||||
|
|
||||||
case "$PLATFORM" in
|
case "$PLATFORM" in
|
||||||
github)
|
github)
|
||||||
cmd=(gh pr merge "$PR_NUMBER" --squash)
|
cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA")
|
||||||
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
|
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
|
||||||
"${cmd[@]}"
|
"${cmd[@]}"
|
||||||
;;
|
;;
|
||||||
@@ -211,32 +218,9 @@ case "$PLATFORM" in
|
|||||||
echo "Error: Cannot determine host from origin remote URL" >&2
|
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
# Gitea's API head_commit_id is an atomic compare-and-merge precondition.
|
||||||
|
# tea cannot express it, so exact-head merges use the authenticated API path.
|
||||||
if [[ -n "$TEA_LOGIN" ]]; then
|
|
||||||
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
|
|
||||||
TEA_ERROR_FILE=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-tea-error.XXXXXX")
|
|
||||||
if tea pr merge "$PR_NUMBER" --style squash --repo "$OWNER/$REPO" --login "$TEA_LOGIN" 2> "$TEA_ERROR_FILE"; then
|
|
||||||
rm -f "$TEA_ERROR_FILE"
|
|
||||||
elif is_known_tea_empty_identity_failure "$TEA_ERROR_FILE"; then
|
|
||||||
cat "$TEA_ERROR_FILE" >&2
|
|
||||||
echo "Known tea empty identity failure detected; using authenticated Gitea API merge fallback." >&2
|
|
||||||
rm -f "$TEA_ERROR_FILE"
|
|
||||||
merge_gitea_with_api "$HOST"
|
merge_gitea_with_api "$HOST"
|
||||||
else
|
|
||||||
cat "$TEA_ERROR_FILE" >&2
|
|
||||||
rm -f "$TEA_ERROR_FILE"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "No tea login configured for $HOST; using authenticated Gitea API merge fallback." >&2
|
|
||||||
merge_gitea_with_api "$HOST"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Delete branch after merge if requested
|
|
||||||
if [[ "$DELETE_BRANCH" == true ]]; then
|
|
||||||
echo "Note: Branch deletion after merge may need to be done separately with tea" >&2
|
|
||||||
fi
|
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Error: Could not detect git platform" >&2
|
echo "Error: Could not detect git platform" >&2
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ PY
|
|||||||
detect_platform > /dev/null
|
detect_platform > /dev/null
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
|
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,headRefOid,headRepository,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
|
||||||
write_metadata "$METADATA"
|
write_metadata "$METADATA"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
OWNER=$(get_repo_owner)
|
OWNER=$(get_repo_owner)
|
||||||
@@ -182,6 +182,25 @@ if isinstance(head_ref, str) and head_ref.startswith('refs/pull/'):
|
|||||||
data.get('head_ref'),
|
data.get('head_ref'),
|
||||||
head_ref,
|
head_ref,
|
||||||
)
|
)
|
||||||
|
head_sha = first_non_empty(
|
||||||
|
nested(data, 'head', 'sha'),
|
||||||
|
nested(data, 'head', 'id'),
|
||||||
|
data.get('head_sha'),
|
||||||
|
)
|
||||||
|
head_repo = first_non_empty(
|
||||||
|
nested(data, 'head', 'repo', 'full_name'),
|
||||||
|
nested(data, 'head', 'repo', 'name_with_owner'),
|
||||||
|
)
|
||||||
|
if not head_repo:
|
||||||
|
head_repo_owner = first_non_empty(
|
||||||
|
nested(data, 'head', 'repo', 'owner', 'login'),
|
||||||
|
nested(data, 'head', 'repo', 'owner', 'username'),
|
||||||
|
nested(data, 'head', 'repo', 'owner_name'),
|
||||||
|
)
|
||||||
|
head_repo_name = first_non_empty(nested(data, 'head', 'repo', 'name'))
|
||||||
|
if head_repo_owner and head_repo_name:
|
||||||
|
head_repo = f'{head_repo_owner}/{head_repo_name}'
|
||||||
|
|
||||||
base_ref = first_non_empty(
|
base_ref = first_non_empty(
|
||||||
nested(data, 'base', 'ref'),
|
nested(data, 'base', 'ref'),
|
||||||
nested(data, 'base', 'name'),
|
nested(data, 'base', 'name'),
|
||||||
@@ -207,6 +226,8 @@ normalized = {
|
|||||||
'state': data.get('state'),
|
'state': data.get('state'),
|
||||||
'author': nested(data, 'user', 'login') or '',
|
'author': nested(data, 'user', 'login') or '',
|
||||||
'headRefName': head_ref,
|
'headRefName': head_ref,
|
||||||
|
'headRefOid': head_sha,
|
||||||
|
'headRepository': head_repo,
|
||||||
'baseRefName': base_ref,
|
'baseRefName': base_ref,
|
||||||
'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)],
|
'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)],
|
||||||
'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)],
|
'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)],
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
# Covers:
|
# Covers:
|
||||||
# (a) 404 branch-absent -> exit 0, "queue clear" message.
|
# (a) 404 branch-absent -> exit 0, "queue clear" message.
|
||||||
# (b) 200 existing branch + a terminal CI state -> unchanged behavior.
|
# (b) 200 existing branch + a terminal CI state -> unchanged behavior.
|
||||||
# (c) genuine API error (500) -> still fail-closed (nonzero exit).
|
# (c) genuine API error (500) -> loud, audited CANNOT_ASSERT; degraded exit 0.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ case "$mode" in
|
|||||||
200) code=200; body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}' ;;
|
200) code=200; body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}' ;;
|
||||||
500) code=500; body='{"message":"internal server error"}' ;;
|
500) code=500; body='{"message":"internal server error"}' ;;
|
||||||
no-status) code=200; body='{}' ;;
|
no-status) code=200; body='{}' ;;
|
||||||
terminal-success) code=200; body='{"state":"success"}' ;;
|
terminal-success) code=200; body='{"state":"success","statuses":[{"status":"success"}]}' ;;
|
||||||
*)
|
*)
|
||||||
echo "curl stub: unknown mode=$mode" >&2
|
echo "curl stub: unknown mode=$mode" >&2
|
||||||
exit 2
|
exit 2
|
||||||
@@ -91,6 +91,7 @@ run_ci_queue_wait() {
|
|||||||
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||||
export GITEA_TOKEN="stub-token"
|
export GITEA_TOKEN="stub-token"
|
||||||
export GITEA_URL="https://git.example.test"
|
export GITEA_URL="https://git.example.test"
|
||||||
|
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
|
||||||
"$SCRIPT_DIR/ci-queue-wait.sh" -B "$branch" --purpose push -t 5 -i 1
|
"$SCRIPT_DIR/ci-queue-wait.sh" -B "$branch" --purpose push -t 5 -i 1
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -131,19 +132,26 @@ elif [[ "$out_b" == *"queue clear"* ]]; then
|
|||||||
fail=1
|
fail=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# (c) genuine API error (500) -> still fail-closed, exit nonzero.
|
# (c) genuine API error (500) -> CANNOT_ASSERT is loud and audited, but does not brick delivery.
|
||||||
set +e
|
set +e
|
||||||
out_c=$(MOSAIC_STUB_BRANCH_MODE=500 run_ci_queue_wait "feat/some-branch" 2>&1)
|
out_c=$(MOSAIC_STUB_BRANCH_MODE=500 run_ci_queue_wait "feat/some-branch" 2>&1)
|
||||||
status_c=$?
|
status_c=$?
|
||||||
set -e
|
set -e
|
||||||
if [[ "$status_c" -eq 0 ]]; then
|
if [[ "$status_c" -ne 0 ]]; then
|
||||||
echo "FAIL(c): expected a nonzero exit for a genuine 500 API error, got 0" >&2
|
echo "FAIL(c): expected degraded exit 0 for provider unavailability, got $status_c" >&2
|
||||||
|
echo "$out_c" >&2
|
||||||
|
fail=1
|
||||||
|
elif [[ "$out_c" != *"CANNOT_ASSERT"* ]]; then
|
||||||
|
echo "FAIL(c): expected a loud CANNOT_ASSERT diagnostic" >&2
|
||||||
echo "$out_c" >&2
|
echo "$out_c" >&2
|
||||||
fail=1
|
fail=1
|
||||||
elif [[ "$out_c" == *"queue clear"* ]]; then
|
elif [[ "$out_c" == *"queue clear"* ]]; then
|
||||||
echo "FAIL(c): a genuine API error must not be reported as queue-clear" >&2
|
echo "FAIL(c): a genuine API error must not be reported as queue-clear" >&2
|
||||||
echo "$out_c" >&2
|
echo "$out_c" >&2
|
||||||
fail=1
|
fail=1
|
||||||
|
elif [[ ! -s "$WORK_DIR/audit/ci-queue-wait.jsonl" ]]; then
|
||||||
|
echo "FAIL(c): expected a durable CANNOT_ASSERT audit record" >&2
|
||||||
|
fail=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "$fail" -eq 0 ]]; then
|
if [[ "$fail" -eq 0 ]]; then
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# GitHub Actions uses Checks API check-runs, not only legacy commit statuses.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-github-checks}"
|
||||||
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
|
STUB_DIR="$WORK_DIR/stubs"
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
mkdir -p "$REPO_DIR" "$STUB_DIR"
|
||||||
|
git -C "$REPO_DIR" init -q
|
||||||
|
git -C "$REPO_DIR" checkout -q -b fix/github-checks
|
||||||
|
git -C "$REPO_DIR" remote add origin https://github.com/acme/widgets.git
|
||||||
|
|
||||||
|
cat > "$STUB_DIR/gh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
endpoint=""
|
||||||
|
for arg in "$@"; do
|
||||||
|
[[ "$arg" == repos/* ]] && endpoint="$arg"
|
||||||
|
done
|
||||||
|
printf '%s\n' "$*" >> "${MOSAIC_GH_CALL_LOG:?}"
|
||||||
|
case "$endpoint" in
|
||||||
|
repos/acme/widgets/branches/fix/github-checks)
|
||||||
|
printf '%s\n' '0123456789abcdef0123456789abcdef01234567'
|
||||||
|
;;
|
||||||
|
repos/acme/widgets/commits/*/statuses?per_page=100)
|
||||||
|
printf '%s\n' '[[]]'
|
||||||
|
;;
|
||||||
|
repos/acme/widgets/commits/*/check-runs?per_page=100\&filter=latest)
|
||||||
|
case "${MOSAIC_GH_CHECK_MODE:?}" in
|
||||||
|
success) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"completed","conclusion":"success"}]}]' ;;
|
||||||
|
pending) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"in_progress","conclusion":null}]}]' ;;
|
||||||
|
failure) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"completed","conclusion":"failure"}]}]' ;;
|
||||||
|
late-failure) printf '%s\n' '[{"total_count":2,"check_runs":[{"name":"first-page","status":"completed","conclusion":"success"}]},{"total_count":2,"check_runs":[{"name":"later-page","status":"completed","conclusion":"failure"}]}]' ;;
|
||||||
|
*) exit 2 ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
*) echo "unexpected gh endpoint: $endpoint" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
SH
|
||||||
|
chmod +x "$STUB_DIR/gh"
|
||||||
|
|
||||||
|
run_guard() {
|
||||||
|
local mode="$1"
|
||||||
|
(
|
||||||
|
cd "$REPO_DIR" || exit
|
||||||
|
export PATH="$STUB_DIR:$PATH"
|
||||||
|
export MOSAIC_GH_CHECK_MODE="$mode"
|
||||||
|
export MOSAIC_GH_CALL_LOG="$WORK_DIR/gh-calls.log"
|
||||||
|
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl"
|
||||||
|
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose push -t 0 -i 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
failures=0
|
||||||
|
assert_case() {
|
||||||
|
local mode="$1" expected_rc="$2" expected_state="$3" output rc
|
||||||
|
set +e
|
||||||
|
output=$(run_guard "$mode" 2>&1)
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$expected_rc" == zero && "$rc" -ne 0 ]]; then
|
||||||
|
echo "FAIL github-$mode: expected rc=0, got $rc" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
elif [[ "$expected_rc" == nonzero && "$rc" -eq 0 ]]; then
|
||||||
|
echo "FAIL github-$mode: expected rc!=0, got 0" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
if [[ "$output" != *"state=$expected_state"* ]]; then
|
||||||
|
echo "FAIL github-$mode: expected state=$expected_state, got:" >&2
|
||||||
|
printf '%s\n' "$output" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
set -e
|
||||||
|
: > "$WORK_DIR/gh-calls.log"
|
||||||
|
assert_case success zero terminal-success
|
||||||
|
assert_case pending nonzero pending
|
||||||
|
assert_case failure nonzero terminal-failure
|
||||||
|
assert_case late-failure nonzero terminal-failure
|
||||||
|
|
||||||
|
if [[ $(grep -c 'check-runs?per_page=100&filter=latest' "$WORK_DIR/gh-calls.log") -lt 4 ]]; then
|
||||||
|
echo "FAIL: expected every case to query all Checks API pages" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$failures" -ne 0 ]]; then
|
||||||
|
echo "GitHub check-runs regression failed ($failures assertions)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "GitHub check-runs regression passed (4/4 cases, including later-page failure)"
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Exit-asserting RM-03 regression harness for ci-queue-wait.sh.
|
||||||
|
# Every case is a process-level assertion: a classifier-only green cannot satisfy it.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-tristate}"
|
||||||
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
|
STUB_DIR="$WORK_DIR/stubs"
|
||||||
|
AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
|
||||||
|
FEATURE_BRANCH="fix/rm-03-fixture"
|
||||||
|
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
mkdir -p "$REPO_DIR" "$STUB_DIR"
|
||||||
|
git -C "$REPO_DIR" init -q
|
||||||
|
git -C "$REPO_DIR" checkout -q -b "$FEATURE_BRANCH"
|
||||||
|
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
|
||||||
|
|
||||||
|
cat > "$STUB_DIR/curl" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
url=""
|
||||||
|
has_write_out=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
-w) has_write_out=1 ;;
|
||||||
|
http://*|https://*) url="$arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
printf '%s\n' "$url" >> "${MOSAIC_STUB_URL_LOG:?}"
|
||||||
|
|
||||||
|
case "$url" in
|
||||||
|
*/branches/*)
|
||||||
|
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "unreachable" ]]; then
|
||||||
|
exit 7
|
||||||
|
fi
|
||||||
|
body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}'
|
||||||
|
if [[ "$has_write_out" -eq 1 ]]; then
|
||||||
|
printf '%s\n200' "$body"
|
||||||
|
else
|
||||||
|
printf '%s' "$body"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
*/status)
|
||||||
|
case "${MOSAIC_STUB_STATUS_MODE:?}" in
|
||||||
|
success) printf '%s' '{"state":"success","statuses":[{"status":"success"}]}' ;;
|
||||||
|
pending) printf '%s' '{"state":"pending","statuses":[{"status":"pending","context":"ci/test"}]}' ;;
|
||||||
|
failure) printf '%s' '{"state":"failure","statuses":[{"status":"failure"}]}' ;;
|
||||||
|
no-status) printf '%s' '{"state":"","statuses":[]}' ;;
|
||||||
|
aggregate-success-no-status) printf '%s' '{"state":"success","statuses":[]}' ;;
|
||||||
|
malformed) printf '%s' 'not-json' ;;
|
||||||
|
malformed-statuses-type) printf '%s' '{"state":"success","statuses":"corrupt"}' ;;
|
||||||
|
malformed-status-entry) printf '%s' '{"state":"success","statuses":[null]}' ;;
|
||||||
|
large-success)
|
||||||
|
python3 -c 'import json; print(json.dumps({"state":"success", "statuses":[{"status":"success"}], "padding":"x" * (160 * 1024)}), end="")'
|
||||||
|
;;
|
||||||
|
unreachable) exit 7 ;;
|
||||||
|
*) echo "unknown status mode" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
*) echo "unexpected curl URL: $url" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
SH
|
||||||
|
chmod +x "$STUB_DIR/curl"
|
||||||
|
|
||||||
|
run_guard() {
|
||||||
|
local status_mode="$1"
|
||||||
|
local audit_log="${2:-$AUDIT_LOG}"
|
||||||
|
shift 2 || true
|
||||||
|
(
|
||||||
|
cd "$REPO_DIR" || exit
|
||||||
|
export PATH="$STUB_DIR:$PATH"
|
||||||
|
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||||
|
if [[ "$status_mode" == "credential-unresolvable" ]]; then
|
||||||
|
export HOME="$WORK_DIR/empty-home"
|
||||||
|
mkdir -p "$HOME"
|
||||||
|
unset GITEA_TOKEN GITEA_URL MOSAIC_GIT_IDENTITY
|
||||||
|
export MOSAIC_STUB_STATUS_MODE=success
|
||||||
|
else
|
||||||
|
export GITEA_TOKEN=stub-token
|
||||||
|
export GITEA_URL=https://git.example.test
|
||||||
|
export MOSAIC_STUB_STATUS_MODE="$status_mode"
|
||||||
|
fi
|
||||||
|
export MOSAIC_STUB_URL_LOG="$WORK_DIR/urls.log"
|
||||||
|
export MOSAIC_CI_QUEUE_AUDIT_LOG="$audit_log"
|
||||||
|
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 0 -i 0 "$@"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
failures=0
|
||||||
|
run_assertion() {
|
||||||
|
local name="$1" expected_rc="$2" status_mode="$3" required_text="$4"
|
||||||
|
local output rc
|
||||||
|
shift 4
|
||||||
|
set +e
|
||||||
|
output=$(run_guard "$status_mode" "$AUDIT_LOG" "$@" 2>&1)
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
case "$expected_rc" in
|
||||||
|
zero)
|
||||||
|
if [[ "$rc" -ne 0 ]]; then
|
||||||
|
echo "FAIL $name: expected rc=0, got rc=$rc" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
nonzero)
|
||||||
|
if [[ "$rc" -eq 0 ]]; then
|
||||||
|
echo "FAIL $name: expected rc!=0, got rc=0" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
not126)
|
||||||
|
if [[ "$rc" -eq 126 ]]; then
|
||||||
|
echo "FAIL $name: payload transport hit ARG_MAX (rc=126)" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
if [[ "$output" != *"$required_text"* ]]; then
|
||||||
|
echo "FAIL $name: output missing '$required_text' (rc=$rc)" >&2
|
||||||
|
printf '%s\n' "$output" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
set -e
|
||||||
|
: > "$WORK_DIR/urls.log"
|
||||||
|
run_assertion success zero success 'state=terminal-success'
|
||||||
|
run_assertion pending nonzero pending 'ASSERTED_NOT_READY'
|
||||||
|
run_assertion failure nonzero failure 'ASSERTED_NOT_READY'
|
||||||
|
run_assertion no-status nonzero no-status 'ASSERTED_NOT_READY'
|
||||||
|
run_assertion aggregate-success-no-status nonzero aggregate-success-no-status 'ASSERTED_NOT_READY'
|
||||||
|
run_assertion malformed nonzero malformed 'ASSERTED_NOT_READY'
|
||||||
|
run_assertion malformed-statuses-type nonzero malformed-statuses-type 'ASSERTED_NOT_READY'
|
||||||
|
run_assertion malformed-status-entry nonzero malformed-status-entry 'ASSERTED_NOT_READY'
|
||||||
|
run_assertion large-payload not126 large-success 'state=terminal-success'
|
||||||
|
run_assertion credential-unresolvable zero credential-unresolvable 'CANNOT_ASSERT'
|
||||||
|
run_assertion provider-unreachable zero unreachable 'CANNOT_ASSERT'
|
||||||
|
|
||||||
|
if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then
|
||||||
|
echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Merge cannot proceed without exact-head evidence. CANNOT_ASSERT is retryable exit 75,
|
||||||
|
# distinct from ASSERTED_NOT_READY (3/124), and still writes its audit record.
|
||||||
|
merge_audit_lines_before=$(wc -l < "$AUDIT_LOG")
|
||||||
|
set +e
|
||||||
|
merge_unreachable_output=$(MOSAIC_TEST_PURPOSE=merge run_guard unreachable "$AUDIT_LOG" 2>&1)
|
||||||
|
merge_unreachable_rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$merge_unreachable_rc" -ne 75 ]]; then
|
||||||
|
echo "FAIL merge-provider-unreachable: expected rc=75, got rc=$merge_unreachable_rc" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
if [[ "$merge_unreachable_output" != *"CANNOT_ASSERT"* ]]; then
|
||||||
|
echo "FAIL merge-provider-unreachable: expected loud CANNOT_ASSERT diagnostic" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
merge_audit_lines_after=$(wc -l < "$AUDIT_LOG")
|
||||||
|
if [[ "$merge_audit_lines_after" -le "$merge_audit_lines_before" ]]; then
|
||||||
|
echo "FAIL merge-provider-unreachable: expected an additional audit record" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A feature-branch push with no -B must inspect the checked-out feature branch.
|
||||||
|
if ! grep -q "/branches/$FEATURE_BRANCH" "$WORK_DIR/urls.log"; then
|
||||||
|
echo "FAIL implicit-branch: provider was not queried for $FEATURE_BRANCH" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Merge callers can pin both a fork repository and the exact reviewed head SHA.
|
||||||
|
exact_sha=0123456789abcdef0123456789abcdef01234567
|
||||||
|
: > "$WORK_DIR/urls.log"
|
||||||
|
run_assertion exact-fork-head zero success 'state=terminal-success' \
|
||||||
|
-B fix/rm-03-fixture -R contributor/widgets-fork --sha "$exact_sha"
|
||||||
|
if ! grep -q "/repos/contributor/widgets-fork/commits/$exact_sha/status" "$WORK_DIR/urls.log"; then
|
||||||
|
echo "FAIL exact-fork-head: status URL did not bind fork repository and exact SHA" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
if grep -q '/branches/' "$WORK_DIR/urls.log"; then
|
||||||
|
echo "FAIL exact-fork-head: explicit SHA must not be re-resolved through a branch" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Platform/repository discovery failures use the same audited CANNOT_ASSERT path.
|
||||||
|
audit_lines_before=$(wc -l < "$AUDIT_LOG")
|
||||||
|
git -C "$REPO_DIR" remote set-url origin https://gitlab.com/acme/widgets.git
|
||||||
|
set +e
|
||||||
|
unsupported_output=$(run_guard success "$AUDIT_LOG" 2>&1)
|
||||||
|
unsupported_rc=$?
|
||||||
|
set -e
|
||||||
|
git -C "$REPO_DIR" remote set-url origin https://git.example.test/acme/widgets.git
|
||||||
|
if [[ "$unsupported_rc" -ne 0 ]]; then
|
||||||
|
echo "FAIL unsupported-platform: expected degraded rc=0, got rc=$unsupported_rc" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
if [[ "$unsupported_output" != *"CANNOT_ASSERT"* ]]; then
|
||||||
|
echo "FAIL unsupported-platform: expected loud CANNOT_ASSERT diagnostic" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
audit_lines_after=$(wc -l < "$AUDIT_LOG")
|
||||||
|
if [[ "$audit_lines_after" -le "$audit_lines_before" ]]; then
|
||||||
|
echo "FAIL unsupported-platform: expected an additional audit record" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A degraded pass is forbidden if the audit receipt cannot be written.
|
||||||
|
mkdir -p "$WORK_DIR/not-a-directory"
|
||||||
|
printf 'file' > "$WORK_DIR/not-a-directory/parent"
|
||||||
|
set +e
|
||||||
|
audit_failure_output=$(run_guard unreachable "$WORK_DIR/not-a-directory/parent/audit.jsonl" 2>&1)
|
||||||
|
audit_failure_rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$audit_failure_rc" -eq 0 ]]; then
|
||||||
|
echo "FAIL audit-unavailable: expected rc!=0, got rc=0" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
if [[ "$audit_failure_output" != *"audit"* ]]; then
|
||||||
|
echo "FAIL audit-unavailable: expected loud audit failure diagnostic" >&2
|
||||||
|
failures=$((failures + 1))
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$failures" -ne 0 ]]; then
|
||||||
|
echo "ci-queue-wait tri-state regression failed ($failures assertions)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "ci-queue-wait tri-state regression passed (all outcome classes)"
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Regression harness for pr-merge.sh Gitea non-interactive tea empty identity fallback.
|
# Regression harness for pr-merge.sh Gitea exact-head API path and input safety.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -79,15 +79,24 @@ emit_response() {
|
|||||||
printf '200'
|
printf '200'
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/commits/0123456789abcdef0123456789abcdef01234567/status"* ]]; then
|
||||||
|
emit_response '{"state":"success","statuses":[{"context":"ci/test","status":"success"}]}'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123"* && "$args" != *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123"* && "$args" != *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
||||||
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock"},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
|
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock","sha":"0123456789abcdef0123456789abcdef01234567","repo":{"full_name":"mosaicstack/stack"}},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
|
||||||
if [[ "$post_data" != '{"Do":"squash"}' ]]; then
|
POST_DATA="$post_data" python3 - <<'PY'
|
||||||
echo "unexpected merge payload: $post_data" >&2
|
import json
|
||||||
exit 96
|
import os
|
||||||
fi
|
payload = json.loads(os.environ["POST_DATA"])
|
||||||
|
assert payload == {
|
||||||
|
"Do": "squash",
|
||||||
|
"head_commit_id": "0123456789abcdef0123456789abcdef01234567",
|
||||||
|
}, payload
|
||||||
|
PY
|
||||||
emit_response '{"merged":true,"message":"mock merge complete"}'
|
emit_response '{"merged":true,"message":"mock merge complete"}'
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
@@ -107,8 +116,8 @@ export GITEA_URL="https://git.mosaicstack.dev"
|
|||||||
export GITEA_TOKEN="redacted-test-token"
|
export GITEA_TOKEN="redacted-test-token"
|
||||||
|
|
||||||
OUTPUT="$SANDBOX/output.log"
|
OUTPUT="$SANDBOX/output.log"
|
||||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
|
||||||
echo "Expected pr-merge.sh to recover via Gitea API fallback." >&2
|
echo "Expected pr-merge.sh to use the exact-head Gitea API path." >&2
|
||||||
echo "--- output ---" >&2
|
echo "--- output ---" >&2
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||||
echo "--- mock log ---" >&2
|
echo "--- mock log ---" >&2
|
||||||
@@ -127,38 +136,6 @@ if grep -q 'redacted-test-token' "$OUTPUT"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cat > "$MOCK_BIN/tea" <<'EOF'
|
|
||||||
#!/bin/bash
|
|
||||||
set -euo pipefail
|
|
||||||
printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG"
|
|
||||||
printf '\n' >> "$PR_MERGE_TEST_LOG"
|
|
||||||
if [[ "$*" == *"login list"* ]]; then
|
|
||||||
echo '[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
if [[ "$*" == *"pr merge"* ]]; then
|
|
||||||
echo 'tea network timeout' >&2
|
|
||||||
exit 2
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
EOF
|
|
||||||
chmod +x "$MOCK_BIN/tea"
|
|
||||||
: > "$LOG_FILE"
|
|
||||||
if "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
|
||||||
echo "Expected arbitrary tea failure to remain blocking." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if grep -q '/api/v1/repos/mosaicstack/stack/pulls/123/merge' "$LOG_FILE"; then
|
|
||||||
echo "Arbitrary tea failure unexpectedly used Gitea API merge fallback." >&2
|
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if ! grep -q 'tea network timeout' "$OUTPUT"; then
|
|
||||||
echo "Expected arbitrary tea error to be preserved in output." >&2
|
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
cat > "$MOCK_BIN/tea" <<'EOF'
|
cat > "$MOCK_BIN/tea" <<'EOF'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -177,8 +154,8 @@ EOF
|
|||||||
chmod +x "$MOCK_BIN/tea"
|
chmod +x "$MOCK_BIN/tea"
|
||||||
unset GITEA_LOGIN
|
unset GITEA_LOGIN
|
||||||
: > "$LOG_FILE"
|
: > "$LOG_FILE"
|
||||||
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
|
||||||
echo "Expected missing tea login to use authenticated Gitea API fallback." >&2
|
echo "Expected the exact-head API path not to depend on a tea login." >&2
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -215,7 +192,7 @@ cd "$REPO_DIR"
|
|||||||
git remote set-url origin https://github.com/mosaicstack/stack.git
|
git remote set-url origin https://github.com/mosaicstack/stack.git
|
||||||
: > "$LOG_FILE"
|
: > "$LOG_FILE"
|
||||||
rm -f "$SENTINEL"
|
rm -f "$SENTINEL"
|
||||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
|
||||||
echo "Expected GitHub metacharacter PR number to be rejected." >&2
|
echo "Expected GitHub metacharacter PR number to be rejected." >&2
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -240,7 +217,7 @@ git remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
|||||||
export GITEA_LOGIN="git.mosaicstack.dev"
|
export GITEA_LOGIN="git.mosaicstack.dev"
|
||||||
: > "$LOG_FILE"
|
: > "$LOG_FILE"
|
||||||
rm -f "$SENTINEL"
|
rm -f "$SENTINEL"
|
||||||
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
|
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
|
||||||
echo "Expected Gitea metacharacter PR number to be rejected." >&2
|
echo "Expected Gitea metacharacter PR number to be rejected." >&2
|
||||||
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -260,4 +237,4 @@ if ! grep -q 'Invalid PR number' "$OUTPUT"; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "pr-merge.sh Gitea fallback regression passed"
|
echo "pr-merge.sh Gitea exact-head API regression passed"
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# shellcheck disable=SC2030,SC2031 # Provider arms isolate PATH/credentials in subshells.
|
||||||
|
# The commit whose CI was guarded must be the commit the provider atomically merges.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-head-pin}"
|
||||||
|
SHA=0123456789abcdef0123456789abcdef01234567
|
||||||
|
|
||||||
|
make_fixture() {
|
||||||
|
local name="$1" remote="$2"
|
||||||
|
local root="$WORK_DIR/$name"
|
||||||
|
local tools="$root/tools/git"
|
||||||
|
mkdir -p "$tools" "$root/repo"
|
||||||
|
cp "$SCRIPT_DIR/pr-merge.sh" "$tools/pr-merge.sh"
|
||||||
|
cp "$SCRIPT_DIR/detect-platform.sh" "$tools/detect-platform.sh"
|
||||||
|
git -C "$root/repo" init -q
|
||||||
|
git -C "$root/repo" remote add origin "$remote"
|
||||||
|
cat > "$tools/pr-metadata.sh" <<SH
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"contributor/widgets-fork"}'
|
||||||
|
SH
|
||||||
|
cat > "$tools/ci-queue-wait.sh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
exit 0
|
||||||
|
SH
|
||||||
|
chmod +x "$tools"/*.sh
|
||||||
|
}
|
||||||
|
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
make_fixture gitea https://git.example.test/acme/widgets.git
|
||||||
|
make_fixture github https://github.com/acme/widgets.git
|
||||||
|
|
||||||
|
cat > "$WORK_DIR/gitea/curl" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
payload=""
|
||||||
|
for ((i=1; i<=$#; i++)); do
|
||||||
|
if [[ "${!i}" == "-d" ]]; then
|
||||||
|
j=$((i + 1))
|
||||||
|
payload="${!j}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
printf '%s' "$payload" > "${MOSAIC_MERGE_PAYLOAD_LOG:?}"
|
||||||
|
printf '200'
|
||||||
|
SH
|
||||||
|
chmod +x "$WORK_DIR/gitea/curl"
|
||||||
|
|
||||||
|
set +e
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR/gitea/repo"
|
||||||
|
export PATH="$WORK_DIR/gitea:$PATH"
|
||||||
|
export GITEA_TOKEN=stub-token
|
||||||
|
export GITEA_URL=https://git.example.test
|
||||||
|
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||||
|
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload.json"
|
||||||
|
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123
|
||||||
|
) >"$WORK_DIR/gitea.out" 2>&1
|
||||||
|
gitea_rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$gitea_rc" -ne 0 ]]; then
|
||||||
|
echo "FAIL gitea-pin: merge fixture returned $gitea_rc" >&2
|
||||||
|
cat "$WORK_DIR/gitea.out" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
python3 - "$WORK_DIR/gitea-payload.json" "$SHA" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
payload = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||||
|
assert set(payload) <= {"Do", "head_commit_id", "delete_branch_after_merge"}, payload
|
||||||
|
assert payload.get("Do") == "squash", payload
|
||||||
|
assert payload.get("head_commit_id") == sys.argv[2], payload
|
||||||
|
PY
|
||||||
|
|
||||||
|
# A merge-gate verdict is commit-bound. A stale expected head must fail before merge.
|
||||||
|
wrong_sha=ffffffffffffffffffffffffffffffffffffffff
|
||||||
|
rm -f "$WORK_DIR/gitea-payload-stale.json"
|
||||||
|
set +e
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR/gitea/repo"
|
||||||
|
export PATH="$WORK_DIR/gitea:$PATH"
|
||||||
|
export GITEA_TOKEN=stub-token
|
||||||
|
export GITEA_URL=https://git.example.test
|
||||||
|
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||||
|
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-stale.json"
|
||||||
|
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --expect-head "$wrong_sha"
|
||||||
|
) >"$WORK_DIR/gitea-stale.out" 2>&1
|
||||||
|
stale_rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$stale_rc" -eq 0 ]] || [[ -e "$WORK_DIR/gitea-payload-stale.json" ]]; then
|
||||||
|
echo "FAIL stale-verdict: moved head was not refused before provider merge" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A merge-capable path cannot bypass the mandatory queue guard. The legacy
|
||||||
|
# --skip-queue-guard option must be rejected before any provider merge call.
|
||||||
|
cat > "$WORK_DIR/gitea/tools/git/ci-queue-wait.sh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
exit 99
|
||||||
|
SH
|
||||||
|
chmod +x "$WORK_DIR/gitea/tools/git/ci-queue-wait.sh"
|
||||||
|
rm -f "$WORK_DIR/gitea-payload-bypass.json"
|
||||||
|
set +e
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR/gitea/repo"
|
||||||
|
export PATH="$WORK_DIR/gitea:$PATH"
|
||||||
|
export GITEA_TOKEN=stub-token
|
||||||
|
export GITEA_URL=https://git.example.test
|
||||||
|
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||||
|
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-bypass.json"
|
||||||
|
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --skip-queue-guard
|
||||||
|
) >"$WORK_DIR/gitea-bypass.out" 2>&1
|
||||||
|
bypass_rc=$?
|
||||||
|
set -e
|
||||||
|
if [[ "$bypass_rc" -eq 0 ]] || [[ -e "$WORK_DIR/gitea-payload-bypass.json" ]]; then
|
||||||
|
echo "FAIL merge-bypass: --skip-queue-guard reached the provider merge path" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Dry-run is the only path that may omit the guard because it exits before the
|
||||||
|
# provider merge dispatch. Prove the exit and absence of a merge payload.
|
||||||
|
rm -f "$WORK_DIR/gitea-payload-dry-run.json"
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR/gitea/repo"
|
||||||
|
export PATH="$WORK_DIR/gitea:$PATH"
|
||||||
|
export GITEA_TOKEN=stub-token
|
||||||
|
export GITEA_URL=https://git.example.test
|
||||||
|
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
|
||||||
|
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-dry-run.json"
|
||||||
|
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --dry-run
|
||||||
|
) >"$WORK_DIR/gitea-dry-run.out" 2>&1
|
||||||
|
if [[ -e "$WORK_DIR/gitea-payload-dry-run.json" ]]; then
|
||||||
|
echo "FAIL dry-run: non-merging preflight reached the provider merge path" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > "$WORK_DIR/github/gh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
printf '%s\n' "$*" > "${MOSAIC_GH_MERGE_LOG:?}"
|
||||||
|
SH
|
||||||
|
chmod +x "$WORK_DIR/github/gh"
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR/github/repo"
|
||||||
|
export PATH="$WORK_DIR/github:$PATH"
|
||||||
|
export MOSAIC_GH_MERGE_LOG="$WORK_DIR/github-call.log"
|
||||||
|
"$WORK_DIR/github/tools/git/pr-merge.sh" -n 123
|
||||||
|
) >"$WORK_DIR/github.out" 2>&1
|
||||||
|
if ! grep -q -- "--match-head-commit $SHA" "$WORK_DIR/github-call.log"; then
|
||||||
|
echo "FAIL github-pin: merge command omitted --match-head-commit $SHA" >&2
|
||||||
|
cat "$WORK_DIR/github-call.log" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "PR merge exact-head pin regression passed (Gitea + GitHub)"
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# RM-03: pr-merge must guard the PR head branch, not its main base branch.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-queue-branch}"
|
||||||
|
FIXTURE_DIR="$WORK_DIR/tools/git"
|
||||||
|
CALL_LOG="$WORK_DIR/queue-call.log"
|
||||||
|
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
mkdir -p "$FIXTURE_DIR"
|
||||||
|
cp "$SCRIPT_DIR/pr-merge.sh" "$FIXTURE_DIR/pr-merge.sh"
|
||||||
|
cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
|
||||||
|
|
||||||
|
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}'
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
printf '%s\n' "$*" > "${MOSAIC_QUEUE_CALL_LOG:?}"
|
||||||
|
exit 42
|
||||||
|
SH
|
||||||
|
chmod +x "$FIXTURE_DIR"/*.sh
|
||||||
|
|
||||||
|
set +e
|
||||||
|
(
|
||||||
|
cd "$WORK_DIR"
|
||||||
|
export MOSAIC_QUEUE_CALL_LOG="$CALL_LOG"
|
||||||
|
"$FIXTURE_DIR/pr-merge.sh" -n 123
|
||||||
|
) >/dev/null 2>&1
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [[ "$rc" -ne 42 ]]; then
|
||||||
|
echo "FAIL: expected queue stub rc=42 to propagate, got $rc" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -s "$CALL_LOG" ]]; then
|
||||||
|
echo "FAIL: merge wrapper did not invoke the queue guard" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -q -- '-B fix/rm-03-fixture' "$CALL_LOG"; then
|
||||||
|
echo "FAIL: merge queue guard did not receive PR head branch" >&2
|
||||||
|
cat "$CALL_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q -- '-B main' "$CALL_LOG"; then
|
||||||
|
echo "FAIL: merge queue guard still received the main base branch" >&2
|
||||||
|
cat "$CALL_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -q -- '-R contributor/widgets-fork' "$CALL_LOG"; then
|
||||||
|
echo "FAIL: merge queue guard did not receive the fork head repository" >&2
|
||||||
|
cat "$CALL_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -q -- '--sha 0123456789abcdef0123456789abcdef01234567' "$CALL_LOG"; then
|
||||||
|
echo "FAIL: merge queue guard did not receive the exact PR head SHA" >&2
|
||||||
|
cat "$CALL_LOG" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "pr-merge queue branch/repository/SHA regression passed"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaicstack/mosaic",
|
"name": "@mosaicstack/mosaic",
|
||||||
"version": "0.0.48",
|
"version": "0.0.49",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||||
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
|
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mosaicstack/brain": "workspace:*",
|
"@mosaicstack/brain": "workspace:*",
|
||||||
|
|||||||
@@ -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: '' });
|
||||||
});
|
});
|
||||||
@@ -156,10 +156,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();
|
||||||
@@ -199,7 +218,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 () => {
|
||||||
|
|||||||
@@ -13,16 +13,22 @@ import {
|
|||||||
type SkillSyncResult as ClaudeSkillSyncResult,
|
type SkillSyncResult as ClaudeSkillSyncResult,
|
||||||
} from '../commands/skill.js';
|
} from '../commands/skill.js';
|
||||||
|
|
||||||
/**
|
function frameworkScriptPath(mosaicHome: string, name: string): string {
|
||||||
* Link runtime assets. Returns a warning string when the install-ordering
|
const currentPath = join(mosaicHome, 'tools', '_scripts', name);
|
||||||
* guard (#869 Point-1 C2) reported a degraded outcome — i.e. the
|
if (existsSync(currentPath)) return currentPath;
|
||||||
* lease-enforcement hooks were NOT wired into ~/.claude/settings.json because
|
|
||||||
* this host could not confirm it can activate them — so the caller can
|
// Backward-compatible fallback for pre-migration installs that still have bin/.
|
||||||
* surface it via `p.warn(...)` instead of it being swallowed by `stdio:
|
const legacyPath = join(mosaicHome, 'bin', name);
|
||||||
* 'pipe'`. Non-fatal either way: the wizard always continues.
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Link runtime assets and surface a non-zero install-ordering guard outcome. */
|
||||||
function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): string | undefined {
|
function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): string | undefined {
|
||||||
const script = join(mosaicHome, 'bin', 'mosaic-link-runtime-assets');
|
const script = frameworkScriptPath(mosaicHome, 'mosaic-link-runtime-assets');
|
||||||
if (!existsSync(script)) return undefined;
|
if (!existsSync(script)) return undefined;
|
||||||
try {
|
try {
|
||||||
const result = spawnSync('bash', [script], {
|
const result = spawnSync('bash', [script], {
|
||||||
@@ -69,7 +75,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,
|
||||||
@@ -117,7 +123,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' };
|
||||||
}
|
}
|
||||||
@@ -170,11 +176,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();
|
||||||
@@ -269,7 +288,12 @@ export async function finalizeStage(
|
|||||||
// 7. PATH setup
|
// 7. PATH setup
|
||||||
const pathAction = setupPath(state.mosaicHome, p);
|
const pathAction = setupPath(state.mosaicHome, p);
|
||||||
|
|
||||||
// 8. Summary
|
let summaryShown = false;
|
||||||
|
const showSummary = () => {
|
||||||
|
if (summaryShown) return;
|
||||||
|
summaryShown = true;
|
||||||
|
|
||||||
|
// 7. Summary
|
||||||
const skillsSummary = skillsResult.success
|
const skillsSummary = skillsResult.success
|
||||||
? skillsResult.installedCount > 0
|
? skillsResult.installedCount > 0
|
||||||
? `${skillsResult.installedCount.toString()} installed`
|
? `${skillsResult.installedCount.toString()} installed`
|
||||||
@@ -294,7 +318,7 @@ export async function finalizeStage(
|
|||||||
|
|
||||||
p.note(summary.join('\n'), 'Installation Summary');
|
p.note(summary.join('\n'), 'Installation Summary');
|
||||||
|
|
||||||
// 9. Next steps
|
// 8. Next steps
|
||||||
const nextSteps: string[] = [];
|
const nextSteps: string[] = [];
|
||||||
if (pathAction === 'added') {
|
if (pathAction === 'added') {
|
||||||
const profilePath = getShellProfilePath();
|
const profilePath = getShellProfilePath();
|
||||||
@@ -309,4 +333,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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+222
@@ -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/[email protected]"*)
|
||||||
|
echo "0.0.49-next.999" > "$STATE/mosaic"
|
||||||
|
;;
|
||||||
|
*"@mosaicstack/[email protected]"*)
|
||||||
|
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/[email protected]' "$LOG"
|
||||||
|
grep -qF 'install -g @mosaicstack/[email protected]' "$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/[email protected]' "$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"
|
||||||
+192
-11
@@ -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,8 +71,18 @@ 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
|
||||||
|
|
||||||
installer_usage() {
|
installer_usage() {
|
||||||
printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref <branch>] [--dev] [--yes|-y] [--no-auto-launch] [--uninstall]\n' >&2
|
printf 'Usage: install.sh [--check] [--framework] [--cli] [--ref <branch>] [--next] [--dev] [--yes|-y] [--no-auto-launch] [--uninstall]\n' >&2
|
||||||
}
|
}
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
@@ -82,9 +102,11 @@ while [[ $# -gt 0 ]]; do
|
|||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
GIT_REF="$2"
|
GIT_REF="$2"
|
||||||
|
GIT_REF_EXPLICIT=true
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--dev) FLAG_DEV=true; shift ;;
|
--dev) FLAG_DEV=true; shift ;;
|
||||||
|
--next) FLAG_NEXT=true; if [[ "$GIT_REF_EXPLICIT" == "false" ]]; then GIT_REF="next"; fi; shift ;;
|
||||||
--yes|-y) FLAG_YES=true; shift ;;
|
--yes|-y) FLAG_YES=true; shift ;;
|
||||||
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
|
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
|
||||||
--uninstall) FLAG_UNINSTALL=true; shift ;;
|
--uninstall) FLAG_UNINSTALL=true; shift ;;
|
||||||
@@ -96,12 +118,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"
|
||||||
|
|
||||||
@@ -116,6 +150,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.
|
||||||
@@ -179,7 +227,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"
|
||||||
@@ -235,6 +283,22 @@ fail() { echo "${R}✖${RESET} $*" >&2; }
|
|||||||
dim() { echo "${DIM}$*${RESET}"; }
|
dim() { echo "${DIM}$*${RESET}"; }
|
||||||
step() { printf '\n%s%s%s\n' "$BOLD" "$*" "$RESET"; }
|
step() { printf '\n%s%s%s\n' "$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() {
|
||||||
@@ -257,10 +321,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];
|
||||||
@@ -353,8 +450,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."
|
||||||
@@ -376,6 +473,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
|
||||||
@@ -409,7 +549,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
|
||||||
@@ -476,8 +616,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
|
||||||
@@ -489,7 +633,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
|
||||||
@@ -500,6 +656,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
|
||||||
@@ -516,6 +678,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"
|
||||||
@@ -624,7 +803,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
|
||||||
@@ -649,7 +828,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];
|
||||||
@@ -674,9 +853,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